| package framework |
| |
| import ( |
| "errors" |
| "fmt" |
| "reflect" |
| "regexp" |
| "regexp/syntax" |
| "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 fields in paths, and string cleanup. |
| // Predefined here to avoid substantial recompilation. |
| |
| var ( |
| nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters |
| pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}", |
| 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, doc *OASDocument) error { |
| for _, p := range backend.Paths { |
| if err := documentPath(p, backend.SpecialPaths(), requestResponsePrefix, 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, 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. |
| forceUnpublished := false |
| paths, err := expandPattern(p.Pattern) |
| if err != nil { |
| if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) { |
| // Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later |
| // processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every |
| // operation (meaning the operations will not be represented in the OpenAPI document). |
| // |
| // This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist, |
| // even though it was not able to contribute actual OpenAPI operations. |
| forceUnpublished = true |
| paths = []string{p.Pattern} |
| } else { |
| return err |
| } |
| } |
| |
| for pathIndex, 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 = withoutOperationHints(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) |
| |
| 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: withoutOperationHints(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 || forceUnpublished { |
| 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() |
| |
| operationID := constructOperationID( |
| path, |
| pathIndex, |
| p.DisplayAttrs, |
| opType, |
| props.DisplayAttrs, |
| requestResponsePrefix, |
| ) |
| |
| op.Summary = props.Summary |
| op.Description = props.Description |
| op.Deprecated = props.Deprecated |
| op.OperationID = operationID |
| |
| // 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: withoutOperationHints(field.DisplayAttrs), |
| } |
| if openapiField.baseType == "array" { |
| p.Items = &OASSchema{ |
| Type: openapiField.items, |
| } |
| } |
| s.Properties[name] = &p |
| } |
| |
| // Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several |
| // versions, doesn't contain spurious non-semantic changes. |
| sort.Strings(s.Required) |
| |
| // 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 := hyphenatedToTitleCase(operationID) + "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: withoutOperationHints(field.DisplayAttrs), |
| } |
| if openapiField.baseType == "array" { |
| p.Items = &OASSchema{ |
| Type: openapiField.items, |
| } |
| } |
| responseSchema.Properties[name] = &p |
| } |
| |
| if len(resp.Fields) != 0 { |
| responseName := hyphenatedToTitleCase(operationID) + "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 |
| } |
| |
| // specialPathMatch checks whether the given path matches one of the special |
| // paths, taking into account * and + wildcards (e.g. foo/+/bar/*) |
| func specialPathMatch(path string, specialPaths []string) bool { |
| // pathMatchesByParts determines if the path matches the special path's |
| // pattern, accounting for the '+' and '*' wildcards |
| pathMatchesByParts := func(pathParts []string, specialPathParts []string) bool { |
| if len(pathParts) < len(specialPathParts) { |
| return false |
| } |
| for i := 0; i < len(specialPathParts); i++ { |
| var ( |
| part = pathParts[i] |
| pattern = specialPathParts[i] |
| ) |
| if pattern == "+" { |
| continue |
| } |
| if pattern == "*" { |
| return true |
| } |
| if strings.HasSuffix(pattern, "*") && strings.HasPrefix(part, pattern[0:len(pattern)-1]) { |
| return true |
| } |
| if pattern != part { |
| return false |
| } |
| } |
| return len(pathParts) == len(specialPathParts) |
| } |
| |
| pathParts := strings.Split(path, "/") |
| |
| for _, sp := range specialPaths { |
| // exact match |
| if sp == path { |
| return true |
| } |
| |
| // match * |
| if strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1]) { |
| return true |
| } |
| |
| // match + |
| if strings.Contains(sp, "+") && pathMatchesByParts(pathParts, strings.Split(sp, "/")) { |
| return true |
| } |
| } |
| |
| return false |
| } |
| |
| // constructOperationID joins the given inputs into a hyphen-separated |
| // lower-case operation id, which is also used as a prefix for request and |
| // response names. |
| // |
| // The OperationPrefix / -Verb / -Suffix found in display attributes will be |
| // used, if provided. Otherwise, the function falls back to using the path and |
| // the operation. |
| // |
| // Examples of generated operation identifiers: |
| // - kvv2-write |
| // - kvv2-read |
| // - google-cloud-login |
| // - google-cloud-write-role |
| func constructOperationID( |
| path string, |
| pathIndex int, |
| pathAttributes *DisplayAttributes, |
| operation logical.Operation, |
| operationAttributes *DisplayAttributes, |
| defaultPrefix string, |
| ) string { |
| var ( |
| prefix string |
| verb string |
| suffix string |
| ) |
| |
| if operationAttributes != nil { |
| prefix = operationAttributes.OperationPrefix |
| verb = operationAttributes.OperationVerb |
| suffix = operationAttributes.OperationSuffix |
| } |
| |
| if pathAttributes != nil { |
| if prefix == "" { |
| prefix = pathAttributes.OperationPrefix |
| } |
| if verb == "" { |
| verb = pathAttributes.OperationVerb |
| } |
| if suffix == "" { |
| suffix = pathAttributes.OperationSuffix |
| } |
| } |
| |
| // A single suffix string can contain multiple pipe-delimited strings. To |
| // determine the actual suffix, we attempt to match it by the index of the |
| // paths returned from `expandPattern(...)`. For example: |
| // |
| // pki/ |
| // Pattern: "keys/generate/(internal|exported|kms)", |
| // DisplayAttrs: { |
| // ... |
| // OperationSuffix: "internal-key|exported-key|kms-key", |
| // }, |
| // |
| // will expand into three paths and corresponding suffixes: |
| // |
| // path 0: "keys/generate/internal" suffix: internal-key |
| // path 1: "keys/generate/exported" suffix: exported-key |
| // path 2: "keys/generate/kms" suffix: kms-key |
| // |
| pathIndexOutOfRange := false |
| |
| if suffixes := strings.Split(suffix, "|"); len(suffixes) > 1 || pathIndex > 0 { |
| // if the index is out of bounds, fall back to the old logic |
| if pathIndex >= len(suffixes) { |
| suffix = "" |
| pathIndexOutOfRange = true |
| } else { |
| suffix = suffixes[pathIndex] |
| } |
| } |
| |
| // a helper that hyphenates & lower-cases the slice except the empty elements |
| toLowerHyphenate := func(parts []string) string { |
| filtered := make([]string, 0, len(parts)) |
| for _, e := range parts { |
| if e != "" { |
| filtered = append(filtered, e) |
| } |
| } |
| return strings.ToLower(strings.Join(filtered, "-")) |
| } |
| |
| // fall back to using the path + operation to construct the operation id |
| var ( |
| needPrefix = prefix == "" && verb == "" |
| needVerb = verb == "" |
| needSuffix = suffix == "" && (verb == "" || pathIndexOutOfRange) |
| ) |
| |
| if needPrefix { |
| prefix = defaultPrefix |
| } |
| |
| if needVerb { |
| if operation == logical.UpdateOperation { |
| verb = "write" |
| } else { |
| verb = string(operation) |
| } |
| } |
| |
| if needSuffix { |
| suffix = toLowerHyphenate(nonWordRe.Split(path, -1)) |
| } |
| |
| return toLowerHyphenate([]string{prefix, verb, suffix}) |
| } |
| |
| // 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, error) { |
| // Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do |
| // the hard work of interpreting the regexp syntax. |
| rx, err := syntax.Parse(pattern, syntax.Perl) |
| if err != nil { |
| // This should be impossible to reach, since regexps have previously been compiled with MustCompile in |
| // Backend.init. |
| panic(err) |
| } |
| |
| paths, err := collectPathsFromRegexpAST(rx) |
| if err != nil { |
| return nil, err |
| } |
| |
| return paths, nil |
| } |
| |
| type pathCollector struct { |
| strings.Builder |
| conditionalSlashAppendedAtLength int |
| } |
| |
| // collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style |
| // path as it goes. |
| // |
| // Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional |
| // results, to account for each possibility. Note: This does mean that an input pattern with lots of these regexp |
| // features can produce a lot of different OpenAPI endpoints. At the time of writing, the most complex known example is |
| // |
| // "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/crl(/pem|/der|/delta(/pem|/der)?)?" |
| // |
| // in the PKI secrets engine which expands to 6 separate paths. |
| // |
| // Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and |
| // the subtree of regexp AST inside the parameter is completely skipped. |
| func collectPathsFromRegexpAST(rx *syntax.Regexp) ([]string, error) { |
| pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}}) |
| if err != nil { |
| return nil, err |
| } |
| paths := make([]string, 0, len(pathCollectors)) |
| for _, collector := range pathCollectors { |
| if collector.conditionalSlashAppendedAtLength != collector.Len() { |
| paths = append(paths, collector.String()) |
| } |
| } |
| return paths, nil |
| } |
| |
| var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern") |
| |
| func collectPathsFromRegexpASTInternal(rx *syntax.Regexp, appendingTo []*pathCollector) ([]*pathCollector, error) { |
| var err error |
| |
| // Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any |
| // characters to the URL path, and whether we need to recurse through child AST nodes. |
| // |
| // Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing |
| // the | and ? conditional regexp features, and new elements are added as each of these features are traversed. |
| // |
| // To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each |
| // recursive call, potentially modified throughout this switch block, and passed back up as a return value at the |
| // end of this function - the parent call uses the return value to update its own local variable. |
| switch rx.Op { |
| |
| // These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all |
| case syntax.OpEmptyMatch: // e.g. (?:) |
| case syntax.OpBeginLine: // i.e. ^ when (?m) |
| case syntax.OpEndLine: // i.e. $ when (?m) |
| case syntax.OpBeginText: // i.e. \A, or ^ when (?-m) |
| case syntax.OpEndText: // i.e. \z, or $ when (?-m) |
| case syntax.OpWordBoundary: // i.e. \b |
| case syntax.OpNoWordBoundary: // i.e. \B |
| |
| // OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through |
| // those pieces. |
| case syntax.OpConcat: |
| for _, child := range rx.Sub { |
| appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo) |
| if err != nil { |
| return nil, err |
| } |
| } |
| |
| // OpLiteral is a literal string in the pattern - append it to the paths we are building. |
| case syntax.OpLiteral: |
| for _, collector := range appendingTo { |
| collector.WriteString(string(rx.Rune)) |
| } |
| |
| // OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths |
| // into, and independently recurse through each alternate option. |
| case syntax.OpAlternate: // i.e | |
| var totalAppendingTo []*pathCollector |
| lastIndex := len(rx.Sub) - 1 |
| for index, child := range rx.Sub { |
| var childAppendingTo []*pathCollector |
| if index == lastIndex { |
| // Optimization: last time through this loop, we can simply re-use the existing set of pathCollector |
| // instances, as we no longer need to preserve them unmodified to make further copies of. |
| childAppendingTo = appendingTo |
| } else { |
| for _, collector := range appendingTo { |
| newCollector := new(pathCollector) |
| newCollector.WriteString(collector.String()) |
| newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength |
| childAppendingTo = append(childAppendingTo, newCollector) |
| } |
| } |
| childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo) |
| if err != nil { |
| return nil, err |
| } |
| totalAppendingTo = append(totalAppendingTo, childAppendingTo...) |
| } |
| appendingTo = totalAppendingTo |
| |
| // OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string. |
| case syntax.OpQuest: |
| child := rx.Sub[0] |
| var childAppendingTo []*pathCollector |
| for _, collector := range appendingTo { |
| newCollector := new(pathCollector) |
| newCollector.WriteString(collector.String()) |
| newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength |
| childAppendingTo = append(childAppendingTo, newCollector) |
| } |
| childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo) |
| if err != nil { |
| return nil, err |
| } |
| appendingTo = append(appendingTo, childAppendingTo...) |
| |
| // Many Vault path patterns end with `/?` to accept paths that end with or without a slash. Our current |
| // convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case |
| // detects when we just appended a single conditional slash, and records the length of the path at this point, |
| // so we can later discard this path variant, if nothing else is appended to it later. |
| if child.Op == syntax.OpLiteral && string(child.Rune) == "/" { |
| for _, collector := range childAppendingTo { |
| collector.conditionalSlashAppendedAtLength = collector.Len() |
| } |
| } |
| |
| // OpCapture, i.e. ( ) or (?P<name> ), a capturing group |
| case syntax.OpCapture: |
| if rx.Name == "" { |
| // In Vault, an unnamed capturing group is not actually used for capturing. |
| // We treat it exactly the same as OpConcat. |
| for _, child := range rx.Sub { |
| appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo) |
| if err != nil { |
| return nil, err |
| } |
| } |
| } else { |
| // A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group |
| // is NOT added to the OpenAPI path. |
| for _, builder := range appendingTo { |
| builder.WriteRune('{') |
| builder.WriteString(rx.Name) |
| builder.WriteRune('}') |
| } |
| } |
| |
| // Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of |
| // the OpenAPI entirely - that's better than generating a path which is incorrect. |
| // |
| // The Op types we expect to hit the default condition are: |
| // |
| // OpCharClass - i.e. [something] |
| // OpAnyCharNotNL - i.e. . |
| // OpAnyChar - i.e. (?s:.) |
| // OpStar - i.e. * |
| // OpPlus - i.e. + |
| // OpRepeat - i.e. {N}, {N,M}, etc. |
| // |
| // In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only |
| // applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.) |
| // |
| // At the time of writing, the only pattern in the builtin Vault plugins that hits this codepath is the ".*" |
| // pattern in the KVv2 secrets engine, which is not a valid path, but rather, is a catch-all used to implement |
| // custom error handling behaviour to guide users who attempt to treat a KVv2 as a KVv1. It is already marked as |
| // Unpublished, so is withheld from the OpenAPI anyway. |
| // |
| // For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by |
| // subsequent Simplify in preparation to Compile, which is not used here. |
| default: |
| return nil, errUnsupportableRegexpOperationForOpenAPI |
| } |
| |
| return appendingTo, nil |
| } |
| |
| // 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 |
| } |
| |
| // withoutOperationHints returns a copy of the given DisplayAttributes without |
| // OperationPrefix / OperationVerb / OperationSuffix since we don't need these |
| // fields in the final output. |
| func withoutOperationHints(in *DisplayAttributes) *DisplayAttributes { |
| if in == nil { |
| return nil |
| } |
| |
| copy := *in |
| |
| copy.OperationPrefix = "" |
| copy.OperationVerb = "" |
| copy.OperationSuffix = "" |
| |
| // return nil if all fields are empty to avoid empty JSON objects |
| if copy == (DisplayAttributes{}) { |
| return nil |
| } |
| |
| return © |
| } |
| |
| func hyphenatedToTitleCase(in string) string { |
| var b strings.Builder |
| |
| title := cases.Title(language.English, cases.NoLower) |
| |
| for _, word := range strings.Split(in, "-") { |
| b.WriteString(title.String(word)) |
| } |
| |
| return b.String() |
| } |
| |
| // 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. |
| // |
| // Deprecated: operationID's are now populated using `constructOperationID`. |
| // This function is here for backwards compatibility with older plugins. |
| 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 |
| } |
| |
| if oasOperation.OperationID != "" { |
| continue |
| } |
| |
| // Discard "_mount_path" from any {thing_mount_path} parameters |
| path = strings.Replace(path, "_mount_path", "", 1) |
| |
| // 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 |
| } |
| } |
| } |