// Copyright 2015 go-swagger maintainers // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package analysis import ( "fmt" "log" "net/http" "net/url" "os" slashpath "path" "path/filepath" "sort" "strings" "strconv" "github.com/go-openapi/analysis/internal" "github.com/go-openapi/jsonpointer" swspec "github.com/go-openapi/spec" "github.com/go-openapi/swag" ) // FlattenOpts configuration for flattening a swagger specification. type FlattenOpts struct { Spec *Spec // The analyzed spec to work with flattenContext *context // Internal context to track flattening activity BasePath string // Flattening options Expand bool // If Expand is true, we skip flattening the spec and expand it instead Minimal bool Verbose bool RemoveUnused bool /* Extra keys */ _ struct{} // require keys } // ExpandOpts creates a spec.ExpandOptions to configure expanding a specification document. func (f *FlattenOpts) ExpandOpts(skipSchemas bool) *swspec.ExpandOptions { return &swspec.ExpandOptions{RelativeBase: f.BasePath, SkipSchemas: skipSchemas} } // Swagger gets the swagger specification for this flatten operation func (f *FlattenOpts) Swagger() *swspec.Swagger { return f.Spec.spec } // newRef stores information about refs created during the flattening process type newRef struct { key string newName string path string isOAIGen bool resolved bool schema *swspec.Schema parents []string } // context stores intermediary results from flatten type context struct { newRefs map[string]*newRef warnings []string resolved map[string]string } func newContext() *context { return &context{ newRefs: make(map[string]*newRef, 150), warnings: make([]string, 0), resolved: make(map[string]string, 50), } } // Flatten an analyzed spec and produce a self-contained spec bundle. // // There is a minimal and a full flattening mode. // // Minimally flattening a spec means: // - Expanding parameters, responses, path items, parameter items and header items (references to schemas are left // unscathed) // - Importing external (http, file) references so they become internal to the document // - Moving every JSON pointer to a $ref to a named definition (i.e. the reworked spec does not contain pointers // like "$ref": "#/definitions/myObject/allOfs/1") // // A minimally flattened spec thus guarantees the following properties: // - all $refs point to a local definition (i.e. '#/definitions/...') // - definitions are unique // // NOTE: arbitrary JSON pointers (other than $refs to top level definitions) are rewritten as definitions if they // represent a complex schema or express commonality in the spec. // Otherwise, they are simply expanded. // // Minimal flattening is necessary and sufficient for codegen rendering using go-swagger. // // Fully flattening a spec means: // - Moving every complex inline schema to be a definition with an auto-generated name in a depth-first fashion. // // By complex, we mean every JSON object with some properties. // Arrays, when they do not define a tuple, // or empty objects with or without additionalProperties, are not considered complex and remain inline. // // NOTE: rewritten schemas get a vendor extension x-go-gen-location so we know from which part of the spec definitions // have been created. // // Available flattening options: // - Minimal: stops flattening after minimal $ref processing, leaving schema constructs untouched // - Expand: expand all $ref's in the document (inoperant if Minimal set to true) // - Verbose: croaks about name conflicts detected // - RemoveUnused: removes unused parameters, responses and definitions after expansion/flattening // // NOTE: expansion removes all $ref save circular $ref, which remain in place // // TODO: additional options // - ProgagateNameExtensions: ensure that created entries properly follow naming rules when their parent have set a // x-go-name extension // - LiftAllOfs: // - limit the flattening of allOf members when simple objects // - merge allOf with validation only // - merge allOf with extensions only // - ... // func Flatten(opts FlattenOpts) error { // Make sure opts.BasePath is an absolute path if !filepath.IsAbs(opts.BasePath) { cwd, _ := os.Getwd() opts.BasePath = filepath.Join(cwd, opts.BasePath) } // make sure drive letter on windows is normalized to lower case u, _ := url.Parse(opts.BasePath) opts.BasePath = u.String() opts.flattenContext = newContext() // recursively expand responses, parameters, path items and items in simple schemas. // This simplifies the spec and leaves $ref only into schema objects. if err := swspec.ExpandSpec(opts.Swagger(), opts.ExpandOpts(!opts.Expand)); err != nil { return err } // strip current file from $ref's, so we can recognize them as proper definitions // In particular, this works around for issue go-openapi/spec#76: leading absolute file in $ref is stripped if err := normalizeRef(&opts); err != nil { return err } if opts.RemoveUnused { // optionally removes shared parameters and responses already expanded (now unused) // default parameters (i.e. under paths) remain. opts.Swagger().Parameters = nil opts.Swagger().Responses = nil } opts.Spec.reload() // re-analyze // at this point there are no references left but in schemas for imported := false; !imported; { // iteratively import remote references until none left. // This inlining deals with name conflicts by introducing auto-generated names ("OAIGen") var err error if imported, err = importExternalReferences(&opts); err != nil { return err } opts.Spec.reload() // re-analyze } if !opts.Minimal && !opts.Expand { // full flattening: rewrite inline schemas (schemas that aren't simple types or arrays or maps) if err := nameInlinedSchemas(&opts); err != nil { return err } opts.Spec.reload() // re-analyze } // rewrite JSON pointers other than $ref to named definitions // and attempt to resolve conflicting names whenever possible. if err := stripPointersAndOAIGen(&opts); err != nil { return err } if opts.RemoveUnused { // remove unused definitions expected := make(map[string]struct{}) for k := range opts.Swagger().Definitions { expected[slashpath.Join(definitionsPath, jsonpointer.Escape(k))] = struct{}{} } for _, k := range opts.Spec.AllDefinitionReferences() { delete(expected, k) } for k := range expected { debugLog("removing unused definition %s", slashpath.Base(k)) if opts.Verbose { log.Printf("info: removing unused definition: %s", slashpath.Base(k)) } delete(opts.Swagger().Definitions, slashpath.Base(k)) } opts.Spec.reload() // re-analyze } // TODO: simplify known schema patterns to flat objects with properties // examples: // - lift simple allOf object, // - empty allOf with validation only or extensions only // - rework allOf arrays // - rework allOf additionalProperties if opts.Verbose { // issue notifications croak(&opts) } return nil } // isAnalyzedAsComplex determines if an analyzed schema is eligible to flattening (i.e. it is "complex"). // // Complex means the schema is any of: // - a simple type (primitive) // - an array of something (items are possibly complex ; if this is the case, items will generate a definition) // - a map of something (additionalProperties are possibly complex ; if this is the case, additionalProperties will // generate a definition) func isAnalyzedAsComplex(asch *AnalyzedSchema) bool { if !asch.IsSimpleSchema && !asch.IsArray && !asch.IsMap { return true } return false } // nameInlinedSchemas replaces every complex inline construct by a named definition. func nameInlinedSchemas(opts *FlattenOpts) error { debugLog("nameInlinedSchemas") namer := &inlineSchemaNamer{ Spec: opts.Swagger(), Operations: opRefsByRef(gatherOperations(opts.Spec, nil)), flattenContext: opts.flattenContext, opts: opts, } depthFirst := sortDepthFirst(opts.Spec.allSchemas) for _, key := range depthFirst { sch := opts.Spec.allSchemas[key] if sch.Schema != nil && sch.Schema.Ref.String() == "" && !sch.TopLevel { // inline schema asch, err := Schema(SchemaOpts{Schema: sch.Schema, Root: opts.Swagger(), BasePath: opts.BasePath}) if err != nil { return fmt.Errorf("schema analysis [%s]: %v", key, err) } if isAnalyzedAsComplex(asch) { // move complex schemas to definitions if err := namer.Name(key, sch.Schema, asch); err != nil { return err } } } } return nil } var depthGroupOrder = []string{ "sharedParam", "sharedResponse", "sharedOpParam", "opParam", "codeResponse", "defaultResponse", "definition", } func sortDepthFirst(data map[string]SchemaRef) []string { // group by category (shared params, op param, statuscode response, default response, definitions) // sort groups internally by number of parts in the key and lexical names // flatten groups into a single list of keys sorted := make([]string, 0, len(data)) grouped := make(map[string]keys, len(data)) for k := range data { split := keyParts(k) var pk string if split.IsSharedOperationParam() { pk = "sharedOpParam" } if split.IsOperationParam() { pk = "opParam" } if split.IsStatusCodeResponse() { pk = "codeResponse" } if split.IsDefaultResponse() { pk = "defaultResponse" } if split.IsDefinition() { pk = "definition" } if split.IsSharedParam() { pk = "sharedParam" } if split.IsSharedResponse() { pk = "sharedResponse" } grouped[pk] = append(grouped[pk], key{Segments: len(split), Key: k}) } for _, pk := range depthGroupOrder { res := grouped[pk] sort.Sort(res) for _, v := range res { sorted = append(sorted, v.Key) } } return sorted } type key struct { Segments int Key string } type keys []key func (k keys) Len() int { return len(k) } func (k keys) Swap(i, j int) { k[i], k[j] = k[j], k[i] } func (k keys) Less(i, j int) bool { return k[i].Segments > k[j].Segments || (k[i].Segments == k[j].Segments && k[i].Key < k[j].Key) } type inlineSchemaNamer struct { Spec *swspec.Swagger Operations map[string]opRef flattenContext *context opts *FlattenOpts } func opRefsByRef(oprefs map[string]opRef) map[string]opRef { result := make(map[string]opRef, len(oprefs)) for _, v := range oprefs { result[v.Ref.String()] = v } return result } func (isn *inlineSchemaNamer) Name(key string, schema *swspec.Schema, aschema *AnalyzedSchema) error { debugLog("naming inlined schema at %s", key) parts := keyParts(key) for _, name := range namesFromKey(parts, aschema, isn.Operations) { if name != "" { // create unique name newName, isOAIGen := uniqifyName(isn.Spec.Definitions, swag.ToJSONName(name)) // clone schema sch, err := cloneSchema(schema) if err != nil { return err } // replace values on schema if err := rewriteSchemaToRef(isn.Spec, key, swspec.MustCreateRef(slashpath.Join(definitionsPath, newName))); err != nil { return fmt.Errorf("error while creating definition %q from inline schema: %v", newName, err) } // rewrite any dependent $ref pointing to this place, // when not already pointing to a top-level definition. // // NOTE: this is important if such referers use arbitrary JSON pointers. an := New(isn.Spec) for k, v := range an.references.allRefs { r, _, erd := deepestRef(isn.opts, v) if erd != nil { return fmt.Errorf("at %s, %v", k, erd) } if r.String() == key || r.String() == slashpath.Join(definitionsPath, newName) && slashpath.Dir(v.String()) != definitionsPath { debugLog("found a $ref to a rewritten schema: %s points to %s", k, v.String()) // rewrite $ref to the new target if err := updateRef(isn.Spec, k, swspec.MustCreateRef(slashpath.Join(definitionsPath, newName))); err != nil { return err } } } // NOTE: this extension is currently not used by go-swagger (provided for information only) sch.AddExtension("x-go-gen-location", genLocation(parts)) // save cloned schema to definitions saveSchema(isn.Spec, newName, sch) // keep track of created refs if isn.flattenContext != nil { debugLog("track created ref: key=%s, newName=%s, isOAIGen=%t", key, newName, isOAIGen) resolved := false if _, ok := isn.flattenContext.newRefs[key]; ok { resolved = isn.flattenContext.newRefs[key].resolved } isn.flattenContext.newRefs[key] = &newRef{ key: key, newName: newName, path: slashpath.Join(definitionsPath, newName), isOAIGen: isOAIGen, resolved: resolved, schema: sch, } } } } return nil } // genLocation indicates from which section of the specification (models or operations) a definition has been created. // // This is reflected in the output spec with a "x-go-gen-location" extension. At the moment, this is is provided // for information only. func genLocation(parts splitKey) string { if parts.IsOperation() { return "operations" } if parts.IsDefinition() { return "models" } return "" } // uniqifyName yields a unique name for a definition func uniqifyName(definitions swspec.Definitions, name string) (string, bool) { isOAIGen := false if name == "" { name = "oaiGen" isOAIGen = true } if len(definitions) == 0 { return name, isOAIGen } unq := true for k := range definitions { if strings.EqualFold(k, name) { unq = false break } } if unq { return name, isOAIGen } name += "OAIGen" isOAIGen = true var idx int unique := name _, known := definitions[unique] for known { idx++ unique = fmt.Sprintf("%s%d", name, idx) _, known = definitions[unique] } return unique, isOAIGen } func namesFromKey(parts splitKey, aschema *AnalyzedSchema, operations map[string]opRef) []string { var baseNames [][]string var startIndex int if parts.IsOperation() { // params if parts.IsOperationParam() || parts.IsSharedOperationParam() { piref := parts.PathItemRef() if piref.String() != "" && parts.IsOperationParam() { if op, ok := operations[piref.String()]; ok { startIndex = 5 baseNames = append(baseNames, []string{op.ID, "params", "body"}) } } else if parts.IsSharedOperationParam() { pref := parts.PathRef() for k, v := range operations { if strings.HasPrefix(k, pref.String()) { startIndex = 4 baseNames = append(baseNames, []string{v.ID, "params", "body"}) } } } } // responses if parts.IsOperationResponse() { piref := parts.PathItemRef() if piref.String() != "" { if op, ok := operations[piref.String()]; ok { startIndex = 6 baseNames = append(baseNames, []string{op.ID, parts.ResponseName(), "body"}) } } } } // definitions if parts.IsDefinition() { nm := parts.DefinitionName() if nm != "" { startIndex = 2 baseNames = append(baseNames, []string{parts.DefinitionName()}) } } var result []string for _, segments := range baseNames { nm := parts.BuildName(segments, startIndex, aschema) if nm != "" { result = append(result, nm) } } sort.Strings(result) return result } const ( paths = "paths" responses = "responses" parameters = "parameters" definitions = "definitions" definitionsPath = "#/definitions" ) var ( ignoredKeys map[string]struct{} validMethods map[string]struct{} ) func init() { ignoredKeys = map[string]struct{}{ "schema": {}, "properties": {}, "not": {}, "anyOf": {}, "oneOf": {}, } validMethods = map[string]struct{}{ "GET": {}, "HEAD": {}, "OPTIONS": {}, "PATCH": {}, "POST": {}, "PUT": {}, "DELETE": {}, } } type splitKey []string func (s splitKey) IsDefinition() bool { return len(s) > 1 && s[0] == definitions } func (s splitKey) DefinitionName() string { if !s.IsDefinition() { return "" } return s[1] } func (s splitKey) isKeyName(i int) bool { if i <= 0 { return false } count := 0 for idx := i - 1; idx > 0; idx-- { if s[idx] != "properties" { break } count++ } return count%2 != 0 } func (s splitKey) BuildName(segments []string, startIndex int, aschema *AnalyzedSchema) string { for i, part := range s[startIndex:] { if _, ignored := ignoredKeys[part]; !ignored || s.isKeyName(startIndex+i) { if part == "items" || part == "additionalItems" { if aschema.IsTuple || aschema.IsTupleWithExtra { segments = append(segments, "tuple") } else { segments = append(segments, "items") } if part == "additionalItems" { segments = append(segments, part) } continue } segments = append(segments, part) } } return strings.Join(segments, " ") } func (s splitKey) IsOperation() bool { return len(s) > 1 && s[0] == paths } func (s splitKey) IsSharedOperationParam() bool { return len(s) > 2 && s[0] == paths && s[2] == parameters } func (s splitKey) IsSharedParam() bool { return len(s) > 1 && s[0] == parameters } func (s splitKey) IsOperationParam() bool { return len(s) > 3 && s[0] == paths && s[3] == parameters } func (s splitKey) IsOperationResponse() bool { return len(s) > 3 && s[0] == paths && s[3] == responses } func (s splitKey) IsSharedResponse() bool { return len(s) > 1 && s[0] == responses } func (s splitKey) IsDefaultResponse() bool { return len(s) > 4 && s[0] == paths && s[3] == responses && s[4] == "default" } func (s splitKey) IsStatusCodeResponse() bool { isInt := func() bool { _, err := strconv.Atoi(s[4]) return err == nil } return len(s) > 4 && s[0] == paths && s[3] == responses && isInt() } func (s splitKey) ResponseName() string { if s.IsStatusCodeResponse() { code, _ := strconv.Atoi(s[4]) return http.StatusText(code) } if s.IsDefaultResponse() { return "Default" } return "" } func (s splitKey) PathItemRef() swspec.Ref { if len(s) < 3 { return swspec.Ref{} } pth, method := s[1], s[2] if _, isValidMethod := validMethods[strings.ToUpper(method)]; !isValidMethod && !strings.HasPrefix(method, "x-") { return swspec.Ref{} } return swspec.MustCreateRef("#" + slashpath.Join("/", paths, jsonpointer.Escape(pth), strings.ToUpper(method))) } func (s splitKey) PathRef() swspec.Ref { if !s.IsOperation() { return swspec.Ref{} } return swspec.MustCreateRef("#" + slashpath.Join("/", paths, jsonpointer.Escape(s[1]))) } func keyParts(key string) splitKey { var res []string for _, part := range strings.Split(key[1:], "/") { if part != "" { res = append(res, jsonpointer.Unescape(part)) } } return res } func rewriteSchemaToRef(spec *swspec.Swagger, key string, ref swspec.Ref) error { debugLog("rewriting schema to ref for %s with %s", key, ref.String()) _, value, err := getPointerFromKey(spec, key) if err != nil { return err } switch refable := value.(type) { case *swspec.Schema: return rewriteParentRef(spec, key, ref) case swspec.Schema: return rewriteParentRef(spec, key, ref) case *swspec.SchemaOrArray: if refable.Schema != nil { refable.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} } case *swspec.SchemaOrBool: if refable.Schema != nil { refable.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} } default: return fmt.Errorf("no schema with ref found at %s for %T", key, value) } return nil } func rewriteParentRef(spec *swspec.Swagger, key string, ref swspec.Ref) error { parent, entry, pvalue, err := getParentFromKey(spec, key) if err != nil { return err } debugLog("rewriting holder for %T", pvalue) switch container := pvalue.(type) { case swspec.Response: if err := rewriteParentRef(spec, "#"+parent, ref); err != nil { return err } case *swspec.Response: container.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case *swspec.Responses: statusCode, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", key[1:], err) } resp := container.StatusCodeResponses[statusCode] resp.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} container.StatusCodeResponses[statusCode] = resp case map[string]swspec.Response: resp := container[entry] resp.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} container[entry] = resp case swspec.Parameter: if err := rewriteParentRef(spec, "#"+parent, ref); err != nil { return err } case map[string]swspec.Parameter: param := container[entry] param.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} container[entry] = param case []swspec.Parameter: idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", key[1:], err) } param := container[idx] param.Schema = &swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} container[idx] = param case swspec.Definitions: container[entry] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case map[string]swspec.Schema: container[entry] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case []swspec.Schema: idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", key[1:], err) } container[idx] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case *swspec.SchemaOrArray: // NOTE: this is necessarily an array - otherwise, the parent would be *Schema idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", key[1:], err) } container.Schemas[idx] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} // NOTE: can't have case *swspec.SchemaOrBool = parent in this case is *Schema default: return fmt.Errorf("unhandled parent schema rewrite %s (%T)", key, pvalue) } return nil } func cloneSchema(schema *swspec.Schema) (*swspec.Schema, error) { var sch swspec.Schema if err := swag.FromDynamicJSON(schema, &sch); err != nil { return nil, fmt.Errorf("cannot clone schema: %v", err) } return &sch, nil } // importExternalReferences iteratively digs remote references and imports them into the main schema. // // At every iteration, new remotes may be found when digging deeper: they are rebased to the current schema before being imported. // // This returns true when no more remote references can be found. func importExternalReferences(opts *FlattenOpts) (bool, error) { debugLog("importExternalReferences") groupedRefs := reverseIndexForSchemaRefs(opts) sortedRefStr := make([]string, 0, len(groupedRefs)) if opts.flattenContext == nil { opts.flattenContext = newContext() } // sort $ref resolution to ensure deterministic name conflict resolution for refStr := range groupedRefs { sortedRefStr = append(sortedRefStr, refStr) } sort.Strings(sortedRefStr) complete := true for _, refStr := range sortedRefStr { entry := groupedRefs[refStr] if entry.Ref.HasFragmentOnly { continue } complete = false var isOAIGen bool newName := opts.flattenContext.resolved[refStr] if newName != "" { // rewrite ref with already resolved external ref (useful for cyclical refs): // rewrite external refs to local ones debugLog("resolving known ref [%s] to %s", refStr, newName) for _, key := range entry.Keys { if err := updateRef(opts.Swagger(), key, swspec.MustCreateRef(slashpath.Join(definitionsPath, newName))); err != nil { return false, err } } } else { // resolve schemas debugLog("resolving schema from remote $ref [%s]", refStr) sch, err := swspec.ResolveRefWithBase(opts.Swagger(), &entry.Ref, opts.ExpandOpts(false)) if err != nil { return false, fmt.Errorf("could not resolve schema: %v", err) } // at this stage only $ref analysis matters partialAnalyzer := &Spec{ references: referenceAnalysis{}, patterns: patternAnalysis{}, enums: enumAnalysis{}, } partialAnalyzer.reset() partialAnalyzer.analyzeSchema("", *sch, "/") // now rewrite those refs with rebase for key, ref := range partialAnalyzer.references.allRefs { if err := updateRef(sch, key, swspec.MustCreateRef(rebaseRef(entry.Ref.String(), ref.String()))); err != nil { return false, fmt.Errorf("failed to rewrite ref for key %q at %s: %v", key, entry.Ref.String(), err) } } // generate a unique name - isOAIGen means that a naming conflict was resolved by changing the name newName, isOAIGen = uniqifyName(opts.Swagger().Definitions, nameFromRef(entry.Ref)) debugLog("new name for [%s]: %s - with name conflict:%t", strings.Join(entry.Keys, ", "), newName, isOAIGen) opts.flattenContext.resolved[refStr] = newName // rewrite the external refs to local ones for _, key := range entry.Keys { if err := updateRef(opts.Swagger(), key, swspec.MustCreateRef(slashpath.Join(definitionsPath, newName))); err != nil { return false, err } // keep track of created refs resolved := false if _, ok := opts.flattenContext.newRefs[key]; ok { resolved = opts.flattenContext.newRefs[key].resolved } opts.flattenContext.newRefs[key] = &newRef{ key: key, newName: newName, path: slashpath.Join(definitionsPath, newName), isOAIGen: isOAIGen, resolved: resolved, schema: sch, } } // add the resolved schema to the definitions saveSchema(opts.Swagger(), newName, sch) } } // maintains ref index entries for k := range opts.flattenContext.newRefs { r := opts.flattenContext.newRefs[k] // update tracking with resolved schemas if r.schema.Ref.String() != "" { ref := swspec.MustCreateRef(r.path) sch, err := swspec.ResolveRefWithBase(opts.Swagger(), &ref, opts.ExpandOpts(false)) if err != nil { return false, fmt.Errorf("could not resolve schema: %v", err) } r.schema = sch } // update tracking with renamed keys: got a cascade of refs if r.path != k { renamed := *r renamed.key = r.path opts.flattenContext.newRefs[renamed.path] = &renamed // indirect ref r.newName = slashpath.Base(k) r.schema = swspec.RefSchema(r.path) r.path = k r.isOAIGen = strings.Contains(k, "OAIGen") } } return complete, nil } type refRevIdx struct { Ref swspec.Ref Keys []string } // rebaseRef rebase a remote ref relative to a base ref. // // NOTE: does not support JSONschema ID for $ref (we assume we are working with swagger specs here). // // NOTE(windows): // * refs are assumed to have been normalized with drive letter lower cased (from go-openapi/spec) // * "/ in paths may appear as escape sequences func rebaseRef(baseRef string, ref string) string { debugLog("rebasing ref: %s onto %s", ref, baseRef) baseRef, _ = url.PathUnescape(baseRef) ref, _ = url.PathUnescape(ref) if baseRef == "" || baseRef == "." || strings.HasPrefix(baseRef, "#") { return ref } parts := strings.Split(ref, "#") baseParts := strings.Split(baseRef, "#") baseURL, _ := url.Parse(baseParts[0]) if strings.HasPrefix(ref, "#") { if baseURL.Host == "" { return strings.Join([]string{baseParts[0], parts[1]}, "#") } return strings.Join([]string{baseParts[0], parts[1]}, "#") } refURL, _ := url.Parse(parts[0]) if refURL.Host != "" || filepath.IsAbs(parts[0]) { // not rebasing an absolute path return ref } // there is a relative path var basePath string if baseURL.Host != "" { // when there is a host, standard URI rules apply (with "/") baseURL.Path = slashpath.Dir(baseURL.Path) baseURL.Path = slashpath.Join(baseURL.Path, "/"+parts[0]) return baseURL.String() } // this is a local relative path // basePart[0] and parts[0] are local filesystem directories/files basePath = filepath.Dir(baseParts[0]) relPath := filepath.Join(basePath, string(filepath.Separator)+parts[0]) if len(parts) > 1 { return strings.Join([]string{relPath, parts[1]}, "#") } return relPath } // normalizePath renders absolute path on remote file refs // // NOTE(windows): // * refs are assumed to have been normalized with drive letter lower cased (from go-openapi/spec) // * "/ in paths may appear as escape sequences func normalizePath(ref swspec.Ref, opts *FlattenOpts) (normalizedPath string) { uri, _ := url.PathUnescape(ref.String()) if ref.HasFragmentOnly || filepath.IsAbs(uri) { normalizedPath = uri return } refURL, _ := url.Parse(uri) if refURL.Host != "" { normalizedPath = uri return } parts := strings.Split(uri, "#") // BasePath, parts[0] are local filesystem directories, guaranteed to be absolute at this stage parts[0] = filepath.Join(filepath.Dir(opts.BasePath), parts[0]) normalizedPath = strings.Join(parts, "#") return } func reverseIndexForSchemaRefs(opts *FlattenOpts) map[string]refRevIdx { collected := make(map[string]refRevIdx) for key, schRef := range opts.Spec.references.schemas { // normalize paths before sorting, // so we get together keys in same external file normalizedPath := normalizePath(schRef, opts) if entry, ok := collected[normalizedPath]; ok { entry.Keys = append(entry.Keys, key) collected[normalizedPath] = entry } else { collected[normalizedPath] = refRevIdx{ Ref: schRef, Keys: []string{key}, } } } return collected } func nameFromRef(ref swspec.Ref) string { u := ref.GetURL() if u.Fragment != "" { return swag.ToJSONName(slashpath.Base(u.Fragment)) } if u.Path != "" { bn := slashpath.Base(u.Path) if bn != "" && bn != "/" { ext := slashpath.Ext(bn) if ext != "" { return swag.ToJSONName(bn[:len(bn)-len(ext)]) } return swag.ToJSONName(bn) } } return swag.ToJSONName(strings.Replace(u.Host, ".", " ", -1)) } func saveSchema(spec *swspec.Swagger, name string, schema *swspec.Schema) { if schema == nil { return } if spec.Definitions == nil { spec.Definitions = make(map[string]swspec.Schema, 150) } spec.Definitions[name] = *schema } // getPointerFromKey retrieves the content of the JSON pointer "key" func getPointerFromKey(spec interface{}, key string) (string, interface{}, error) { switch spec.(type) { case *swspec.Schema: case *swspec.Swagger: default: panic("unexpected type used in getPointerFromKey") } if key == "#/" { return "", spec, nil } // unescape chars in key, e.g. "{}" from path params pth, _ := internal.PathUnescape(key[1:]) ptr, err := jsonpointer.New(pth) if err != nil { return "", nil, err } value, _, err := ptr.Get(spec) if err != nil { debugLog("error when getting key: %s with path: %s", key, pth) return "", nil, err } return pth, value, nil } // getParentFromKey retrieves the container of the JSON pointer "key" func getParentFromKey(spec interface{}, key string) (string, string, interface{}, error) { switch spec.(type) { case *swspec.Schema: case *swspec.Swagger: default: panic("unexpected type used in getPointerFromKey") } // unescape chars in key, e.g. "{}" from path params pth, _ := internal.PathUnescape(key[1:]) parent, entry := slashpath.Dir(pth), slashpath.Base(pth) debugLog("getting schema holder at: %s, with entry: %s", parent, entry) pptr, err := jsonpointer.New(parent) if err != nil { return "", "", nil, err } pvalue, _, err := pptr.Get(spec) if err != nil { return "", "", nil, fmt.Errorf("can't get parent for %s: %v", parent, err) } return parent, entry, pvalue, nil } // updateRef replaces a ref by another one func updateRef(spec interface{}, key string, ref swspec.Ref) error { switch spec.(type) { case *swspec.Schema: case *swspec.Swagger: default: panic("unexpected type used in getPointerFromKey") } debugLog("updating ref for %s with %s", key, ref.String()) pth, value, err := getPointerFromKey(spec, key) if err != nil { return err } switch refable := value.(type) { case *swspec.Schema: refable.Ref = ref case *swspec.SchemaOrArray: if refable.Schema != nil { refable.Schema.Ref = ref } case *swspec.SchemaOrBool: if refable.Schema != nil { refable.Schema.Ref = ref } case swspec.Schema: debugLog("rewriting holder for %T", refable) _, entry, pvalue, erp := getParentFromKey(spec, key) if erp != nil { return err } switch container := pvalue.(type) { case swspec.Definitions: container[entry] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case map[string]swspec.Schema: container[entry] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case []swspec.Schema: idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", pth, err) } container[idx] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} case *swspec.SchemaOrArray: // NOTE: this is necessarily an array - otherwise, the parent would be *Schema idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", pth, err) } container.Schemas[idx] = swspec.Schema{SchemaProps: swspec.SchemaProps{Ref: ref}} // NOTE: can't have case *swspec.SchemaOrBool = parent in this case is *Schema default: return fmt.Errorf("unhandled container type at %s: %T", key, value) } default: return fmt.Errorf("no schema with ref found at %s for %T", key, value) } return nil } // updateRefWithSchema replaces a ref with a schema (i.e. re-inline schema) func updateRefWithSchema(spec *swspec.Swagger, key string, sch *swspec.Schema) error { debugLog("updating ref for %s with schema", key) pth, value, err := getPointerFromKey(spec, key) if err != nil { return err } switch refable := value.(type) { case *swspec.Schema: *refable = *sch case swspec.Schema: _, entry, pvalue, erp := getParentFromKey(spec, key) if erp != nil { return err } switch container := pvalue.(type) { case swspec.Definitions: container[entry] = *sch case map[string]swspec.Schema: container[entry] = *sch case []swspec.Schema: idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", pth, err) } container[idx] = *sch case *swspec.SchemaOrArray: // NOTE: this is necessarily an array - otherwise, the parent would be *Schema idx, err := strconv.Atoi(entry) if err != nil { return fmt.Errorf("%s not a number: %v", pth, err) } container.Schemas[idx] = *sch // NOTE: can't have case *swspec.SchemaOrBool = parent in this case is *Schema default: return fmt.Errorf("unhandled type for parent of [%s]: %T", key, value) } case *swspec.SchemaOrArray: *refable.Schema = *sch // NOTE: can't have case *swspec.SchemaOrBool = parent in this case is *Schema case *swspec.SchemaOrBool: *refable.Schema = *sch default: return fmt.Errorf("no schema with ref found at %s for %T", key, value) } return nil } func containsString(names []string, name string) bool { for _, nm := range names { if nm == name { return true } } return false } type opRef struct { Method string Path string Key string ID string Op *swspec.Operation Ref swspec.Ref } type opRefs []opRef func (o opRefs) Len() int { return len(o) } func (o opRefs) Swap(i, j int) { o[i], o[j] = o[j], o[i] } func (o opRefs) Less(i, j int) bool { return o[i].Key < o[j].Key } func gatherOperations(specDoc *Spec, operationIDs []string) map[string]opRef { var oprefs opRefs for method, pathItem := range specDoc.Operations() { for pth, operation := range pathItem { vv := *operation oprefs = append(oprefs, opRef{ Key: swag.ToGoName(strings.ToLower(method) + " " + pth), Method: method, Path: pth, ID: vv.ID, Op: &vv, Ref: swspec.MustCreateRef("#" + slashpath.Join("/paths", jsonpointer.Escape(pth), method)), }) } } sort.Sort(oprefs) operations := make(map[string]opRef) for _, opr := range oprefs { nm := opr.ID if nm == "" { nm = opr.Key } oo, found := operations[nm] if found && oo.Method != opr.Method && oo.Path != opr.Path { nm = opr.Key } if len(operationIDs) == 0 || containsString(operationIDs, opr.ID) || containsString(operationIDs, nm) { opr.ID = nm opr.Op.ID = nm operations[nm] = opr } } return operations } // stripPointersAndOAIGen removes anonymous JSON pointers from spec and chain with name conflicts handler. // This loops until the spec has no such pointer and all name conflicts have been reduced as much as possible. func stripPointersAndOAIGen(opts *FlattenOpts) error { // name all JSON pointers to anonymous documents if err := namePointers(opts); err != nil { return err } // remove unnecessary OAIGen ref (created when flattening external refs creates name conflicts) hasIntroducedPointerOrInline, ers := stripOAIGen(opts) if ers != nil { return ers } // iterate as pointer or OAIGen resolution may introduce inline schemas or pointers for hasIntroducedPointerOrInline { if !opts.Minimal { opts.Spec.reload() // re-analyze if err := nameInlinedSchemas(opts); err != nil { return err } } if err := namePointers(opts); err != nil { return err } // restrip if hasIntroducedPointerOrInline, ers = stripOAIGen(opts); ers != nil { return ers } opts.Spec.reload() // re-analyze } return nil } // stripOAIGen strips the spec from unnecessary OAIGen constructs, initially created to dedupe flattened definitions. // // A dedupe is deemed unnecessary whenever: // - the only conflict is with its (single) parent: OAIGen is merged into its parent (reinlining) // - there is a conflict with multiple parents: merge OAIGen in first parent, the rewrite other parents to point to // the first parent. // // This function returns a true bool whenever it re-inlined a complex schema, so the caller may chose to iterate // pointer and name resolution again. func stripOAIGen(opts *FlattenOpts) (bool, error) { debugLog("stripOAIGen") replacedWithComplex := false // figure out referers of OAIGen definitions for _, r := range opts.flattenContext.newRefs { if !r.isOAIGen || r.resolved { // bail on already resolved entries (avoid looping) continue } for k, v := range opts.Spec.references.allRefs { if r.path != v.String() { continue } found := false for _, p := range r.parents { if p == k { found = true break } } if !found { r.parents = append(r.parents, k) } } } for k := range opts.flattenContext.newRefs { r := opts.flattenContext.newRefs[k] //debugLog("newRefs[%s]: isOAIGen: %t, resolved: %t, name: %s, path:%s, #parents: %d, parents: %v, ref: %s", // k, r.isOAIGen, r.resolved, r.newName, r.path, len(r.parents), r.parents, r.schema.Ref.String()) if r.isOAIGen && len(r.parents) >= 1 { pr := r.parents sort.Strings(pr) // rewrite first parent schema in lexicographical order debugLog("rewrite first parent in lex order %s with schema", pr[0]) if err := updateRefWithSchema(opts.Swagger(), pr[0], r.schema); err != nil { return false, err } if pa, ok := opts.flattenContext.newRefs[pr[0]]; ok && pa.isOAIGen { // update parent in ref index entry debugLog("update parent entry: %s", pr[0]) pa.schema = r.schema pa.resolved = false replacedWithComplex = true } // rewrite other parents to point to first parent if len(pr) > 1 { for _, p := range pr[1:] { replacingRef := swspec.MustCreateRef(pr[0]) // set complex when replacing ref is an anonymous jsonpointer: further processing may be required replacedWithComplex = replacedWithComplex || slashpath.Dir(replacingRef.String()) != definitionsPath debugLog("rewrite parent with ref: %s", replacingRef.String()) // NOTE: it is possible at this stage to introduce json pointers (to non-definitions places). // Those are stripped later on. if err := updateRef(opts.Swagger(), p, replacingRef); err != nil { return false, err } if pa, ok := opts.flattenContext.newRefs[p]; ok && pa.isOAIGen { // update parent in ref index debugLog("update parent entry: %s", p) pa.schema = r.schema pa.resolved = false replacedWithComplex = true } } } // remove OAIGen definition debugLog("removing definition %s", slashpath.Base(r.path)) delete(opts.Swagger().Definitions, slashpath.Base(r.path)) // propagate changes in ref index for keys which have this one as a parent for kk, value := range opts.flattenContext.newRefs { if kk == k || !value.isOAIGen || value.resolved { continue } found := false newParents := make([]string, 0, len(value.parents)) for _, parent := range value.parents { switch { case parent == r.path: found = true parent = pr[0] case strings.HasPrefix(parent, r.path+"/"): found = true parent = slashpath.Join(pr[0], strings.TrimPrefix(parent, r.path)) } newParents = append(newParents, parent) } if found { value.parents = newParents } } // mark naming conflict as resolved debugLog("marking naming conflict resolved for key: %s", r.key) opts.flattenContext.newRefs[r.key].isOAIGen = false opts.flattenContext.newRefs[r.key].resolved = true // determine if the previous substitution did inline a complex schema if r.schema != nil && r.schema.Ref.String() == "" { // inline schema asch, err := Schema(SchemaOpts{Schema: r.schema, Root: opts.Swagger(), BasePath: opts.BasePath}) if err != nil { return false, err } debugLog("re-inlined schema: parent: %s, %t", pr[0], isAnalyzedAsComplex(asch)) replacedWithComplex = replacedWithComplex || !(slashpath.Dir(pr[0]) == definitionsPath) && isAnalyzedAsComplex(asch) } } } debugLog("replacedWithComplex: %t", replacedWithComplex) opts.Spec.reload() // re-analyze return replacedWithComplex, nil } // croak logs notifications and warnings about valid, but possibly unwanted constructs resulting // from flattening a spec func croak(opts *FlattenOpts) { reported := make(map[string]bool, len(opts.flattenContext.newRefs)) for _, v := range opts.Spec.references.allRefs { // warns about duplicate handling for _, r := range opts.flattenContext.newRefs { if r.isOAIGen && r.path == v.String() { reported[r.newName] = true } } } for k := range reported { log.Printf("warning: duplicate flattened definition name resolved as %s", k) } // warns about possible type mismatches uniqueMsg := make(map[string]bool) for _, msg := range opts.flattenContext.warnings { if _, ok := uniqueMsg[msg]; ok { continue } log.Printf("warning: %s", msg) uniqueMsg[msg] = true } } // namePointers replaces all JSON pointers to anonymous documents by a $ref to a new named definitions. // // This is carried on depth-first. Pointers to $refs which are top level definitions are replaced by the $ref itself. // Pointers to simple types are expanded, unless they express commonality (i.e. several such $ref are used). func namePointers(opts *FlattenOpts) error { debugLog("name pointers") refsToReplace := make(map[string]SchemaRef, len(opts.Spec.references.schemas)) for k, ref := range opts.Spec.references.allRefs { if slashpath.Dir(ref.String()) == definitionsPath { // this a ref to a top-level definition: ok continue } replacingRef, sch, erd := deepestRef(opts, ref) if erd != nil { return fmt.Errorf("at %s, %v", k, erd) } debugLog("planning pointer to replace at %s: %s, resolved to: %s", k, ref.String(), replacingRef.String()) refsToReplace[k] = SchemaRef{ Name: k, // caller Ref: replacingRef, // callee Schema: sch, TopLevel: slashpath.Dir(replacingRef.String()) == definitionsPath, } } depthFirst := sortDepthFirst(refsToReplace) namer := &inlineSchemaNamer{ Spec: opts.Swagger(), Operations: opRefsByRef(gatherOperations(opts.Spec, nil)), flattenContext: opts.flattenContext, opts: opts, } for _, key := range depthFirst { v := refsToReplace[key] // update current replacement, which may have been updated by previous changes of deeper elements replacingRef, sch, erd := deepestRef(opts, v.Ref) if erd != nil { return fmt.Errorf("at %s, %v", key, erd) } v.Ref = replacingRef v.Schema = sch v.TopLevel = slashpath.Dir(replacingRef.String()) == definitionsPath debugLog("replacing pointer at %s: resolved to: %s", key, v.Ref.String()) if v.TopLevel { debugLog("replace pointer %s by canonical definition: %s", key, v.Ref.String()) // if the schema is a $ref to a top level definition, just rewrite the pointer to this $ref if err := updateRef(opts.Swagger(), key, v.Ref); err != nil { return err } } else { // this is a JSON pointer to an anonymous document (internal or external): // create a definition for this schema when: // - it is a complex schema // - or it is pointed by more than one $ref (i.e. expresses commonality) // otherwise, expand the pointer (single reference to a simple type) // // The named definition for this follows the target's key, not the caller's debugLog("namePointers at %s for %s", key, v.Ref.String()) // qualify the expanded schema /* if key == "#/paths/~1some~1where~1{id}/get/parameters/1/items" { // DEBUG //func getPointerFromKey(spec interface{}, key string) (string, interface{}, error) { k, res, err := getPointerFromKey(namer.Spec, key) debugLog("k = %s, res=%#v, err=%v", k, res, err) } */ asch, ers := Schema(SchemaOpts{Schema: v.Schema, Root: opts.Swagger(), BasePath: opts.BasePath}) if ers != nil { return fmt.Errorf("schema analysis [%s]: %v", key, ers) } callers := make([]string, 0, 64) debugLog("looking for callers") an := New(opts.Swagger()) for k, w := range an.references.allRefs { r, _, erd := deepestRef(opts, w) if erd != nil { return fmt.Errorf("at %s, %v", key, erd) } if r.String() == v.Ref.String() { callers = append(callers, k) } } debugLog("callers for %s: %d", v.Ref.String(), len(callers)) if len(callers) == 0 { // has already been updated and resolved continue } parts := keyParts(v.Ref.String()) debugLog("number of callers for %s: %d", v.Ref.String(), len(callers)) // identifying edge case when the namer did nothing because we point to a non-schema object // no definition is created and we expand the $ref for all callers if (!asch.IsSimpleSchema || len(callers) > 1) && !parts.IsSharedParam() && !parts.IsSharedResponse() { debugLog("replace JSON pointer at [%s] by definition: %s", key, v.Ref.String()) if err := namer.Name(v.Ref.String(), v.Schema, asch); err != nil { return err } // regular case: we named the $ref as a definition, and we move all callers to this new $ref for _, caller := range callers { if caller != key { // move $ref for next to resolve debugLog("identified caller of %s at [%s]", v.Ref.String(), caller) c := refsToReplace[caller] c.Ref = v.Ref refsToReplace[caller] = c } } } else { debugLog("expand JSON pointer for key=%s", key) if err := updateRefWithSchema(opts.Swagger(), key, v.Schema); err != nil { return err } // NOTE: there is no other caller to update } } } opts.Spec.reload() // re-analyze return nil } // deepestRef finds the first definition ref, from a cascade of nested refs which are not definitions. // - if no definition is found, returns the deepest ref. // - pointers to external files are expanded // // NOTE: all external $ref's are assumed to be already expanded at this stage. func deepestRef(opts *FlattenOpts, ref swspec.Ref) (swspec.Ref, *swspec.Schema, error) { if !ref.HasFragmentOnly { // we found an external $ref, which is odd // does nothing on external $refs return ref, nil, nil } currentRef := ref visited := make(map[string]bool, 64) DOWNREF: for currentRef.String() != "" { if slashpath.Dir(currentRef.String()) == definitionsPath { // this is a top-level definition: stop here and return this ref return currentRef, nil, nil } if _, beenThere := visited[currentRef.String()]; beenThere { return swspec.Ref{}, nil, fmt.Errorf("cannot resolve cyclic chain of pointers under %s", currentRef.String()) } visited[currentRef.String()] = true value, _, err := currentRef.GetPointer().Get(opts.Swagger()) if err != nil { return swspec.Ref{}, nil, err } switch refable := value.(type) { case *swspec.Schema: if refable.Ref.String() == "" { break DOWNREF } currentRef = refable.Ref case swspec.Schema: if refable.Ref.String() == "" { break DOWNREF } currentRef = refable.Ref case *swspec.SchemaOrArray: if refable.Schema == nil || refable.Schema != nil && refable.Schema.Ref.String() == "" { break DOWNREF } currentRef = refable.Schema.Ref case *swspec.SchemaOrBool: if refable.Schema == nil || refable.Schema != nil && refable.Schema.Ref.String() == "" { break DOWNREF } currentRef = refable.Schema.Ref case swspec.Response: // a pointer points to a schema initially marshalled in responses section... // Attempt to convert this to a schema. If this fails, the spec is invalid asJSON, _ := refable.MarshalJSON() var asSchema swspec.Schema err := asSchema.UnmarshalJSON(asJSON) if err != nil { return swspec.Ref{}, nil, fmt.Errorf("invalid type for resolved JSON pointer %s. Expected a schema a, got: %T", currentRef.String(), value) } opts.flattenContext.warnings = append(opts.flattenContext.warnings, fmt.Sprintf("found $ref %q (response) interpreted as schema", currentRef.String())) if asSchema.Ref.String() == "" { break DOWNREF } currentRef = asSchema.Ref case swspec.Parameter: // a pointer points to a schema initially marshalled in parameters section... // Attempt to convert this to a schema. If this fails, the spec is invalid asJSON, _ := refable.MarshalJSON() var asSchema swspec.Schema err := asSchema.UnmarshalJSON(asJSON) if err != nil { return swspec.Ref{}, nil, fmt.Errorf("invalid type for resolved JSON pointer %s. Expected a schema a, got: %T", currentRef.String(), value) } opts.flattenContext.warnings = append(opts.flattenContext.warnings, fmt.Sprintf("found $ref %q (parameter) interpreted as schema", currentRef.String())) if asSchema.Ref.String() == "" { break DOWNREF } currentRef = asSchema.Ref default: return swspec.Ref{}, nil, fmt.Errorf("unhandled type to resolve JSON pointer %s. Expected a Schema, got: %T", currentRef.String(), value) } } // assess what schema we're ending with sch, erv := swspec.ResolveRefWithBase(opts.Swagger(), ¤tRef, opts.ExpandOpts(false)) if erv != nil { return swspec.Ref{}, nil, erv } if sch == nil { return swspec.Ref{}, nil, fmt.Errorf("no schema found at %s", currentRef.String()) } return currentRef, sch, nil } // normalizeRef strips the current file from any $ref. This works around issue go-openapi/spec#76: // leading absolute file in $ref is stripped func normalizeRef(opts *FlattenOpts) error { debugLog("normalizeRef") opts.Spec.reload() // re-analyze for k, w := range opts.Spec.references.allRefs { if strings.HasPrefix(w.String(), opts.BasePath+definitionsPath) { // may be a mix of / and \, depending on OS // strip base path from definition debugLog("stripping absolute path for: %s", w.String()) if err := updateRef(opts.Swagger(), k, swspec.MustCreateRef(slashpath.Join(definitionsPath, slashpath.Base(w.String())))); err != nil { return err } } } opts.Spec.reload() // re-analyze return nil }