github.com/hashicorp/vault/sdk@v0.13.0/framework/openapi.go (about)

     1  package framework
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"reflect"
     7  	"regexp"
     8  	"regexp/syntax"
     9  	"sort"
    10  	"strconv"
    11  	"strings"
    12  
    13  	log "github.com/hashicorp/go-hclog"
    14  	"github.com/hashicorp/vault/sdk/helper/wrapping"
    15  	"github.com/hashicorp/vault/sdk/logical"
    16  	"github.com/mitchellh/mapstructure"
    17  	"golang.org/x/text/cases"
    18  	"golang.org/x/text/language"
    19  )
    20  
    21  // OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md
    22  const OASVersion = "3.0.2"
    23  
    24  // NewOASDocument returns an empty OpenAPI document.
    25  func NewOASDocument(version string) *OASDocument {
    26  	return &OASDocument{
    27  		Version: OASVersion,
    28  		Info: OASInfo{
    29  			Title:       "HashiCorp Vault API",
    30  			Description: "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
    31  			Version:     version,
    32  			License: OASLicense{
    33  				Name: "Mozilla Public License 2.0",
    34  				URL:  "https://www.mozilla.org/en-US/MPL/2.0",
    35  			},
    36  		},
    37  		Paths: make(map[string]*OASPathItem),
    38  		Components: OASComponents{
    39  			Schemas: make(map[string]*OASSchema),
    40  		},
    41  	}
    42  }
    43  
    44  // NewOASDocumentFromMap builds an OASDocument from an existing map version of a document.
    45  // If a document has been decoded from JSON or received from a plugin, it will be as a map[string]interface{}
    46  // and needs special handling beyond the default mapstructure decoding.
    47  func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) {
    48  	// The Responses map uses integer keys (the response code), but once translated into JSON
    49  	// (e.g. during the plugin transport) these become strings. mapstructure will not coerce these back
    50  	// to integers without a custom decode hook.
    51  	decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) {
    52  		// Only alter data if:
    53  		//  1. going from string to int
    54  		//  2. string represent an int in status code range (100-599)
    55  		if src.Kind() == reflect.String && tgt.Kind() == reflect.Int {
    56  			if input, ok := inputRaw.(string); ok {
    57  				if intval, err := strconv.Atoi(input); err == nil {
    58  					if intval >= 100 && intval < 600 {
    59  						return intval, nil
    60  					}
    61  				}
    62  			}
    63  		}
    64  		return inputRaw, nil
    65  	}
    66  
    67  	doc := new(OASDocument)
    68  
    69  	config := &mapstructure.DecoderConfig{
    70  		DecodeHook: decodeHook,
    71  		Result:     doc,
    72  	}
    73  
    74  	decoder, err := mapstructure.NewDecoder(config)
    75  	if err != nil {
    76  		return nil, err
    77  	}
    78  
    79  	if err := decoder.Decode(input); err != nil {
    80  		return nil, err
    81  	}
    82  
    83  	return doc, nil
    84  }
    85  
    86  type OASDocument struct {
    87  	Version    string                  `json:"openapi" mapstructure:"openapi"`
    88  	Info       OASInfo                 `json:"info"`
    89  	Paths      map[string]*OASPathItem `json:"paths"`
    90  	Components OASComponents           `json:"components"`
    91  }
    92  
    93  type OASComponents struct {
    94  	Schemas map[string]*OASSchema `json:"schemas"`
    95  }
    96  
    97  type OASInfo struct {
    98  	Title       string     `json:"title"`
    99  	Description string     `json:"description"`
   100  	Version     string     `json:"version"`
   101  	License     OASLicense `json:"license"`
   102  }
   103  
   104  type OASLicense struct {
   105  	Name string `json:"name"`
   106  	URL  string `json:"url"`
   107  }
   108  
   109  type OASPathItem struct {
   110  	Description     string             `json:"description,omitempty"`
   111  	Parameters      []OASParameter     `json:"parameters,omitempty"`
   112  	Sudo            bool               `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"`
   113  	Unauthenticated bool               `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"`
   114  	CreateSupported bool               `json:"x-vault-createSupported,omitempty" mapstructure:"x-vault-createSupported"`
   115  	DisplayAttrs    *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"`
   116  
   117  	Get    *OASOperation `json:"get,omitempty"`
   118  	Post   *OASOperation `json:"post,omitempty"`
   119  	Delete *OASOperation `json:"delete,omitempty"`
   120  }
   121  
   122  // NewOASOperation creates an empty OpenAPI Operations object.
   123  func NewOASOperation() *OASOperation {
   124  	return &OASOperation{
   125  		Responses: make(map[int]*OASResponse),
   126  	}
   127  }
   128  
   129  type OASOperation struct {
   130  	Summary     string               `json:"summary,omitempty"`
   131  	Description string               `json:"description,omitempty"`
   132  	OperationID string               `json:"operationId,omitempty"`
   133  	Tags        []string             `json:"tags,omitempty"`
   134  	Parameters  []OASParameter       `json:"parameters,omitempty"`
   135  	RequestBody *OASRequestBody      `json:"requestBody,omitempty"`
   136  	Responses   map[int]*OASResponse `json:"responses"`
   137  	Deprecated  bool                 `json:"deprecated,omitempty"`
   138  }
   139  
   140  type OASParameter struct {
   141  	Name        string     `json:"name"`
   142  	Description string     `json:"description,omitempty"`
   143  	In          string     `json:"in"`
   144  	Schema      *OASSchema `json:"schema,omitempty"`
   145  	Required    bool       `json:"required,omitempty"`
   146  	Deprecated  bool       `json:"deprecated,omitempty"`
   147  }
   148  
   149  type OASRequestBody struct {
   150  	Description string     `json:"description,omitempty"`
   151  	Required    bool       `json:"required,omitempty"`
   152  	Content     OASContent `json:"content,omitempty"`
   153  }
   154  
   155  type OASContent map[string]*OASMediaTypeObject
   156  
   157  type OASMediaTypeObject struct {
   158  	Schema *OASSchema `json:"schema,omitempty"`
   159  }
   160  
   161  type OASSchema struct {
   162  	Ref         string                `json:"$ref,omitempty"`
   163  	Type        string                `json:"type,omitempty"`
   164  	Description string                `json:"description,omitempty"`
   165  	Properties  map[string]*OASSchema `json:"properties,omitempty"`
   166  
   167  	AdditionalProperties interface{} `json:"additionalProperties,omitempty"`
   168  
   169  	// Required is a list of keys in Properties that are required to be present. This is a different
   170  	// approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'.
   171  	Required []string `json:"required,omitempty"`
   172  
   173  	Items      *OASSchema    `json:"items,omitempty"`
   174  	Format     string        `json:"format,omitempty"`
   175  	Pattern    string        `json:"pattern,omitempty"`
   176  	Enum       []interface{} `json:"enum,omitempty"`
   177  	Default    interface{}   `json:"default,omitempty"`
   178  	Example    interface{}   `json:"example,omitempty"`
   179  	Deprecated bool          `json:"deprecated,omitempty"`
   180  	// DisplayName      string             `json:"x-vault-displayName,omitempty" mapstructure:"x-vault-displayName,omitempty"`
   181  	DisplayValue     interface{}        `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"`
   182  	DisplaySensitive bool               `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"`
   183  	DisplayGroup     string             `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"`
   184  	DisplayAttrs     *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,omitempty"`
   185  }
   186  
   187  type OASResponse struct {
   188  	Description string     `json:"description"`
   189  	Content     OASContent `json:"content,omitempty"`
   190  }
   191  
   192  var OASStdRespOK = &OASResponse{
   193  	Description: "OK",
   194  }
   195  
   196  var OASStdRespNoContent = &OASResponse{
   197  	Description: "empty body",
   198  }
   199  
   200  var OASStdRespListOK = &OASResponse{
   201  	Description: "OK",
   202  	Content: OASContent{
   203  		"application/json": &OASMediaTypeObject{
   204  			Schema: &OASSchema{
   205  				Ref: "#/components/schemas/StandardListResponse",
   206  			},
   207  		},
   208  	},
   209  }
   210  
   211  var OASStdSchemaStandardListResponse = &OASSchema{
   212  	Type: "object",
   213  	Properties: map[string]*OASSchema{
   214  		"keys": {
   215  			Type: "array",
   216  			Items: &OASSchema{
   217  				Type: "string",
   218  			},
   219  		},
   220  	},
   221  }
   222  
   223  // Regex for handling fields in paths, and string cleanup.
   224  // Predefined here to avoid substantial recompilation.
   225  
   226  var (
   227  	nonWordRe    = regexp.MustCompile(`[^\w]+`)  // Match a sequence of non-word characters
   228  	pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
   229  	wsRe         = regexp.MustCompile(`\s+`)     // Match whitespace, to be compressed during cleaning
   230  )
   231  
   232  // documentPaths parses all paths in a framework.Backend into OpenAPI paths.
   233  func documentPaths(backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
   234  	for _, p := range backend.Paths {
   235  		if err := documentPath(p, backend, requestResponsePrefix, doc); err != nil {
   236  			return err
   237  		}
   238  	}
   239  
   240  	return nil
   241  }
   242  
   243  // documentPath parses a framework.Path into one or more OpenAPI paths.
   244  func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
   245  	var sudoPaths []string
   246  	var unauthPaths []string
   247  
   248  	if backend.PathsSpecial != nil {
   249  		sudoPaths = backend.PathsSpecial.Root
   250  		unauthPaths = backend.PathsSpecial.Unauthenticated
   251  	}
   252  
   253  	// Convert optional parameters into distinct patterns to be processed independently.
   254  	forceUnpublished := false
   255  	paths, captures, err := expandPattern(p.Pattern)
   256  	if err != nil {
   257  		if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) {
   258  			// Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later
   259  			// processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every
   260  			// operation (meaning the operations will not be represented in the OpenAPI document).
   261  			//
   262  			// This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist,
   263  			// even though it was not able to contribute actual OpenAPI operations.
   264  			forceUnpublished = true
   265  			paths = []string{p.Pattern}
   266  		} else {
   267  			return err
   268  		}
   269  	}
   270  
   271  	for pathIndex, path := range paths {
   272  		// Construct a top level PathItem which will be populated as the path is processed.
   273  		pi := OASPathItem{
   274  			Description: cleanString(p.HelpSynopsis),
   275  		}
   276  
   277  		pi.Sudo = specialPathMatch(path, sudoPaths)
   278  		pi.Unauthenticated = specialPathMatch(path, unauthPaths)
   279  		pi.DisplayAttrs = withoutOperationHints(p.DisplayAttrs)
   280  
   281  		// If the newer style Operations map isn't defined, create one from the legacy fields.
   282  		operations := p.Operations
   283  		if operations == nil {
   284  			operations = make(map[logical.Operation]OperationHandler)
   285  
   286  			for opType, cb := range p.Callbacks {
   287  				operations[opType] = &PathOperation{
   288  					Callback: cb,
   289  					Summary:  p.HelpSynopsis,
   290  				}
   291  			}
   292  		}
   293  
   294  		// Process path and header parameters, which are common to all operations.
   295  		// Body fields will be added to individual operations.
   296  		pathFields, queryFields, bodyFields := splitFields(p.Fields, path, captures)
   297  
   298  		for name, field := range pathFields {
   299  			t := convertType(field.Type)
   300  			p := OASParameter{
   301  				Name:        name,
   302  				Description: cleanString(field.Description),
   303  				In:          "path",
   304  				Schema: &OASSchema{
   305  					Type:         t.baseType,
   306  					Pattern:      t.pattern,
   307  					Enum:         field.AllowedValues,
   308  					Default:      field.Default,
   309  					DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
   310  				},
   311  				Required:   true,
   312  				Deprecated: field.Deprecated,
   313  			}
   314  			pi.Parameters = append(pi.Parameters, p)
   315  		}
   316  
   317  		// Sort parameters for a stable output
   318  		sort.Slice(pi.Parameters, func(i, j int) bool {
   319  			return pi.Parameters[i].Name < pi.Parameters[j].Name
   320  		})
   321  
   322  		// Process each supported operation by building up an Operation object
   323  		// with descriptions, properties and examples from the framework.Path data.
   324  		var listOperation *OASOperation
   325  		for opType, opHandler := range operations {
   326  			props := opHandler.Properties()
   327  			if props.Unpublished || forceUnpublished {
   328  				continue
   329  			}
   330  
   331  			if opType == logical.CreateOperation {
   332  				pi.CreateSupported = true
   333  
   334  				// If both Create and Update are defined, only process Update.
   335  				if operations[logical.UpdateOperation] != nil {
   336  					continue
   337  				}
   338  			}
   339  
   340  			op := NewOASOperation()
   341  
   342  			operationID := constructOperationID(
   343  				path,
   344  				pathIndex,
   345  				p.DisplayAttrs,
   346  				opType,
   347  				props.DisplayAttrs,
   348  				requestResponsePrefix,
   349  			)
   350  
   351  			op.Summary = props.Summary
   352  			op.Description = props.Description
   353  			op.Deprecated = props.Deprecated
   354  			op.OperationID = operationID
   355  
   356  			switch opType {
   357  			// For the operation types which map to POST/PUT methods, and so allow for request body parameters,
   358  			// prepare the request body definition
   359  			case logical.CreateOperation:
   360  				fallthrough
   361  			case logical.UpdateOperation:
   362  				s := &OASSchema{
   363  					Type:       "object",
   364  					Properties: make(map[string]*OASSchema),
   365  					Required:   make([]string, 0),
   366  				}
   367  
   368  				for name, field := range bodyFields {
   369  					// Removing this field from the spec as it is deprecated in favor of using "sha256"
   370  					// The duplicate sha_256 and sha256 in these paths cause issues with codegen
   371  					if name == "sha_256" && strings.Contains(path, "plugins/catalog/") {
   372  						continue
   373  					}
   374  
   375  					addFieldToOASSchema(s, name, field)
   376  				}
   377  
   378  				// Contrary to what one might guess, fields marked with "Query: true" are only query fields when the
   379  				// request method is one which does not allow for a request body - they are still body fields when
   380  				// dealing with a POST/PUT request.
   381  				for name, field := range queryFields {
   382  					addFieldToOASSchema(s, name, field)
   383  				}
   384  
   385  				// Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several
   386  				// versions, doesn't contain spurious non-semantic changes.
   387  				sort.Strings(s.Required)
   388  
   389  				// If examples were given, use the first one as the sample
   390  				// of this schema.
   391  				if len(props.Examples) > 0 {
   392  					s.Example = props.Examples[0].Data
   393  				}
   394  
   395  				// TakesArbitraryInput is a case like writing to:
   396  				//   - sys/wrapping/wrap
   397  				//   - kv-v1/{path}
   398  				//   - cubbyhole/{path}
   399  				// where the entire request body is an arbitrary JSON object used directly as input.
   400  				if p.TakesArbitraryInput {
   401  					// Whilst the default value of additionalProperties is true according to the JSON Schema standard,
   402  					// making this explicit helps communicate this to humans, and also tools such as
   403  					// https://openapi-generator.tech/ which treat it as defaulting to false.
   404  					s.AdditionalProperties = true
   405  				}
   406  
   407  				// Set the final request body. Only JSON request data is supported.
   408  				if len(s.Properties) > 0 {
   409  					requestName := hyphenatedToTitleCase(operationID) + "Request"
   410  					doc.Components.Schemas[requestName] = s
   411  					op.RequestBody = &OASRequestBody{
   412  						Required: true,
   413  						Content: OASContent{
   414  							"application/json": &OASMediaTypeObject{
   415  								Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)},
   416  							},
   417  						},
   418  					}
   419  				} else if p.TakesArbitraryInput {
   420  					// When there are no properties, the schema is trivial enough that it makes more sense to write it
   421  					// inline, rather than as a named component.
   422  					op.RequestBody = &OASRequestBody{
   423  						Required: true,
   424  						Content: OASContent{
   425  							"application/json": &OASMediaTypeObject{
   426  								Schema: s,
   427  							},
   428  						},
   429  					}
   430  				}
   431  
   432  			// For the operation types which map to HTTP methods without a request body, populate query parameters
   433  			case logical.ListOperation:
   434  				// LIST is represented as GET with a `list` query parameter. Code later on in this function will assign
   435  				// list operations to a path with an extra trailing slash, ensuring they do not collide with read
   436  				// operations.
   437  				op.Parameters = append(op.Parameters, OASParameter{
   438  					Name:        "list",
   439  					Description: "Must be set to `true`",
   440  					Required:    true,
   441  					In:          "query",
   442  					Schema:      &OASSchema{Type: "string", Enum: []interface{}{"true"}},
   443  				})
   444  				fallthrough
   445  			case logical.DeleteOperation:
   446  				fallthrough
   447  			case logical.ReadOperation:
   448  				for name, field := range queryFields {
   449  					t := convertType(field.Type)
   450  					p := OASParameter{
   451  						Name:        name,
   452  						Description: cleanString(field.Description),
   453  						In:          "query",
   454  						Schema: &OASSchema{
   455  							Type:         t.baseType,
   456  							Pattern:      t.pattern,
   457  							Enum:         field.AllowedValues,
   458  							Default:      field.Default,
   459  							DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
   460  						},
   461  						Deprecated: field.Deprecated,
   462  					}
   463  					op.Parameters = append(op.Parameters, p)
   464  				}
   465  
   466  				// Sort parameters for a stable output
   467  				sort.Slice(op.Parameters, func(i, j int) bool {
   468  					return op.Parameters[i].Name < op.Parameters[j].Name
   469  				})
   470  			}
   471  
   472  			// Add tags based on backend type
   473  			var tags []string
   474  			switch backend.BackendType {
   475  			case logical.TypeLogical:
   476  				tags = []string{"secrets"}
   477  			case logical.TypeCredential:
   478  				tags = []string{"auth"}
   479  			}
   480  
   481  			op.Tags = append(op.Tags, tags...)
   482  
   483  			// Set default responses.
   484  			if len(props.Responses) == 0 {
   485  				if opType == logical.DeleteOperation {
   486  					op.Responses[204] = OASStdRespNoContent
   487  				} else if opType == logical.ListOperation {
   488  					op.Responses[200] = OASStdRespListOK
   489  					doc.Components.Schemas["StandardListResponse"] = OASStdSchemaStandardListResponse
   490  				} else {
   491  					op.Responses[200] = OASStdRespOK
   492  				}
   493  			}
   494  
   495  			// Add any defined response details.
   496  			for code, responses := range props.Responses {
   497  				var description string
   498  				content := make(OASContent)
   499  
   500  				for i, resp := range responses {
   501  					if i == 0 {
   502  						description = resp.Description
   503  					}
   504  					if resp.Example != nil {
   505  						mediaType := resp.MediaType
   506  						if mediaType == "" {
   507  							mediaType = "application/json"
   508  						}
   509  
   510  						// create a version of the response that will not emit null items
   511  						cr := cleanResponse(resp.Example)
   512  
   513  						// Only one example per media type is allowed, so first one wins
   514  						if _, ok := content[mediaType]; !ok {
   515  							content[mediaType] = &OASMediaTypeObject{
   516  								Schema: &OASSchema{
   517  									Example: cr,
   518  								},
   519  							}
   520  						}
   521  					}
   522  
   523  					responseSchema := &OASSchema{
   524  						Type:       "object",
   525  						Properties: make(map[string]*OASSchema),
   526  					}
   527  
   528  					for name, field := range resp.Fields {
   529  						openapiField := convertType(field.Type)
   530  						p := OASSchema{
   531  							Type:         openapiField.baseType,
   532  							Description:  cleanString(field.Description),
   533  							Format:       openapiField.format,
   534  							Pattern:      openapiField.pattern,
   535  							Enum:         field.AllowedValues,
   536  							Default:      field.Default,
   537  							Deprecated:   field.Deprecated,
   538  							DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
   539  						}
   540  						if openapiField.baseType == "array" {
   541  							p.Items = &OASSchema{
   542  								Type: openapiField.items,
   543  							}
   544  						}
   545  						responseSchema.Properties[name] = &p
   546  					}
   547  
   548  					if len(resp.Fields) != 0 {
   549  						responseName := hyphenatedToTitleCase(operationID) + "Response"
   550  						doc.Components.Schemas[responseName] = responseSchema
   551  						content = OASContent{
   552  							"application/json": &OASMediaTypeObject{
   553  								Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)},
   554  							},
   555  						}
   556  					}
   557  				}
   558  
   559  				op.Responses[code] = &OASResponse{
   560  					Description: description,
   561  					Content:     content,
   562  				}
   563  			}
   564  
   565  			switch opType {
   566  			case logical.CreateOperation, logical.UpdateOperation:
   567  				pi.Post = op
   568  			case logical.ReadOperation:
   569  				pi.Get = op
   570  			case logical.DeleteOperation:
   571  				pi.Delete = op
   572  			case logical.ListOperation:
   573  				listOperation = op
   574  			}
   575  		}
   576  
   577  		// The conventions enforced by the Vault HTTP routing code make it impossible to match a path with a trailing
   578  		// slash to anything other than a ListOperation. Catch mistakes in path definition, to enforce that if both of
   579  		// the two following blocks of code (non-list, and list) write an OpenAPI path to the output document, then the
   580  		// first one will definitely not have a trailing slash.
   581  		originalPathHasTrailingSlash := strings.HasSuffix(path, "/")
   582  		if originalPathHasTrailingSlash && (pi.Get != nil || pi.Post != nil || pi.Delete != nil) {
   583  			backend.Logger().Warn(
   584  				"OpenAPI spec generation: discarding impossible-to-invoke non-list operations from path with "+
   585  					"required trailing slash; this is a bug in the backend code", "path", path)
   586  			pi.Get = nil
   587  			pi.Post = nil
   588  			pi.Delete = nil
   589  		}
   590  
   591  		// Write the regular, non-list, OpenAPI path to the OpenAPI document, UNLESS we generated a ListOperation, and
   592  		// NO OTHER operation types. In that fairly common case (there are lots of list-only endpoints), we avoid
   593  		// writing a redundant OpenAPI path for (e.g.) "auth/token/accessors" with no operations, only to then write
   594  		// one for "auth/token/accessors/" immediately below.
   595  		//
   596  		// On the other hand, we do still write the OpenAPI path here if we generated ZERO operation types - this serves
   597  		// to provide documentation to a human that an endpoint exists, even if it has no invokable OpenAPI operations.
   598  		// Examples of this include kv-v2's ".*" endpoint (regex cannot be translated to OpenAPI parameters), and the
   599  		// auth/oci/login endpoint (implements ResolveRoleOperation only, only callable from inside Vault).
   600  		if listOperation == nil || pi.Get != nil || pi.Post != nil || pi.Delete != nil {
   601  			openAPIPath := "/" + path
   602  			if doc.Paths[openAPIPath] != nil {
   603  				backend.Logger().Warn(
   604  					"OpenAPI spec generation: multiple framework.Path instances generated the same path; "+
   605  						"last processed wins", "path", openAPIPath)
   606  			}
   607  			doc.Paths[openAPIPath] = &pi
   608  		}
   609  
   610  		// If there is a ListOperation, write it to a separate OpenAPI path in the document.
   611  		if listOperation != nil {
   612  			// Append a slash here to disambiguate from the path written immediately above.
   613  			// However, if the path already contains a trailing slash, we want to avoid doubling it, and it is
   614  			// guaranteed (through the interaction of logic in the last two blocks) that the block immediately above
   615  			// will NOT have written a path to the OpenAPI document.
   616  			if !originalPathHasTrailingSlash {
   617  				path += "/"
   618  			}
   619  
   620  			listPathItem := OASPathItem{
   621  				Description:  pi.Description,
   622  				Parameters:   pi.Parameters,
   623  				DisplayAttrs: pi.DisplayAttrs,
   624  
   625  				// Since the path may now have an extra slash on the end, we need to recalculate the special path
   626  				// matches, as the sudo or unauthenticated status may be changed as a result!
   627  				Sudo:            specialPathMatch(path, sudoPaths),
   628  				Unauthenticated: specialPathMatch(path, unauthPaths),
   629  
   630  				Get: listOperation,
   631  			}
   632  
   633  			openAPIPath := "/" + path
   634  			if doc.Paths[openAPIPath] != nil {
   635  				backend.Logger().Warn(
   636  					"OpenAPI spec generation: multiple framework.Path instances generated the same path; "+
   637  						"last processed wins", "path", openAPIPath)
   638  			}
   639  			doc.Paths[openAPIPath] = &listPathItem
   640  		}
   641  	}
   642  
   643  	return nil
   644  }
   645  
   646  func addFieldToOASSchema(s *OASSchema, name string, field *FieldSchema) {
   647  	openapiField := convertType(field.Type)
   648  	if field.Required {
   649  		s.Required = append(s.Required, name)
   650  	}
   651  
   652  	p := OASSchema{
   653  		Type:         openapiField.baseType,
   654  		Description:  cleanString(field.Description),
   655  		Format:       openapiField.format,
   656  		Pattern:      openapiField.pattern,
   657  		Enum:         field.AllowedValues,
   658  		Default:      field.Default,
   659  		Deprecated:   field.Deprecated,
   660  		DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
   661  	}
   662  	if openapiField.baseType == "array" {
   663  		p.Items = &OASSchema{
   664  			Type: openapiField.items,
   665  		}
   666  	}
   667  
   668  	s.Properties[name] = &p
   669  }
   670  
   671  // specialPathMatch checks whether the given path matches one of the special
   672  // paths, taking into account * and + wildcards (e.g. foo/+/bar/*)
   673  func specialPathMatch(path string, specialPaths []string) bool {
   674  	// pathMatchesByParts determines if the path matches the special path's
   675  	// pattern, accounting for the '+' and '*' wildcards
   676  	pathMatchesByParts := func(pathParts []string, specialPathParts []string) bool {
   677  		if len(pathParts) < len(specialPathParts) {
   678  			return false
   679  		}
   680  		for i := 0; i < len(specialPathParts); i++ {
   681  			var (
   682  				part    = pathParts[i]
   683  				pattern = specialPathParts[i]
   684  			)
   685  			if pattern == "+" {
   686  				continue
   687  			}
   688  			if pattern == "*" {
   689  				return true
   690  			}
   691  			if strings.HasSuffix(pattern, "*") && strings.HasPrefix(part, pattern[0:len(pattern)-1]) {
   692  				return true
   693  			}
   694  			if pattern != part {
   695  				return false
   696  			}
   697  		}
   698  		return len(pathParts) == len(specialPathParts)
   699  	}
   700  
   701  	pathParts := strings.Split(path, "/")
   702  
   703  	for _, sp := range specialPaths {
   704  		// exact match
   705  		if sp == path {
   706  			return true
   707  		}
   708  
   709  		// match *
   710  		if strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1]) {
   711  			return true
   712  		}
   713  
   714  		// match +
   715  		if strings.Contains(sp, "+") && pathMatchesByParts(pathParts, strings.Split(sp, "/")) {
   716  			return true
   717  		}
   718  	}
   719  
   720  	return false
   721  }
   722  
   723  // constructOperationID joins the given inputs into a hyphen-separated
   724  // lower-case operation id, which is also used as a prefix for request and
   725  // response names.
   726  //
   727  // The OperationPrefix / -Verb / -Suffix found in display attributes will be
   728  // used, if provided. Otherwise, the function falls back to using the path and
   729  // the operation.
   730  //
   731  // Examples of generated operation identifiers:
   732  //   - kvv2-write
   733  //   - kvv2-read
   734  //   - google-cloud-login
   735  //   - google-cloud-write-role
   736  func constructOperationID(
   737  	path string,
   738  	pathIndex int,
   739  	pathAttributes *DisplayAttributes,
   740  	operation logical.Operation,
   741  	operationAttributes *DisplayAttributes,
   742  	defaultPrefix string,
   743  ) string {
   744  	var (
   745  		prefix string
   746  		verb   string
   747  		suffix string
   748  	)
   749  
   750  	if operationAttributes != nil {
   751  		prefix = operationAttributes.OperationPrefix
   752  		verb = operationAttributes.OperationVerb
   753  		suffix = operationAttributes.OperationSuffix
   754  	}
   755  
   756  	if pathAttributes != nil {
   757  		if prefix == "" {
   758  			prefix = pathAttributes.OperationPrefix
   759  		}
   760  		if verb == "" {
   761  			verb = pathAttributes.OperationVerb
   762  		}
   763  		if suffix == "" {
   764  			suffix = pathAttributes.OperationSuffix
   765  		}
   766  	}
   767  
   768  	// A single suffix string can contain multiple pipe-delimited strings. To
   769  	// determine the actual suffix, we attempt to match it by the index of the
   770  	// paths returned from `expandPattern(...)`. For example:
   771  	//
   772  	//  pki/
   773  	//  	Pattern: "keys/generate/(internal|exported|kms)",
   774  	//      DisplayAttrs: {
   775  	//          ...
   776  	//          OperationSuffix: "internal-key|exported-key|kms-key",
   777  	//      },
   778  	//
   779  	//  will expand into three paths and corresponding suffixes:
   780  	//
   781  	//      path 0: "keys/generate/internal"  suffix: internal-key
   782  	//      path 1: "keys/generate/exported"  suffix: exported-key
   783  	//      path 2: "keys/generate/kms"       suffix: kms-key
   784  	//
   785  	pathIndexOutOfRange := false
   786  
   787  	if suffixes := strings.Split(suffix, "|"); len(suffixes) > 1 || pathIndex > 0 {
   788  		// if the index is out of bounds, fall back to the old logic
   789  		if pathIndex >= len(suffixes) {
   790  			suffix = ""
   791  			pathIndexOutOfRange = true
   792  		} else {
   793  			suffix = suffixes[pathIndex]
   794  		}
   795  	}
   796  
   797  	// a helper that hyphenates & lower-cases the slice except the empty elements
   798  	toLowerHyphenate := func(parts []string) string {
   799  		filtered := make([]string, 0, len(parts))
   800  		for _, e := range parts {
   801  			if e != "" {
   802  				filtered = append(filtered, e)
   803  			}
   804  		}
   805  		return strings.ToLower(strings.Join(filtered, "-"))
   806  	}
   807  
   808  	// fall back to using the path + operation to construct the operation id
   809  	var (
   810  		needPrefix = prefix == "" && verb == ""
   811  		needVerb   = verb == ""
   812  		needSuffix = suffix == "" && (verb == "" || pathIndexOutOfRange)
   813  	)
   814  
   815  	if needPrefix {
   816  		prefix = defaultPrefix
   817  	}
   818  
   819  	if needVerb {
   820  		if operation == logical.UpdateOperation {
   821  			verb = "write"
   822  		} else {
   823  			verb = string(operation)
   824  		}
   825  	}
   826  
   827  	if needSuffix {
   828  		suffix = toLowerHyphenate(nonWordRe.Split(path, -1))
   829  	}
   830  
   831  	return toLowerHyphenate([]string{prefix, verb, suffix})
   832  }
   833  
   834  // expandPattern expands a regex pattern by generating permutations of any optional parameters
   835  // and changing named parameters into their {openapi} equivalents. It also returns the names of all capturing groups
   836  // observed in the pattern.
   837  func expandPattern(pattern string) (paths []string, captures map[string]struct{}, err error) {
   838  	// Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do
   839  	// the hard work of interpreting the regexp syntax.
   840  	rx, err := syntax.Parse(pattern, syntax.Perl)
   841  	if err != nil {
   842  		// This should be impossible to reach, since regexps have previously been compiled with MustCompile in
   843  		// Backend.init.
   844  		panic(err)
   845  	}
   846  
   847  	paths, captures, err = collectPathsFromRegexpAST(rx)
   848  	if err != nil {
   849  		return nil, nil, err
   850  	}
   851  
   852  	return paths, captures, nil
   853  }
   854  
   855  type pathCollector struct {
   856  	strings.Builder
   857  	conditionalSlashAppendedAtLength int
   858  }
   859  
   860  // collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style
   861  // path as it goes.
   862  //
   863  // Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional
   864  // results, to account for each possibility. Note: This does mean that an input pattern with lots of these regexp
   865  // features can produce a lot of different OpenAPI endpoints. At the time of writing, the most complex known example is
   866  //
   867  //	"issuer/" + framework.GenericNameRegex(issuerRefParam) + "/crl(/pem|/der|/delta(/pem|/der)?)?"
   868  //
   869  // in the PKI secrets engine which expands to 6 separate paths.
   870  //
   871  // Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and
   872  // the subtree of regexp AST inside the parameter is completely skipped.
   873  func collectPathsFromRegexpAST(rx *syntax.Regexp) (paths []string, captures map[string]struct{}, err error) {
   874  	captures = make(map[string]struct{})
   875  	pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}}, captures)
   876  	if err != nil {
   877  		return nil, nil, err
   878  	}
   879  	paths = make([]string, 0, len(pathCollectors))
   880  	for _, collector := range pathCollectors {
   881  		if collector.conditionalSlashAppendedAtLength != collector.Len() {
   882  			paths = append(paths, collector.String())
   883  		}
   884  	}
   885  	return paths, captures, nil
   886  }
   887  
   888  var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern")
   889  
   890  func collectPathsFromRegexpASTInternal(
   891  	rx *syntax.Regexp,
   892  	appendingTo []*pathCollector,
   893  	captures map[string]struct{},
   894  ) ([]*pathCollector, error) {
   895  	var err error
   896  
   897  	// Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any
   898  	// characters to the URL path, and whether we need to recurse through child AST nodes.
   899  	//
   900  	// Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing
   901  	// the | and ? conditional regexp features, and new elements are added as each of these features are traversed.
   902  	//
   903  	// To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each
   904  	// recursive call, potentially modified throughout this switch block, and passed back up as a return value at the
   905  	// end of this function - the parent call uses the return value to update its own local variable.
   906  	switch rx.Op {
   907  
   908  	// These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all
   909  	case syntax.OpEmptyMatch: // e.g. (?:)
   910  	case syntax.OpBeginLine: // i.e. ^ when (?m)
   911  	case syntax.OpEndLine: // i.e. $ when (?m)
   912  	case syntax.OpBeginText: // i.e. \A, or ^ when (?-m)
   913  	case syntax.OpEndText: // i.e. \z, or $ when (?-m)
   914  	case syntax.OpWordBoundary: // i.e. \b
   915  	case syntax.OpNoWordBoundary: // i.e. \B
   916  
   917  	// OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through
   918  	// those pieces.
   919  	case syntax.OpConcat:
   920  		for _, child := range rx.Sub {
   921  			appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo, captures)
   922  			if err != nil {
   923  				return nil, err
   924  			}
   925  		}
   926  
   927  	// OpLiteral is a literal string in the pattern - append it to the paths we are building.
   928  	case syntax.OpLiteral:
   929  		for _, collector := range appendingTo {
   930  			collector.WriteString(string(rx.Rune))
   931  		}
   932  
   933  	// OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths
   934  	// into, and independently recurse through each alternate option.
   935  	case syntax.OpAlternate: // i.e |
   936  		var totalAppendingTo []*pathCollector
   937  		lastIndex := len(rx.Sub) - 1
   938  		for index, child := range rx.Sub {
   939  			var childAppendingTo []*pathCollector
   940  			if index == lastIndex {
   941  				// Optimization: last time through this loop, we can simply re-use the existing set of pathCollector
   942  				// instances, as we no longer need to preserve them unmodified to make further copies of.
   943  				childAppendingTo = appendingTo
   944  			} else {
   945  				for _, collector := range appendingTo {
   946  					newCollector := new(pathCollector)
   947  					newCollector.WriteString(collector.String())
   948  					newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
   949  					childAppendingTo = append(childAppendingTo, newCollector)
   950  				}
   951  			}
   952  			childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo, captures)
   953  			if err != nil {
   954  				return nil, err
   955  			}
   956  			totalAppendingTo = append(totalAppendingTo, childAppendingTo...)
   957  		}
   958  		appendingTo = totalAppendingTo
   959  
   960  	// OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string.
   961  	case syntax.OpQuest:
   962  		child := rx.Sub[0]
   963  		var childAppendingTo []*pathCollector
   964  		for _, collector := range appendingTo {
   965  			newCollector := new(pathCollector)
   966  			newCollector.WriteString(collector.String())
   967  			newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
   968  			childAppendingTo = append(childAppendingTo, newCollector)
   969  		}
   970  		childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo, captures)
   971  		if err != nil {
   972  			return nil, err
   973  		}
   974  		appendingTo = append(appendingTo, childAppendingTo...)
   975  
   976  		// Many Vault path patterns end with `/?` to accept paths that end with or without a slash. Our current
   977  		// convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case
   978  		// detects when we just appended a single conditional slash, and records the length of the path at this point,
   979  		// so we can later discard this path variant, if nothing else is appended to it later.
   980  		if child.Op == syntax.OpLiteral && string(child.Rune) == "/" {
   981  			for _, collector := range childAppendingTo {
   982  				collector.conditionalSlashAppendedAtLength = collector.Len()
   983  			}
   984  		}
   985  
   986  	// OpCapture, i.e. ( ) or (?P<name> ), a capturing group
   987  	case syntax.OpCapture:
   988  		if rx.Name == "" {
   989  			// In Vault, an unnamed capturing group is not actually used for capturing.
   990  			// We treat it exactly the same as OpConcat.
   991  			for _, child := range rx.Sub {
   992  				appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo, captures)
   993  				if err != nil {
   994  					return nil, err
   995  				}
   996  			}
   997  		} else {
   998  			// A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group
   999  			// is NOT added to the OpenAPI path.
  1000  			for _, builder := range appendingTo {
  1001  				builder.WriteRune('{')
  1002  				builder.WriteString(rx.Name)
  1003  				builder.WriteRune('}')
  1004  			}
  1005  			captures[rx.Name] = struct{}{}
  1006  		}
  1007  
  1008  	// Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of
  1009  	// the OpenAPI entirely - that's better than generating a path which is incorrect.
  1010  	//
  1011  	// The Op types we expect to hit the default condition are:
  1012  	//
  1013  	//     OpCharClass    - i.e. [something]
  1014  	//     OpAnyCharNotNL - i.e. .
  1015  	//     OpAnyChar      - i.e. (?s:.)
  1016  	//     OpStar         - i.e. *
  1017  	//     OpPlus         - i.e. +
  1018  	//     OpRepeat       - i.e. {N}, {N,M}, etc.
  1019  	//
  1020  	// In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only
  1021  	// applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.)
  1022  	//
  1023  	// At the time of writing, the only pattern in the builtin Vault plugins that hits this codepath is the ".*"
  1024  	// pattern in the KVv2 secrets engine, which is not a valid path, but rather, is a catch-all used to implement
  1025  	// custom error handling behaviour to guide users who attempt to treat a KVv2 as a KVv1. It is already marked as
  1026  	// Unpublished, so is withheld from the OpenAPI anyway.
  1027  	//
  1028  	// For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by
  1029  	// subsequent Simplify in preparation to Compile, which is not used here.
  1030  	default:
  1031  		return nil, errUnsupportableRegexpOperationForOpenAPI
  1032  	}
  1033  
  1034  	return appendingTo, nil
  1035  }
  1036  
  1037  // schemaType is a subset of the JSON Schema elements used as a target
  1038  // for conversions from Vault's standard FieldTypes.
  1039  type schemaType struct {
  1040  	baseType string
  1041  	items    string
  1042  	format   string
  1043  	pattern  string
  1044  }
  1045  
  1046  // convertType translates a FieldType into an OpenAPI type.
  1047  // In the case of arrays, a subtype is returned as well.
  1048  func convertType(t FieldType) schemaType {
  1049  	ret := schemaType{}
  1050  
  1051  	switch t {
  1052  	case TypeString, TypeHeader:
  1053  		ret.baseType = "string"
  1054  	case TypeNameString:
  1055  		ret.baseType = "string"
  1056  		ret.pattern = `\w([\w-.]*\w)?`
  1057  	case TypeLowerCaseString:
  1058  		ret.baseType = "string"
  1059  		ret.format = "lowercase"
  1060  	case TypeInt:
  1061  		ret.baseType = "integer"
  1062  	case TypeInt64:
  1063  		ret.baseType = "integer"
  1064  		ret.format = "int64"
  1065  	case TypeDurationSecond, TypeSignedDurationSecond:
  1066  		ret.baseType = "string"
  1067  		ret.format = "duration"
  1068  	case TypeBool:
  1069  		ret.baseType = "boolean"
  1070  	case TypeMap:
  1071  		ret.baseType = "object"
  1072  		ret.format = "map"
  1073  	case TypeKVPairs:
  1074  		ret.baseType = "object"
  1075  		ret.format = "kvpairs"
  1076  	case TypeSlice:
  1077  		ret.baseType = "array"
  1078  		ret.items = "object"
  1079  	case TypeStringSlice, TypeCommaStringSlice:
  1080  		ret.baseType = "array"
  1081  		ret.items = "string"
  1082  	case TypeCommaIntSlice:
  1083  		ret.baseType = "array"
  1084  		ret.items = "integer"
  1085  	case TypeTime:
  1086  		ret.baseType = "string"
  1087  		ret.format = "date-time"
  1088  	case TypeFloat:
  1089  		ret.baseType = "number"
  1090  		ret.format = "float"
  1091  	default:
  1092  		log.L().Warn("error parsing field type", "type", t)
  1093  		ret.format = "unknown"
  1094  	}
  1095  
  1096  	return ret
  1097  }
  1098  
  1099  // cleanString prepares s for inclusion in the output
  1100  func cleanString(s string) string {
  1101  	// clean leading/trailing whitespace, and replace whitespace runs into a single space
  1102  	s = strings.TrimSpace(s)
  1103  	s = wsRe.ReplaceAllString(s, " ")
  1104  	return s
  1105  }
  1106  
  1107  // splitFields partitions fields into path, query and body groups. It uses information on capturing groups previously
  1108  // collected by expandPattern, which is necessary to correctly match the treatment in (*Backend).HandleRequest:
  1109  // a field counts as a path field if it appears in any capture in the regex, and if that capture was inside an
  1110  // alternation or optional part of the regex which does not survive in the OpenAPI path pattern currently being
  1111  // processed, that field should NOT be rendered to the OpenAPI spec AT ALL.
  1112  func splitFields(
  1113  	allFields map[string]*FieldSchema,
  1114  	openAPIPathPattern string,
  1115  	captures map[string]struct{},
  1116  ) (pathFields, queryFields, bodyFields map[string]*FieldSchema) {
  1117  	pathFields = make(map[string]*FieldSchema)
  1118  	queryFields = make(map[string]*FieldSchema)
  1119  	bodyFields = make(map[string]*FieldSchema)
  1120  
  1121  	for _, match := range pathFieldsRe.FindAllStringSubmatch(openAPIPathPattern, -1) {
  1122  		name := match[1]
  1123  		pathFields[name] = allFields[name]
  1124  	}
  1125  
  1126  	for name, field := range allFields {
  1127  		// Any field which relates to a regex capture was already processed above, if it needed to be.
  1128  		if _, ok := captures[name]; !ok {
  1129  			if field.Query {
  1130  				queryFields[name] = field
  1131  			} else {
  1132  				bodyFields[name] = field
  1133  			}
  1134  		}
  1135  	}
  1136  
  1137  	return pathFields, queryFields, bodyFields
  1138  }
  1139  
  1140  // withoutOperationHints returns a copy of the given DisplayAttributes without
  1141  // OperationPrefix / OperationVerb / OperationSuffix since we don't need these
  1142  // fields in the final output.
  1143  func withoutOperationHints(in *DisplayAttributes) *DisplayAttributes {
  1144  	if in == nil {
  1145  		return nil
  1146  	}
  1147  
  1148  	copy := *in
  1149  
  1150  	copy.OperationPrefix = ""
  1151  	copy.OperationVerb = ""
  1152  	copy.OperationSuffix = ""
  1153  
  1154  	// return nil if all fields are empty to avoid empty JSON objects
  1155  	if copy == (DisplayAttributes{}) {
  1156  		return nil
  1157  	}
  1158  
  1159  	return &copy
  1160  }
  1161  
  1162  func hyphenatedToTitleCase(in string) string {
  1163  	var b strings.Builder
  1164  
  1165  	title := cases.Title(language.English, cases.NoLower)
  1166  
  1167  	for _, word := range strings.Split(in, "-") {
  1168  		b.WriteString(title.String(word))
  1169  	}
  1170  
  1171  	return b.String()
  1172  }
  1173  
  1174  // cleanedResponse is identical to logical.Response but with nulls
  1175  // removed from from JSON encoding
  1176  type cleanedResponse struct {
  1177  	Secret    *logical.Secret            `json:"secret,omitempty"`
  1178  	Auth      *logical.Auth              `json:"auth,omitempty"`
  1179  	Data      map[string]interface{}     `json:"data,omitempty"`
  1180  	Redirect  string                     `json:"redirect,omitempty"`
  1181  	Warnings  []string                   `json:"warnings,omitempty"`
  1182  	WrapInfo  *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"`
  1183  	Headers   map[string][]string        `json:"headers,omitempty"`
  1184  	MountType string                     `json:"mount_type,omitempty"`
  1185  }
  1186  
  1187  func cleanResponse(resp *logical.Response) *cleanedResponse {
  1188  	return &cleanedResponse{
  1189  		Secret:    resp.Secret,
  1190  		Auth:      resp.Auth,
  1191  		Data:      resp.Data,
  1192  		Redirect:  resp.Redirect,
  1193  		Warnings:  resp.Warnings,
  1194  		WrapInfo:  resp.WrapInfo,
  1195  		Headers:   resp.Headers,
  1196  		MountType: resp.MountType,
  1197  	}
  1198  }
  1199  
  1200  // CreateOperationIDs generates unique operationIds for all paths/methods.
  1201  // The transform will convert path/method into camelcase. e.g.:
  1202  //
  1203  // /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes
  1204  //
  1205  // In the unlikely case of a duplicate ids, a numeric suffix is added:
  1206  //
  1207  //	postSysToolsRandomUrlbytes_2
  1208  //
  1209  // An optional user-provided suffix ("context") may also be appended.
  1210  //
  1211  // Deprecated: operationID's are now populated using `constructOperationID`.
  1212  // This function is here for backwards compatibility with older plugins.
  1213  func (d *OASDocument) CreateOperationIDs(context string) {
  1214  	opIDCount := make(map[string]int)
  1215  	var paths []string
  1216  
  1217  	// traverse paths in a stable order to ensure stable output
  1218  	for path := range d.Paths {
  1219  		paths = append(paths, path)
  1220  	}
  1221  	sort.Strings(paths)
  1222  
  1223  	for _, path := range paths {
  1224  		pi := d.Paths[path]
  1225  		for _, method := range []string{"get", "post", "delete"} {
  1226  			var oasOperation *OASOperation
  1227  			switch method {
  1228  			case "get":
  1229  				oasOperation = pi.Get
  1230  			case "post":
  1231  				oasOperation = pi.Post
  1232  			case "delete":
  1233  				oasOperation = pi.Delete
  1234  			}
  1235  
  1236  			if oasOperation == nil {
  1237  				continue
  1238  			}
  1239  
  1240  			if oasOperation.OperationID != "" {
  1241  				continue
  1242  			}
  1243  
  1244  			// Discard "_mount_path" from any {thing_mount_path} parameters
  1245  			path = strings.Replace(path, "_mount_path", "", 1)
  1246  
  1247  			// Space-split on non-words, title case everything, recombine
  1248  			opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ")
  1249  			opID = strings.Title(opID)
  1250  			opID = method + strings.ReplaceAll(opID, " ", "")
  1251  
  1252  			// deduplicate operationIds. This is a safeguard, since generated IDs should
  1253  			// already be unique given our current path naming conventions.
  1254  			opIDCount[opID]++
  1255  			if opIDCount[opID] > 1 {
  1256  				opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID])
  1257  			}
  1258  
  1259  			if context != "" {
  1260  				opID += "_" + context
  1261  			}
  1262  
  1263  			oasOperation.OperationID = opID
  1264  		}
  1265  	}
  1266  }