diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 7c950937b..214af2a77 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -110,6 +110,48 @@ func Int64Ptr(value int64) *int64 func ReadFromFile(loader *Loader, location *url.URL) ([]byte, error) ReadFromFile is a ReadFromURIFunc which reads local file URIs. +func ReferencesComponentInRootDocument(doc *T, ref componentRef) (string, bool) + ReferencesComponentInRootDocument returns if the given component reference + references the same document or element as another component reference in + the root document's '#/components/'. If it does, it returns the name + of it in the form '#/components//NameXXX' + + Of course given a component from the root document will always match itself. + + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#relative-references-in-urls + + Example. Take the spec with directory structure: + + openapi.yaml + schemas/ + ├─ record.yaml + ├─ records.yaml + + In openapi.yaml we have: + + components: + schemas: + Record: + $ref: schemas/record.yaml + + Case 1: records.yml references a component in the root document + + $ref: ../openapi.yaml#/components/schemas/Record + + This would return... + + #/components/schemas/Record + + Case 2: records.yml indirectly refers to the same schema as a schema the + root document's '#/components/schemas'. + + $ref: ./record.yaml + + This would also return... + + #/components/schemas/Record + func RegisterArrayUniqueItemsChecker(fn SliceUniqueItemsChecker) RegisterArrayUniqueItemsChecker is used to register a customized function used to check if JSON array have unique items. @@ -191,11 +233,16 @@ func (callback *Callback) Value(key string) *PathItem type CallbackRef struct { Ref string Value *Callback + // Has unexported fields. } CallbackRef represents either a Callback or a $ref to a Callback. When serializing and both fields are set, Ref is preferred over Value. +func (x *CallbackRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *CallbackRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -206,6 +253,12 @@ func (x CallbackRef) MarshalJSON() ([]byte, error) func (x CallbackRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of CallbackRef. +func (x *CallbackRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *CallbackRef) RefString() string + RefString returns the $ref value. + func (x *CallbackRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets CallbackRef to a copy of data. @@ -379,11 +432,16 @@ func (example *Example) Validate(ctx context.Context, opts ...ValidationOption) type ExampleRef struct { Ref string Value *Example + // Has unexported fields. } ExampleRef represents either a Example or a $ref to a Example. When serializing and both fields are set, Ref is preferred over Value. +func (x *ExampleRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *ExampleRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -394,6 +452,12 @@ func (x ExampleRef) MarshalJSON() ([]byte, error) func (x ExampleRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of ExampleRef. +func (x *ExampleRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *ExampleRef) RefString() string + RefString returns the $ref value. + func (x *ExampleRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ExampleRef to a copy of data. @@ -466,11 +530,16 @@ func (header *Header) Validate(ctx context.Context, opts ...ValidationOption) er type HeaderRef struct { Ref string Value *Header + // Has unexported fields. } HeaderRef represents either a Header or a $ref to a Header. When serializing and both fields are set, Ref is preferred over Value. +func (x *HeaderRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *HeaderRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -481,6 +550,12 @@ func (x HeaderRef) MarshalJSON() ([]byte, error) func (x HeaderRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of HeaderRef. +func (x *HeaderRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *HeaderRef) RefString() string + RefString returns the $ref value. + func (x *HeaderRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets HeaderRef to a copy of data. @@ -568,11 +643,16 @@ func (link *Link) Validate(ctx context.Context, opts ...ValidationOption) error type LinkRef struct { Ref string Value *Link + // Has unexported fields. } LinkRef represents either a Link or a $ref to a Link. When serializing and both fields are set, Ref is preferred over Value. +func (x *LinkRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *LinkRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -583,6 +663,12 @@ func (x LinkRef) MarshalJSON() ([]byte, error) func (x LinkRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of LinkRef. +func (x *LinkRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *LinkRef) RefString() string + RefString returns the $ref value. + func (x *LinkRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets LinkRef to a copy of data. @@ -879,11 +965,16 @@ func (parameter *Parameter) WithSchema(value *Schema) *Parameter type ParameterRef struct { Ref string Value *Parameter + // Has unexported fields. } ParameterRef represents either a Parameter or a $ref to a Parameter. When serializing and both fields are set, Ref is preferred over Value. +func (x *ParameterRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *ParameterRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -894,6 +985,12 @@ func (x ParameterRef) MarshalJSON() ([]byte, error) func (x ParameterRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of ParameterRef. +func (x *ParameterRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *ParameterRef) RefString() string + RefString returns the $ref value. + func (x *ParameterRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ParameterRef to a copy of data. @@ -1112,11 +1209,16 @@ func (requestBody *RequestBody) WithSchemaRef(value *SchemaRef, consumes []strin type RequestBodyRef struct { Ref string Value *RequestBody + // Has unexported fields. } RequestBodyRef represents either a RequestBody or a $ref to a RequestBody. When serializing and both fields are set, Ref is preferred over Value. +func (x *RequestBodyRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *RequestBodyRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -1127,6 +1229,12 @@ func (x RequestBodyRef) MarshalJSON() ([]byte, error) func (x RequestBodyRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of RequestBodyRef. +func (x *RequestBodyRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *RequestBodyRef) RefString() string + RefString returns the $ref value. + func (x *RequestBodyRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets RequestBodyRef to a copy of data. @@ -1176,11 +1284,16 @@ func (m ResponseBodies) JSONLookup(token string) (interface{}, error) type ResponseRef struct { Ref string Value *Response + // Has unexported fields. } ResponseRef represents either a Response or a $ref to a Response. When serializing and both fields are set, Ref is preferred over Value. +func (x *ResponseRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *ResponseRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -1191,6 +1304,12 @@ func (x ResponseRef) MarshalJSON() ([]byte, error) func (x ResponseRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of ResponseRef. +func (x *ResponseRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *ResponseRef) RefString() string + RefString returns the $ref value. + func (x *ResponseRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ResponseRef to a copy of data. @@ -1471,6 +1590,7 @@ func (err SchemaError) Unwrap() error type SchemaRef struct { Ref string Value *Schema + // Has unexported fields. } SchemaRef represents either a Schema or a $ref to a Schema. When serializing @@ -1479,6 +1599,10 @@ type SchemaRef struct { func NewSchemaRef(ref string, value *Schema) *SchemaRef NewSchemaRef simply builds a SchemaRef +func (x *SchemaRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *SchemaRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -1489,6 +1613,12 @@ func (x SchemaRef) MarshalJSON() ([]byte, error) func (x SchemaRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of SchemaRef. +func (x *SchemaRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *SchemaRef) RefString() string + RefString returns the $ref value. + func (x *SchemaRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets SchemaRef to a copy of data. @@ -1620,12 +1750,17 @@ func (ss *SecurityScheme) WithType(value string) *SecurityScheme type SecuritySchemeRef struct { Ref string Value *SecurityScheme + // Has unexported fields. } SecuritySchemeRef represents either a SecurityScheme or a $ref to a SecurityScheme. When serializing and both fields are set, Ref is preferred over Value. +func (x *SecuritySchemeRef) CollectionName() string + CollectionName returns the JSON string used for a collection of these + components. + func (x *SecuritySchemeRef) JSONLookup(token string) (interface{}, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable @@ -1636,6 +1771,12 @@ func (x SecuritySchemeRef) MarshalJSON() ([]byte, error) func (x SecuritySchemeRef) MarshalYAML() (interface{}, error) MarshalYAML returns the YAML encoding of SecuritySchemeRef. +func (x *SecuritySchemeRef) RefPath() *url.URL + RefPath returns the path of the $ref relative to the root document. + +func (x *SecuritySchemeRef) RefString() string + RefString returns the $ref value. + func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets SecuritySchemeRef to a copy of data. diff --git a/README.md b/README.md index aea156c0d..043a6e252 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,31 @@ func safeErrorMessage(err *openapi3.SchemaError) string { } ``` +## Reconciling component $ref types + +`ReferencesComponentInRootDocument` is a useful helper function to check if a component reference +coincides with a reference in the root document's component objects fixed fields. + +This can be used to determine if two schema definitions are of the same structure, helpful for +code generation tools when generating go type models. + +```go +doc, err = loader.LoadFromFile("openapi.yml") + +for _, path := range doc.Paths.InMatchingOrder() { + pathItem := doc.Paths.Find(path) + + if pathItem.Get == nil || pathItem.Get.Responses.Status(200) { + continue + } + + for _, s := range pathItem.Get.Responses.Status(200).Value.Content { + name, match := ReferencesComponentInRootDocument(doc, s.Schema) + fmt.Println(path, match, name) // /record true #/components/schemas/BookRecord + } +} +``` + This will change the schema validation errors to return only the `Reason` field, which is guaranteed to not include the original value. ## CHANGELOG: Sub-v1 breaking API changes diff --git a/openapi3/helpers.go b/openapi3/helpers.go index d160eb1e8..25da8a7ca 100644 --- a/openapi3/helpers.go +++ b/openapi3/helpers.go @@ -2,7 +2,13 @@ package openapi3 import ( "fmt" + "net/url" + "path" + "reflect" "regexp" + "strings" + + "github.com/go-openapi/jsonpointer" ) const identifierPattern = `^[a-zA-Z0-9._-]+$` @@ -39,3 +45,192 @@ func Int64Ptr(value int64) *int64 { func Uint64Ptr(value uint64) *uint64 { return &value } + +type componentRef interface { + RefString() string + RefPath() *url.URL + CollectionName() string +} + +// refersToSameDocument returns if the $ref refers to the same document. +// +// Documents in different directories will have distinct $ref values that resolve to +// the same document. +// For example, consider the 3 files: +// +// /records.yaml +// /root.yaml $ref: records.yaml +// /schema/other.yaml $ref: ../records.yaml +// +// The records.yaml reference in the 2 latter refers to the same document. +func refersToSameDocument(o1 componentRef, o2 componentRef) bool { + if o1 == nil || o2 == nil { + return false + } + + r1 := o1.RefPath() + r2 := o2.RefPath() + + if r1 == nil || r2 == nil { + return false + } + + // refURL is relative to the working directory & base spec file. + return referenceURIMatch(r1, r2) +} + +// referencesRootDocument returns if the $ref points to the root document of the OpenAPI spec. +// +// If the document has no location, perhaps loaded from data in memory, it always returns false. +func referencesRootDocument(doc *T, ref componentRef) bool { + if doc.url == nil || ref == nil || ref.RefPath() == nil { + return false + } + + refURL := *ref.RefPath() + refURL.Fragment = "" + + // Check referenced element was in the root document. + return referenceURIMatch(doc.url, &refURL) +} + +func referenceURIMatch(u1 *url.URL, u2 *url.URL) bool { + s1, s2 := *u1, *u2 + if s1.Scheme == "" { + s1.Scheme = "file" + } + if s2.Scheme == "" { + s2.Scheme = "file" + } + + return s1.String() == s2.String() +} + +// ReferencesComponentInRootDocument returns if the given component reference references +// the same document or element as another component reference in the root document's +// '#/components/'. If it does, it returns the name of it in the form +// '#/components//NameXXX' +// +// Of course given a component from the root document will always match itself. +// +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#relative-references-in-urls +// +// Example. Take the spec with directory structure: +// +// openapi.yaml +// schemas/ +// ├─ record.yaml +// ├─ records.yaml +// +// In openapi.yaml we have: +// +// components: +// schemas: +// Record: +// $ref: schemas/record.yaml +// +// Case 1: records.yml references a component in the root document +// +// $ref: ../openapi.yaml#/components/schemas/Record +// +// This would return... +// +// #/components/schemas/Record +// +// Case 2: records.yml indirectly refers to the same schema +// as a schema the root document's '#/components/schemas'. +// +// $ref: ./record.yaml +// +// This would also return... +// +// #/components/schemas/Record +func ReferencesComponentInRootDocument(doc *T, ref componentRef) (string, bool) { + if ref == nil || ref.RefString() == "" { + return "", false + } + + // Case 1: + // Something like: ../another-folder/document.json#/myElement + if isRemoteReference(ref.RefString()) && isRootComponentReference(ref.RefString(), ref.CollectionName()) { + // Determine if it is *this* root doc. + if referencesRootDocument(doc, ref) { + _, name, _ := strings.Cut(ref.RefString(), path.Join("#/components/", ref.CollectionName())) + + return path.Join("#/components/", ref.CollectionName(), name), true + } + } + + // If there are no schemas defined in the root document return early. + if doc.Components == nil { + return "", false + } + + collection, _, err := jsonpointer.GetForToken(doc.Components, ref.CollectionName()) + if err != nil { + panic(err) // unreachable + } + + var components map[string]componentRef + + componentRefType := reflect.TypeOf(new(componentRef)).Elem() + if t := reflect.TypeOf(collection); t.Kind() == reflect.Map && + t.Key().Kind() == reflect.String && + t.Elem().AssignableTo(componentRefType) { + v := reflect.ValueOf(collection) + + components = make(map[string]componentRef, v.Len()) + for _, key := range v.MapKeys() { + strct := v.MapIndex(key) + // Type assertion safe, already checked via reflection above. + components[key.Interface().(string)] = strct.Interface().(componentRef) + } + } else { + return "", false + } + + // Case 2: + // Something like: ../openapi.yaml#/components/schemas/myElement + for name, s := range components { + // Must be a reference to a YAML file. + if !isWholeDocumentReference(s.RefString()) { + continue + } + + // Is the schema a ref to the same resource. + if !refersToSameDocument(s, ref) { + continue + } + + // Transform the remote ref to the equivalent schema in the root document. + return path.Join("#/components/", ref.CollectionName(), name), true + } + + return "", false +} + +// isElementReference takes a $ref value and checks if it references a specific element. +func isElementReference(ref string) bool { + return ref != "" && !isWholeDocumentReference(ref) +} + +// isSchemaReference takes a $ref value and checks if it references a schema element. +func isRootComponentReference(ref string, compType string) bool { + return isElementReference(ref) && strings.Contains(ref, path.Join("#/components/", compType)) +} + +// isWholeDocumentReference takes a $ref value and checks if it is whole document reference. +func isWholeDocumentReference(ref string) bool { + return ref != "" && !strings.ContainsAny(ref, "#") +} + +// isRemoteReference takes a $ref value and checks if it is remote reference. +func isRemoteReference(ref string) bool { + return ref != "" && !strings.HasPrefix(ref, "#") && !isURLReference(ref) +} + +// isURLReference takes a $ref value and checks if it is URL reference. +func isURLReference(ref string) bool { + return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "//") +} diff --git a/openapi3/helpers_test.go b/openapi3/helpers_test.go new file mode 100644 index 000000000..ff350e394 --- /dev/null +++ b/openapi3/helpers_test.go @@ -0,0 +1,83 @@ +package openapi3 + +import ( + "net/url" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReferencesComponentInRootDocument(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + + runAssertions := func(doc *T) { + // The element type of ./records.yml references a document which is also in the root document. + v, ok := ReferencesComponentInRootDocument(doc, doc.Components.Schemas["BookRecords"].Value.Items) + assert.True(t, ok) + assert.Equal(t, "#/components/schemas/BookRecord", v) + + // The array element type directly references the component in the root document. + v, ok = ReferencesComponentInRootDocument(doc, doc.Components.Schemas["CdRecords"].Value.Items) + assert.True(t, ok) + assert.Equal(t, "#/components/schemas/CdRecord", v) + + // A component from the root document should + v, ok = ReferencesComponentInRootDocument(doc, doc.Components.Schemas["CdRecord"]) + assert.True(t, ok) + assert.Equal(t, "#/components/schemas/CdRecord", v) + + // The error response component is in the root doc. + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/records").Get.Responses.Value("500")) + assert.True(t, ok) + assert.Equal(t, "#/components/responses/ErrorResponse", v) + + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/records").Get.Responses.Value("500").Value.Content.Get("application/json").Schema) + assert.False(t, ok) + assert.Empty(t, v) + + // Ref path doesn't include a './' + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/record").Get.Parameters[0]) + assert.True(t, ok) + assert.Equal(t, "#/components/parameters/BookIDParameter", v) + + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/record").Get.Responses.Value("200").Value.Content.Get("application/json").Examples["first-example"]) + assert.True(t, ok) + assert.Equal(t, "#/components/examples/RecordResponseExample", v) + + // Matches equivalent paths where string is no equal. + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/record").Get.Responses.Value("200").Value.Headers["X-Custom-Header"]) + assert.True(t, ok) + assert.Equal(t, "#/components/headers/CustomHeader", v) + + // Same structure distinct definition of the same header + v, ok = ReferencesComponentInRootDocument(doc, doc.Paths.Find("/record").Get.Responses.Value("200").Value.Headers["X-Custom-Header2"]) + assert.False(t, ok) + assert.Empty(t, v) + } + + // Load from the file system + doc, err := loader.LoadFromFile("testdata/refsToRoot/openapi.yml") + require.NoError(t, err) + + runAssertions(doc) + + // Loading from a URL by mocking HTTP calls. + // Loads the data using the URI path from the testdata/ folder. + loader.ReadFromURIFunc = func(loader *Loader, url *url.URL) ([]byte, error) { + localURL := *url + localURL.Scheme = "" + localURL.Host = "" + localURL.Path = filepath.Join("testdata", localURL.Path) + + return ReadFromFile(loader, &localURL) + } + + u, _ := url.Parse("https://example.com/refsToRoot/openapi.yml") + doc, err = loader.LoadFromURI(u) + require.NoError(t, err) + + runAssertions(doc) +} diff --git a/openapi3/loader.go b/openapi3/loader.go index 88ab566ac..e43f72b33 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -173,6 +173,11 @@ func (loader *Loader) loadFromDataWithPathInternal(data []byte, location *url.UR } doc := &T{} + if location != nil { + specURL := *location + doc.url = &specURL // shallow-copy + } + loader.visitedDocuments[uri] = doc if err := unmarshal(data, doc); err != nil { @@ -547,6 +552,7 @@ func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPat return err } component.Value = &header + component.refPath = *documentPath } else { var resolved HeaderRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -560,6 +566,7 @@ func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPat return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } value := component.Value @@ -597,6 +604,7 @@ func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, docum return err } component.Value = ¶m + component.refPath = *documentPath } else { var resolved ParameterRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -610,6 +618,7 @@ func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, docum return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } value := component.Value @@ -657,6 +666,7 @@ func (loader *Loader) resolveRequestBodyRef(doc *T, component *RequestBodyRef, d return err } component.Value = &requestBody + component.refPath = *documentPath } else { var resolved RequestBodyRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -670,6 +680,7 @@ func (loader *Loader) resolveRequestBodyRef(doc *T, component *RequestBodyRef, d return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } value := component.Value @@ -724,6 +735,7 @@ func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documen return err } component.Value = &resp + component.refPath = *documentPath } else { var resolved ResponseRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -737,6 +749,7 @@ func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documen return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } value := component.Value @@ -802,6 +815,7 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat return err } component.Value = &schema + component.refPath = *documentPath } else { if visitedLimit(visited, ref) { visited = append(visited, ref) @@ -821,6 +835,7 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat return err } component.Value = resolved.Value + component.refPath = resolved.refPath } if loader.visitedSchema == nil { loader.visitedSchema = make(map[*Schema]struct{}) @@ -893,6 +908,7 @@ func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecurityScheme return err } component.Value = &scheme + component.refPath = *documentPath } else { var resolved SecuritySchemeRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -906,6 +922,7 @@ func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecurityScheme return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } return nil @@ -933,6 +950,7 @@ func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentP return err } component.Value = &example + component.refPath = *documentPath } else { var resolved ExampleRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -946,6 +964,7 @@ func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentP return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } return nil @@ -973,6 +992,7 @@ func (loader *Loader) resolveCallbackRef(doc *T, component *CallbackRef, documen return err } component.Value = &resolved + component.refPath = *documentPath } else { var resolved CallbackRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -986,6 +1006,7 @@ func (loader *Loader) resolveCallbackRef(doc *T, component *CallbackRef, documen return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } value := component.Value @@ -1023,6 +1044,7 @@ func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *u return err } component.Value = &link + component.refPath = *documentPath } else { var resolved LinkRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -1036,6 +1058,7 @@ func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *u return err } component.Value = resolved.Value + component.refPath = resolved.refPath } } return nil diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index 04bac8ff7..ea3c98545 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "github.com/go-openapi/jsonpointer" ) @@ -24,6 +25,7 @@ type T struct { ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` visited visitedComponent + url *url.URL } var _ jsonpointer.JSONPointable = (*T)(nil) diff --git a/openapi3/refs.go b/openapi3/refs.go index 087e5abfe..34f955214 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "sort" "github.com/go-openapi/jsonpointer" @@ -17,12 +18,23 @@ type CallbackRef struct { Ref string Value *Callback extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*CallbackRef)(nil) func (x *CallbackRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *CallbackRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *CallbackRef) CollectionName() string { return "callbacks" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *CallbackRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of CallbackRef. func (x CallbackRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -96,12 +108,23 @@ type ExampleRef struct { Ref string Value *Example extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*ExampleRef)(nil) func (x *ExampleRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *ExampleRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *ExampleRef) CollectionName() string { return "examples" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *ExampleRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of ExampleRef. func (x ExampleRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -175,12 +198,23 @@ type HeaderRef struct { Ref string Value *Header extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*HeaderRef)(nil) func (x *HeaderRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *HeaderRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *HeaderRef) CollectionName() string { return "headers" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *HeaderRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of HeaderRef. func (x HeaderRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -254,12 +288,23 @@ type LinkRef struct { Ref string Value *Link extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*LinkRef)(nil) func (x *LinkRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *LinkRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *LinkRef) CollectionName() string { return "links" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *LinkRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of LinkRef. func (x LinkRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -333,12 +378,23 @@ type ParameterRef struct { Ref string Value *Parameter extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*ParameterRef)(nil) func (x *ParameterRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *ParameterRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *ParameterRef) CollectionName() string { return "parameters" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *ParameterRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of ParameterRef. func (x ParameterRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -412,12 +468,23 @@ type RequestBodyRef struct { Ref string Value *RequestBody extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*RequestBodyRef)(nil) func (x *RequestBodyRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *RequestBodyRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *RequestBodyRef) CollectionName() string { return "requestBodies" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *RequestBodyRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of RequestBodyRef. func (x RequestBodyRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -491,12 +558,23 @@ type ResponseRef struct { Ref string Value *Response extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*ResponseRef)(nil) func (x *ResponseRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *ResponseRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *ResponseRef) CollectionName() string { return "responses" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *ResponseRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of ResponseRef. func (x ResponseRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -570,12 +648,23 @@ type SchemaRef struct { Ref string Value *Schema extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*SchemaRef)(nil) func (x *SchemaRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *SchemaRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *SchemaRef) CollectionName() string { return "schemas" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *SchemaRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of SchemaRef. func (x SchemaRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { @@ -649,12 +738,23 @@ type SecuritySchemeRef struct { Ref string Value *SecurityScheme extra []string + + refPath url.URL } var _ jsonpointer.JSONPointable = (*SecuritySchemeRef)(nil) func (x *SecuritySchemeRef) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefString returns the $ref value. +func (x *SecuritySchemeRef) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *SecuritySchemeRef) CollectionName() string { return "securitySchemes" } + +// RefPath returns the path of the $ref relative to the root document. +func (x *SecuritySchemeRef) RefPath() *url.URL { return &x.refPath } + // MarshalYAML returns the YAML encoding of SecuritySchemeRef. func (x SecuritySchemeRef) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index 638d6469d..31e6a068f 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -5,34 +5,46 @@ import ( "context" "encoding/json" "fmt" + "net/url" "sort" "github.com/go-openapi/jsonpointer" "github.com/perimeterx/marshmallow" ) {{ range $type := .Types }} -// {{ $type }}Ref represents either a {{ $type }} or a $ref to a {{ $type }}. +// {{ $type.Name }}Ref represents either a {{ $type.Name }} or a $ref to a {{ $type.Name }}. // When serializing and both fields are set, Ref is preferred over Value. -type {{ $type }}Ref struct { +type {{ $type.Name }}Ref struct { Ref string - Value *{{ $type }} + Value *{{ $type.Name }} extra []string + + refPath url.URL } -var _ jsonpointer.JSONPointable = (*{{ $type }}Ref)(nil) +var _ jsonpointer.JSONPointable = (*{{ $type.Name }}Ref)(nil) + +func (x *{{ $type.Name }}Ref) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } + +// RefString returns the $ref value. +func (x *{{ $type.Name }}Ref) RefString() string { return x.Ref } + +// CollectionName returns the JSON string used for a collection of these components. +func (x *{{ $type.Name }}Ref) CollectionName() string { return "{{ $type.CollectionName }}" } -func (x *{{ $type }}Ref) isEmpty() bool { return x == nil || x.Ref == "" && x.Value == nil } +// RefPath returns the path of the $ref relative to the root document. +func (x *{{ $type.Name }}Ref) RefPath() *url.URL { return &x.refPath } -// MarshalYAML returns the YAML encoding of {{ $type }}Ref. -func (x {{ $type }}Ref) MarshalYAML() (interface{}, error) { +// MarshalYAML returns the YAML encoding of {{ $type.Name }}Ref. +func (x {{ $type.Name }}Ref) MarshalYAML() (interface{}, error) { if ref := x.Ref; ref != "" { return &Ref{Ref: ref}, nil } return x.Value.MarshalYAML() } -// MarshalJSON returns the JSON encoding of {{ $type }}Ref. -func (x {{ $type }}Ref) MarshalJSON() ([]byte, error) { +// MarshalJSON returns the JSON encoding of {{ $type.Name }}Ref. +func (x {{ $type.Name }}Ref) MarshalJSON() ([]byte, error) { y, err := x.MarshalYAML() if err != nil { return nil, err @@ -40,8 +52,8 @@ func (x {{ $type }}Ref) MarshalJSON() ([]byte, error) { return json.Marshal(y) } -// UnmarshalJSON sets {{ $type }}Ref to a copy of data. -func (x *{{ $type }}Ref) UnmarshalJSON(data []byte) error { +// UnmarshalJSON sets {{ $type.Name }}Ref to a copy of data. +func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { var refOnly Ref if extra, err := marshmallow.Unmarshal(data, &refOnly, marshmallow.WithExcludeKnownFieldsFromMap(true)); err == nil && refOnly.Ref != "" { x.Ref = refOnly.Ref @@ -57,8 +69,8 @@ func (x *{{ $type }}Ref) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &x.Value) } -// Validate returns an error if {{ $type }}Ref does not comply with the OpenAPI spec. -func (x *{{ $type }}Ref) Validate(ctx context.Context, opts ...ValidationOption) error { +// Validate returns an error if {{ $type.Name }}Ref does not comply with the OpenAPI spec. +func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) if extra := x.extra; len(extra) != 0 { extras := make([]string, 0, len(extra)) @@ -82,7 +94,7 @@ func (x *{{ $type }}Ref) Validate(ctx context.Context, opts ...ValidationOption) } // JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (x *{{ $type }}Ref) JSONLookup(token string) (interface{}, error) { +func (x *{{ $type.Name }}Ref) JSONLookup(token string) (interface{}, error) { if token == "$ref" { return x.Ref, nil } diff --git a/openapi3/refsgenerator.go b/openapi3/refsgenerator.go index 5bddfe258..f3d66bb2f 100644 --- a/openapi3/refsgenerator.go +++ b/openapi3/refsgenerator.go @@ -27,21 +27,26 @@ func main() { packageTemplate := template.Must(template.New("openapi3-refs").Parse(tmplData)) + type componentType struct { + Name string + CollectionName string + } + if err := packageTemplate.Execute(file, struct { Package string - Types []string + Types []componentType }{ Package: os.Getenv("GOPACKAGE"), // set by the go:generate directive - Types: []string{ - "Callback", - "Example", - "Header", - "Link", - "Parameter", - "RequestBody", - "Response", - "Schema", - "SecurityScheme", + Types: []componentType{ + {Name: "Callback", CollectionName: "callbacks"}, + {Name: "Example", CollectionName: "examples"}, + {Name: "Header", CollectionName: "headers"}, + {Name: "Link", CollectionName: "links"}, + {Name: "Parameter", CollectionName: "parameters"}, + {Name: "RequestBody", CollectionName: "requestBodies"}, + {Name: "Response", CollectionName: "responses"}, + {Name: "Schema", CollectionName: "schemas"}, + {Name: "SecurityScheme", CollectionName: "securitySchemes"}, }, }); err != nil { panic(err) diff --git a/openapi3/testdata/refsToRoot/openapi.yml b/openapi3/testdata/refsToRoot/openapi.yml new file mode 100644 index 000000000..ad9fc1e9b --- /dev/null +++ b/openapi3/testdata/refsToRoot/openapi.yml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + version: 1.0.0 + title: Mode ref resolution Example +paths: + /records: + get: + operationId: getBookRecords + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/BookRecords' + 500: + $ref: './other/response.yml' + /record: + get: + operationId: getBookRecord + parameters: + - $ref: 'other/parameter.yml' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/BookRecord' + examples: + first-example: + $ref: './other/example.yml' + headers: + X-Custom-Header: + $ref: 'schema/book/../../other/header.yml' + X-Custom-Header2: + schema: + type: string + 500: + $ref: './other/response.yml' +components: + schemas: + BookRecord: + $ref: './schemas/book/record.yml' + BookRecords: + $ref: './schemas/book/records.yml' + CdRecord: + $ref: './schemas/cd/record.yml' + CdRecords: + $ref: './schemas/cd/records.yml' + responses: + ErrorResponse: + $ref: './other/response.yml' + parameters: + BookIDParameter: + $ref: './other/parameter.yml' + headers: + CustomHeader: + $ref: './other/header.yml' + examples: + RecordResponseExample: + $ref: './other/example.yml' diff --git a/openapi3/testdata/refsToRoot/other/example.yml b/openapi3/testdata/refsToRoot/other/example.yml new file mode 100644 index 000000000..2137d106e --- /dev/null +++ b/openapi3/testdata/refsToRoot/other/example.yml @@ -0,0 +1,2 @@ +description: Example example +id: 42 diff --git a/openapi3/testdata/refsToRoot/other/header.yml b/openapi3/testdata/refsToRoot/other/header.yml new file mode 100644 index 000000000..5fe26bfb2 --- /dev/null +++ b/openapi3/testdata/refsToRoot/other/header.yml @@ -0,0 +1,3 @@ +description: Example +schema: + type: string diff --git a/openapi3/testdata/refsToRoot/other/parameter.yml b/openapi3/testdata/refsToRoot/other/parameter.yml new file mode 100644 index 000000000..b30e0a33f --- /dev/null +++ b/openapi3/testdata/refsToRoot/other/parameter.yml @@ -0,0 +1,2 @@ +name: id +in: query diff --git a/openapi3/testdata/refsToRoot/other/response.yml b/openapi3/testdata/refsToRoot/other/response.yml new file mode 100644 index 000000000..e1766afba --- /dev/null +++ b/openapi3/testdata/refsToRoot/other/response.yml @@ -0,0 +1,4 @@ +content: + application/json: + schema: + $ref: '../schemas/error.yml' diff --git a/openapi3/testdata/refsToRoot/schemas/book/record.yml b/openapi3/testdata/refsToRoot/schemas/book/record.yml new file mode 100644 index 000000000..0e383fcdd --- /dev/null +++ b/openapi3/testdata/refsToRoot/schemas/book/record.yml @@ -0,0 +1,6 @@ +type: object +required: + - id +properties: + id: + type: number diff --git a/openapi3/testdata/refsToRoot/schemas/book/records.yml b/openapi3/testdata/refsToRoot/schemas/book/records.yml new file mode 100644 index 000000000..5097d391d --- /dev/null +++ b/openapi3/testdata/refsToRoot/schemas/book/records.yml @@ -0,0 +1,3 @@ +type: array +items: + $ref: './record.yml' diff --git a/openapi3/testdata/refsToRoot/schemas/cd/record.yml b/openapi3/testdata/refsToRoot/schemas/cd/record.yml new file mode 100644 index 000000000..0e383fcdd --- /dev/null +++ b/openapi3/testdata/refsToRoot/schemas/cd/record.yml @@ -0,0 +1,6 @@ +type: object +required: + - id +properties: + id: + type: number diff --git a/openapi3/testdata/refsToRoot/schemas/cd/records.yml b/openapi3/testdata/refsToRoot/schemas/cd/records.yml new file mode 100644 index 000000000..22ea0332a --- /dev/null +++ b/openapi3/testdata/refsToRoot/schemas/cd/records.yml @@ -0,0 +1,3 @@ +type: array +items: + $ref: '../../openapi.yml#/components/schemas/CdRecord' diff --git a/openapi3/testdata/refsToRoot/schemas/error.yml b/openapi3/testdata/refsToRoot/schemas/error.yml new file mode 100644 index 000000000..f2ed54321 --- /dev/null +++ b/openapi3/testdata/refsToRoot/schemas/error.yml @@ -0,0 +1,6 @@ +type: object +required: + - msg +properties: + id: + type: string