mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-09 16:17:01 +02:00
Move version out of SDK. For now it's a copy rather than move: the part not addressed by this change is sdk/helper/useragent.String, which we'll want to remove in favour of PluginString. That will have to wait until we've removed uses of useragent.String from all builtins.
829 lines
26 KiB
Go
829 lines
26 KiB
Go
package framework
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
log "github.com/hashicorp/go-hclog"
|
|
"github.com/hashicorp/vault/sdk/helper/wrapping"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
"github.com/mitchellh/mapstructure"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md
|
|
const OASVersion = "3.0.2"
|
|
|
|
// NewOASDocument returns an empty OpenAPI document.
|
|
func NewOASDocument(version string) *OASDocument {
|
|
return &OASDocument{
|
|
Version: OASVersion,
|
|
Info: OASInfo{
|
|
Title: "HashiCorp Vault API",
|
|
Description: "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
|
Version: version,
|
|
License: OASLicense{
|
|
Name: "Mozilla Public License 2.0",
|
|
URL: "https://www.mozilla.org/en-US/MPL/2.0",
|
|
},
|
|
},
|
|
Paths: make(map[string]*OASPathItem),
|
|
Components: OASComponents{
|
|
Schemas: make(map[string]*OASSchema),
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewOASDocumentFromMap builds an OASDocument from an existing map version of a document.
|
|
// If a document has been decoded from JSON or received from a plugin, it will be as a map[string]interface{}
|
|
// and needs special handling beyond the default mapstructure decoding.
|
|
func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) {
|
|
// The Responses map uses integer keys (the response code), but once translated into JSON
|
|
// (e.g. during the plugin transport) these become strings. mapstructure will not coerce these back
|
|
// to integers without a custom decode hook.
|
|
decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) {
|
|
// Only alter data if:
|
|
// 1. going from string to int
|
|
// 2. string represent an int in status code range (100-599)
|
|
if src.Kind() == reflect.String && tgt.Kind() == reflect.Int {
|
|
if input, ok := inputRaw.(string); ok {
|
|
if intval, err := strconv.Atoi(input); err == nil {
|
|
if intval >= 100 && intval < 600 {
|
|
return intval, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return inputRaw, nil
|
|
}
|
|
|
|
doc := new(OASDocument)
|
|
|
|
config := &mapstructure.DecoderConfig{
|
|
DecodeHook: decodeHook,
|
|
Result: doc,
|
|
}
|
|
|
|
decoder, err := mapstructure.NewDecoder(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := decoder.Decode(input); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
type OASDocument struct {
|
|
Version string `json:"openapi" mapstructure:"openapi"`
|
|
Info OASInfo `json:"info"`
|
|
Paths map[string]*OASPathItem `json:"paths"`
|
|
Components OASComponents `json:"components"`
|
|
}
|
|
|
|
type OASComponents struct {
|
|
Schemas map[string]*OASSchema `json:"schemas"`
|
|
}
|
|
|
|
type OASInfo struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Version string `json:"version"`
|
|
License OASLicense `json:"license"`
|
|
}
|
|
|
|
type OASLicense struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type OASPathItem struct {
|
|
Description string `json:"description,omitempty"`
|
|
Parameters []OASParameter `json:"parameters,omitempty"`
|
|
Sudo bool `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"`
|
|
Unauthenticated bool `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"`
|
|
CreateSupported bool `json:"x-vault-createSupported,omitempty" mapstructure:"x-vault-createSupported"`
|
|
DisplayNavigation bool `json:"x-vault-displayNavigation,omitempty" mapstructure:"x-vault-displayNavigation"`
|
|
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"`
|
|
|
|
Get *OASOperation `json:"get,omitempty"`
|
|
Post *OASOperation `json:"post,omitempty"`
|
|
Delete *OASOperation `json:"delete,omitempty"`
|
|
}
|
|
|
|
// NewOASOperation creates an empty OpenAPI Operations object.
|
|
func NewOASOperation() *OASOperation {
|
|
return &OASOperation{
|
|
Responses: make(map[int]*OASResponse),
|
|
}
|
|
}
|
|
|
|
type OASOperation struct {
|
|
Summary string `json:"summary,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
OperationID string `json:"operationId,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Parameters []OASParameter `json:"parameters,omitempty"`
|
|
RequestBody *OASRequestBody `json:"requestBody,omitempty"`
|
|
Responses map[int]*OASResponse `json:"responses"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
}
|
|
|
|
type OASParameter struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
In string `json:"in"`
|
|
Schema *OASSchema `json:"schema,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
}
|
|
|
|
type OASRequestBody struct {
|
|
Description string `json:"description,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Content OASContent `json:"content,omitempty"`
|
|
}
|
|
|
|
type OASContent map[string]*OASMediaTypeObject
|
|
|
|
type OASMediaTypeObject struct {
|
|
Schema *OASSchema `json:"schema,omitempty"`
|
|
}
|
|
|
|
type OASSchema struct {
|
|
Ref string `json:"$ref,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Properties map[string]*OASSchema `json:"properties,omitempty"`
|
|
|
|
// Required is a list of keys in Properties that are required to be present. This is a different
|
|
// approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'.
|
|
Required []string `json:"required,omitempty"`
|
|
|
|
Items *OASSchema `json:"items,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
Enum []interface{} `json:"enum,omitempty"`
|
|
Default interface{} `json:"default,omitempty"`
|
|
Example interface{} `json:"example,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
// DisplayName string `json:"x-vault-displayName,omitempty" mapstructure:"x-vault-displayName,omitempty"`
|
|
DisplayValue interface{} `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"`
|
|
DisplaySensitive bool `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"`
|
|
DisplayGroup string `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"`
|
|
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,omitempty"`
|
|
}
|
|
|
|
type OASResponse struct {
|
|
Description string `json:"description"`
|
|
Content OASContent `json:"content,omitempty"`
|
|
}
|
|
|
|
var OASStdRespOK = &OASResponse{
|
|
Description: "OK",
|
|
}
|
|
|
|
var OASStdRespNoContent = &OASResponse{
|
|
Description: "empty body",
|
|
}
|
|
|
|
// Regex for handling optional and named parameters in paths, and string cleanup.
|
|
// Predefined here to avoid substantial recompilation.
|
|
|
|
// Capture optional path elements in ungreedy (?U) fashion
|
|
// Both "(leases/)?renew" and "(/(?P<name>.+))?" formats are detected
|
|
var optRe = regexp.MustCompile(`(?U)\([^(]*\)\?|\(/\(\?P<[^(]*\)\)\?`)
|
|
|
|
var (
|
|
altFieldsGroupRe = regexp.MustCompile(`\(\?P<\w+>\w+(\|\w+)+\)`) // Match named groups that limit options, e.g. "(?<foo>a|b|c)"
|
|
altFieldsRe = regexp.MustCompile(`\w+(\|\w+)+`) // Match an options set, e.g. "a|b|c"
|
|
altRe = regexp.MustCompile(`\((.*)\|(.*)\)`) // Capture alternation elements, e.g. "(raw/?$|raw/(?P<path>.+))"
|
|
altRootsRe = regexp.MustCompile(`^\(([\w\-_]+(?:\|[\w\-_]+)+)\)(/.*)$`) // Pattern starting with alts, e.g. "(root1|root2)/(?P<name>regex)"
|
|
cleanCharsRe = regexp.MustCompile("[()^$?]") // Set of regex characters that will be stripped during cleaning
|
|
cleanSuffixRe = regexp.MustCompile(`/\?\$?$`) // Path suffix patterns that will be stripped during cleaning
|
|
nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters
|
|
pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
|
|
reqdRe = regexp.MustCompile(`\(?\?P<(\w+)>[^)]*\)?`) // Capture required parameters, e.g. "(?P<name>regex)"
|
|
wsRe = regexp.MustCompile(`\s+`) // Match whitespace, to be compressed during cleaning
|
|
)
|
|
|
|
// documentPaths parses all paths in a framework.Backend into OpenAPI paths.
|
|
func documentPaths(backend *Backend, requestResponsePrefix string, genericMountPaths bool, doc *OASDocument) error {
|
|
for _, p := range backend.Paths {
|
|
if err := documentPath(p, backend.SpecialPaths(), requestResponsePrefix, genericMountPaths, backend.BackendType, doc); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// documentPath parses a framework.Path into one or more OpenAPI paths.
|
|
func documentPath(p *Path, specialPaths *logical.Paths, requestResponsePrefix string, genericMountPaths bool, backendType logical.BackendType, doc *OASDocument) error {
|
|
var sudoPaths []string
|
|
var unauthPaths []string
|
|
|
|
if specialPaths != nil {
|
|
sudoPaths = specialPaths.Root
|
|
unauthPaths = specialPaths.Unauthenticated
|
|
}
|
|
|
|
// Convert optional parameters into distinct patterns to be processed independently.
|
|
paths := expandPattern(p.Pattern)
|
|
|
|
for _, path := range paths {
|
|
// Construct a top level PathItem which will be populated as the path is processed.
|
|
pi := OASPathItem{
|
|
Description: cleanString(p.HelpSynopsis),
|
|
}
|
|
|
|
pi.Sudo = specialPathMatch(path, sudoPaths)
|
|
pi.Unauthenticated = specialPathMatch(path, unauthPaths)
|
|
pi.DisplayAttrs = p.DisplayAttrs
|
|
|
|
// If the newer style Operations map isn't defined, create one from the legacy fields.
|
|
operations := p.Operations
|
|
if operations == nil {
|
|
operations = make(map[logical.Operation]OperationHandler)
|
|
|
|
for opType, cb := range p.Callbacks {
|
|
operations[opType] = &PathOperation{
|
|
Callback: cb,
|
|
Summary: p.HelpSynopsis,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process path and header parameters, which are common to all operations.
|
|
// Body fields will be added to individual operations.
|
|
pathFields, bodyFields := splitFields(p.Fields, path)
|
|
|
|
if genericMountPaths && requestResponsePrefix != "system" && requestResponsePrefix != "identity" {
|
|
// Add mount path as a parameter
|
|
p := OASParameter{
|
|
Name: "mountPath",
|
|
Description: "Path that the backend was mounted at",
|
|
In: "path",
|
|
Schema: &OASSchema{
|
|
Type: "string",
|
|
},
|
|
Required: true,
|
|
}
|
|
|
|
pi.Parameters = append(pi.Parameters, p)
|
|
}
|
|
|
|
for name, field := range pathFields {
|
|
location := "path"
|
|
required := true
|
|
|
|
if field == nil {
|
|
continue
|
|
}
|
|
|
|
if field.Query {
|
|
location = "query"
|
|
required = false
|
|
}
|
|
|
|
t := convertType(field.Type)
|
|
p := OASParameter{
|
|
Name: name,
|
|
Description: cleanString(field.Description),
|
|
In: location,
|
|
Schema: &OASSchema{
|
|
Type: t.baseType,
|
|
Pattern: t.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
DisplayAttrs: field.DisplayAttrs,
|
|
},
|
|
Required: required,
|
|
Deprecated: field.Deprecated,
|
|
}
|
|
pi.Parameters = append(pi.Parameters, p)
|
|
}
|
|
|
|
// Sort parameters for a stable output
|
|
sort.Slice(pi.Parameters, func(i, j int) bool {
|
|
return strings.ToLower(pi.Parameters[i].Name) < strings.ToLower(pi.Parameters[j].Name)
|
|
})
|
|
|
|
// Process each supported operation by building up an Operation object
|
|
// with descriptions, properties and examples from the framework.Path data.
|
|
for opType, opHandler := range operations {
|
|
props := opHandler.Properties()
|
|
if props.Unpublished {
|
|
continue
|
|
}
|
|
|
|
if opType == logical.CreateOperation {
|
|
pi.CreateSupported = true
|
|
|
|
// If both Create and Update are defined, only process Update.
|
|
if operations[logical.UpdateOperation] != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// If both List and Read are defined, only process Read.
|
|
if opType == logical.ListOperation && operations[logical.ReadOperation] != nil {
|
|
continue
|
|
}
|
|
|
|
op := NewOASOperation()
|
|
|
|
op.Summary = props.Summary
|
|
op.Description = props.Description
|
|
op.Deprecated = props.Deprecated
|
|
|
|
// Add any fields not present in the path as body parameters for POST.
|
|
if opType == logical.CreateOperation || opType == logical.UpdateOperation {
|
|
s := &OASSchema{
|
|
Type: "object",
|
|
Properties: make(map[string]*OASSchema),
|
|
Required: make([]string, 0),
|
|
}
|
|
|
|
for name, field := range bodyFields {
|
|
// Removing this field from the spec as it is deprecated in favor of using "sha256"
|
|
// The duplicate sha_256 and sha256 in these paths cause issues with codegen
|
|
if name == "sha_256" && strings.Contains(path, "plugins/catalog/") {
|
|
continue
|
|
}
|
|
|
|
openapiField := convertType(field.Type)
|
|
if field.Required {
|
|
s.Required = append(s.Required, name)
|
|
}
|
|
|
|
p := OASSchema{
|
|
Type: openapiField.baseType,
|
|
Description: cleanString(field.Description),
|
|
Format: openapiField.format,
|
|
Pattern: openapiField.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
Deprecated: field.Deprecated,
|
|
DisplayAttrs: field.DisplayAttrs,
|
|
}
|
|
if openapiField.baseType == "array" {
|
|
p.Items = &OASSchema{
|
|
Type: openapiField.items,
|
|
}
|
|
}
|
|
s.Properties[name] = &p
|
|
}
|
|
|
|
// If examples were given, use the first one as the sample
|
|
// of this schema.
|
|
if len(props.Examples) > 0 {
|
|
s.Example = props.Examples[0].Data
|
|
}
|
|
|
|
// Set the final request body. Only JSON request data is supported.
|
|
if len(s.Properties) > 0 || s.Example != nil {
|
|
requestName := constructRequestResponseName(path, requestResponsePrefix, "Request")
|
|
doc.Components.Schemas[requestName] = s
|
|
op.RequestBody = &OASRequestBody{
|
|
Required: true,
|
|
Content: OASContent{
|
|
"application/json": &OASMediaTypeObject{
|
|
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// LIST is represented as GET with a `list` query parameter.
|
|
if opType == logical.ListOperation {
|
|
// Only accepts List (due to the above skipping of ListOperations that also have ReadOperations)
|
|
op.Parameters = append(op.Parameters, OASParameter{
|
|
Name: "list",
|
|
Description: "Must be set to `true`",
|
|
Required: true,
|
|
In: "query",
|
|
Schema: &OASSchema{Type: "string", Enum: []interface{}{"true"}},
|
|
})
|
|
} else if opType == logical.ReadOperation && operations[logical.ListOperation] != nil {
|
|
// Accepts both Read and List
|
|
op.Parameters = append(op.Parameters, OASParameter{
|
|
Name: "list",
|
|
Description: "Return a list if `true`",
|
|
In: "query",
|
|
Schema: &OASSchema{Type: "string"},
|
|
})
|
|
}
|
|
|
|
// Add tags based on backend type
|
|
var tags []string
|
|
switch backendType {
|
|
case logical.TypeLogical:
|
|
tags = []string{"secrets"}
|
|
case logical.TypeCredential:
|
|
tags = []string{"auth"}
|
|
}
|
|
|
|
op.Tags = append(op.Tags, tags...)
|
|
|
|
// Set default responses.
|
|
if len(props.Responses) == 0 {
|
|
if opType == logical.DeleteOperation {
|
|
op.Responses[204] = OASStdRespNoContent
|
|
} else {
|
|
op.Responses[200] = OASStdRespOK
|
|
}
|
|
}
|
|
|
|
// Add any defined response details.
|
|
for code, responses := range props.Responses {
|
|
var description string
|
|
content := make(OASContent)
|
|
|
|
for i, resp := range responses {
|
|
if i == 0 {
|
|
description = resp.Description
|
|
}
|
|
if resp.Example != nil {
|
|
mediaType := resp.MediaType
|
|
if mediaType == "" {
|
|
mediaType = "application/json"
|
|
}
|
|
|
|
// create a version of the response that will not emit null items
|
|
cr := cleanResponse(resp.Example)
|
|
|
|
// Only one example per media type is allowed, so first one wins
|
|
if _, ok := content[mediaType]; !ok {
|
|
content[mediaType] = &OASMediaTypeObject{
|
|
Schema: &OASSchema{
|
|
Example: cr,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
responseSchema := &OASSchema{
|
|
Type: "object",
|
|
Properties: make(map[string]*OASSchema),
|
|
}
|
|
|
|
for name, field := range resp.Fields {
|
|
openapiField := convertType(field.Type)
|
|
p := OASSchema{
|
|
Type: openapiField.baseType,
|
|
Description: cleanString(field.Description),
|
|
Format: openapiField.format,
|
|
Pattern: openapiField.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
Deprecated: field.Deprecated,
|
|
DisplayAttrs: field.DisplayAttrs,
|
|
}
|
|
if openapiField.baseType == "array" {
|
|
p.Items = &OASSchema{
|
|
Type: openapiField.items,
|
|
}
|
|
}
|
|
responseSchema.Properties[name] = &p
|
|
}
|
|
|
|
if len(resp.Fields) != 0 {
|
|
responseName := constructRequestResponseName(path, requestResponsePrefix, "Response")
|
|
doc.Components.Schemas[responseName] = responseSchema
|
|
content = OASContent{
|
|
"application/json": &OASMediaTypeObject{
|
|
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
op.Responses[code] = &OASResponse{
|
|
Description: description,
|
|
Content: content,
|
|
}
|
|
}
|
|
|
|
switch opType {
|
|
case logical.CreateOperation, logical.UpdateOperation:
|
|
pi.Post = op
|
|
case logical.ReadOperation, logical.ListOperation:
|
|
pi.Get = op
|
|
case logical.DeleteOperation:
|
|
pi.Delete = op
|
|
}
|
|
}
|
|
|
|
doc.Paths["/"+path] = &pi
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// constructRequestResponseName joins the given path with prefix & suffix into
|
|
// a CamelCase request or response name.
|
|
//
|
|
// For example, path=/config/lease/{name}, prefix="secret", suffix="request"
|
|
// will result in "SecretConfigLeaseRequest"
|
|
func constructRequestResponseName(path, prefix, suffix string) string {
|
|
var b strings.Builder
|
|
|
|
title := cases.Title(language.English)
|
|
|
|
b.WriteString(title.String(prefix))
|
|
|
|
// split the path by / _ - separators
|
|
for _, token := range strings.FieldsFunc(path, func(r rune) bool {
|
|
return r == '/' || r == '_' || r == '-'
|
|
}) {
|
|
// exclude request fields
|
|
if !strings.ContainsAny(token, "{}") {
|
|
b.WriteString(title.String(token))
|
|
}
|
|
}
|
|
|
|
b.WriteString(suffix)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func specialPathMatch(path string, specialPaths []string) bool {
|
|
// Test for exact or prefix match of special paths.
|
|
for _, sp := range specialPaths {
|
|
if sp == path ||
|
|
(strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1])) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// expandPattern expands a regex pattern by generating permutations of any optional parameters
|
|
// and changing named parameters into their {openapi} equivalents.
|
|
func expandPattern(pattern string) []string {
|
|
var paths []string
|
|
|
|
// Determine if the pattern starts with an alternation for multiple roots
|
|
// example (root1|root2)/(?P<name>regex) -> match['(root1|root2)/(?P<name>regex)','root1|root2','/(?P<name>regex)']
|
|
match := altRootsRe.FindStringSubmatch(pattern)
|
|
if len(match) == 3 {
|
|
var expandedRoots []string
|
|
for _, root := range strings.Split(match[1], "|") {
|
|
expandedRoots = append(expandedRoots, expandPattern(root+match[2])...)
|
|
}
|
|
return expandedRoots
|
|
}
|
|
|
|
// GenericNameRegex adds a regex that complicates our parsing. It is much easier to
|
|
// detect and remove it now than to compensate for in the other regexes.
|
|
//
|
|
// example: (?P<foo>\\w(([\\w-.]+)?\\w)?) -> (?P<foo>)
|
|
base := GenericNameRegex("")
|
|
start := strings.Index(base, ">")
|
|
end := strings.LastIndex(base, ")")
|
|
regexToRemove := ""
|
|
if start != -1 && end != -1 && end > start {
|
|
regexToRemove = base[start+1 : end]
|
|
}
|
|
pattern = strings.ReplaceAll(pattern, regexToRemove, "")
|
|
|
|
// Simplify named fields that have limited options, e.g. (?P<foo>a|b|c) -> (<P<foo>.+)
|
|
pattern = altFieldsGroupRe.ReplaceAllStringFunc(pattern, func(s string) string {
|
|
return altFieldsRe.ReplaceAllString(s, ".+")
|
|
})
|
|
|
|
// Initialize paths with the original pattern or the halves of an
|
|
// alternation, which is also present in some patterns.
|
|
matches := altRe.FindAllStringSubmatch(pattern, -1)
|
|
if len(matches) > 0 {
|
|
paths = []string{matches[0][1], matches[0][2]}
|
|
} else {
|
|
paths = []string{pattern}
|
|
}
|
|
|
|
// Expand all optional regex elements into two paths. This approach is really only useful up to 2 optional
|
|
// groups, but we probably don't want to deal with the exponential increase beyond that anyway.
|
|
for i := 0; i < len(paths); i++ {
|
|
p := paths[i]
|
|
|
|
// match is a 2-element slice that will have a start and end index
|
|
// for the left-most match of a regex of form: (lease/)?
|
|
match := optRe.FindStringIndex(p)
|
|
|
|
if match != nil {
|
|
// create a path that includes the optional element but without
|
|
// parenthesis or the '?' character.
|
|
paths[i] = p[:match[0]] + p[match[0]+1:match[1]-2] + p[match[1]:]
|
|
|
|
// create a path that excludes the optional element.
|
|
paths = append(paths, p[:match[0]]+p[match[1]:])
|
|
i--
|
|
}
|
|
}
|
|
|
|
// Replace named parameters (?P<foo>) with {foo}
|
|
var replacedPaths []string
|
|
|
|
for _, path := range paths {
|
|
result := reqdRe.FindAllStringSubmatch(path, -1)
|
|
if result != nil {
|
|
for _, p := range result {
|
|
par := p[1]
|
|
path = strings.Replace(path, p[0], fmt.Sprintf("{%s}", par), 1)
|
|
}
|
|
}
|
|
// Final cleanup
|
|
path = cleanSuffixRe.ReplaceAllString(path, "")
|
|
path = cleanCharsRe.ReplaceAllString(path, "")
|
|
replacedPaths = append(replacedPaths, path)
|
|
}
|
|
|
|
return replacedPaths
|
|
}
|
|
|
|
// schemaType is a subset of the JSON Schema elements used as a target
|
|
// for conversions from Vault's standard FieldTypes.
|
|
type schemaType struct {
|
|
baseType string
|
|
items string
|
|
format string
|
|
pattern string
|
|
}
|
|
|
|
// convertType translates a FieldType into an OpenAPI type.
|
|
// In the case of arrays, a subtype is returned as well.
|
|
func convertType(t FieldType) schemaType {
|
|
ret := schemaType{}
|
|
|
|
switch t {
|
|
case TypeString, TypeHeader:
|
|
ret.baseType = "string"
|
|
case TypeNameString:
|
|
ret.baseType = "string"
|
|
ret.pattern = `\w([\w-.]*\w)?`
|
|
case TypeLowerCaseString:
|
|
ret.baseType = "string"
|
|
ret.format = "lowercase"
|
|
case TypeInt:
|
|
ret.baseType = "integer"
|
|
case TypeInt64:
|
|
ret.baseType = "integer"
|
|
ret.format = "int64"
|
|
case TypeDurationSecond, TypeSignedDurationSecond:
|
|
ret.baseType = "integer"
|
|
ret.format = "seconds"
|
|
case TypeBool:
|
|
ret.baseType = "boolean"
|
|
case TypeMap:
|
|
ret.baseType = "object"
|
|
ret.format = "map"
|
|
case TypeKVPairs:
|
|
ret.baseType = "object"
|
|
ret.format = "kvpairs"
|
|
case TypeSlice:
|
|
ret.baseType = "array"
|
|
ret.items = "object"
|
|
case TypeStringSlice, TypeCommaStringSlice:
|
|
ret.baseType = "array"
|
|
ret.items = "string"
|
|
case TypeCommaIntSlice:
|
|
ret.baseType = "array"
|
|
ret.items = "integer"
|
|
case TypeTime:
|
|
ret.baseType = "string"
|
|
ret.format = "date-time"
|
|
case TypeFloat:
|
|
ret.baseType = "number"
|
|
ret.format = "float"
|
|
default:
|
|
log.L().Warn("error parsing field type", "type", t)
|
|
ret.format = "unknown"
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
// cleanString prepares s for inclusion in the output
|
|
func cleanString(s string) string {
|
|
// clean leading/trailing whitespace, and replace whitespace runs into a single space
|
|
s = strings.TrimSpace(s)
|
|
s = wsRe.ReplaceAllString(s, " ")
|
|
return s
|
|
}
|
|
|
|
// splitFields partitions fields into path and body groups
|
|
// The input pattern is expected to have been run through expandPattern,
|
|
// with paths parameters denotes in {braces}.
|
|
func splitFields(allFields map[string]*FieldSchema, pattern string) (pathFields, bodyFields map[string]*FieldSchema) {
|
|
pathFields = make(map[string]*FieldSchema)
|
|
bodyFields = make(map[string]*FieldSchema)
|
|
|
|
for _, match := range pathFieldsRe.FindAllStringSubmatch(pattern, -1) {
|
|
name := match[1]
|
|
pathFields[name] = allFields[name]
|
|
}
|
|
|
|
for name, field := range allFields {
|
|
if _, ok := pathFields[name]; !ok {
|
|
if field.Query {
|
|
pathFields[name] = field
|
|
} else {
|
|
bodyFields[name] = field
|
|
}
|
|
}
|
|
}
|
|
|
|
return pathFields, bodyFields
|
|
}
|
|
|
|
// cleanedResponse is identical to logical.Response but with nulls
|
|
// removed from from JSON encoding
|
|
type cleanedResponse struct {
|
|
Secret *logical.Secret `json:"secret,omitempty"`
|
|
Auth *logical.Auth `json:"auth,omitempty"`
|
|
Data map[string]interface{} `json:"data,omitempty"`
|
|
Redirect string `json:"redirect,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"`
|
|
Headers map[string][]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
func cleanResponse(resp *logical.Response) *cleanedResponse {
|
|
return &cleanedResponse{
|
|
Secret: resp.Secret,
|
|
Auth: resp.Auth,
|
|
Data: resp.Data,
|
|
Redirect: resp.Redirect,
|
|
Warnings: resp.Warnings,
|
|
WrapInfo: resp.WrapInfo,
|
|
Headers: resp.Headers,
|
|
}
|
|
}
|
|
|
|
// CreateOperationIDs generates unique operationIds for all paths/methods.
|
|
// The transform will convert path/method into camelcase. e.g.:
|
|
//
|
|
// /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes
|
|
//
|
|
// In the unlikely case of a duplicate ids, a numeric suffix is added:
|
|
//
|
|
// postSysToolsRandomUrlbytes_2
|
|
//
|
|
// An optional user-provided suffix ("context") may also be appended.
|
|
func (d *OASDocument) CreateOperationIDs(context string) {
|
|
opIDCount := make(map[string]int)
|
|
var paths []string
|
|
|
|
// traverse paths in a stable order to ensure stable output
|
|
for path := range d.Paths {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
|
|
for _, path := range paths {
|
|
pi := d.Paths[path]
|
|
for _, method := range []string{"get", "post", "delete"} {
|
|
var oasOperation *OASOperation
|
|
switch method {
|
|
case "get":
|
|
oasOperation = pi.Get
|
|
case "post":
|
|
oasOperation = pi.Post
|
|
case "delete":
|
|
oasOperation = pi.Delete
|
|
}
|
|
|
|
if oasOperation == nil {
|
|
continue
|
|
}
|
|
|
|
// Space-split on non-words, title case everything, recombine
|
|
opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ")
|
|
opID = strings.Title(opID)
|
|
opID = method + strings.ReplaceAll(opID, " ", "")
|
|
|
|
// deduplicate operationIds. This is a safeguard, since generated IDs should
|
|
// already be unique given our current path naming conventions.
|
|
opIDCount[opID]++
|
|
if opIDCount[opID] > 1 {
|
|
opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID])
|
|
}
|
|
|
|
if context != "" {
|
|
opID += "_" + context
|
|
}
|
|
|
|
oasOperation.OperationID = opID
|
|
}
|
|
}
|
|
}
|