diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 7458a1b1d..a1aae91a2 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -842,8 +842,10 @@ func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) ResolveRefsIn expands references if for instance spec was just unmarshaled type Location struct { - Line int `json:"line,omitempty" yaml:"line,omitempty"` - Column int `json:"column,omitempty" yaml:"column,omitempty"` + File string `json:"file,omitempty" yaml:"file,omitempty"` + Line int `json:"line,omitempty" yaml:"line,omitempty"` + Column int `json:"column,omitempty" yaml:"column,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } Location is a struct that contains the location of a field. @@ -1041,12 +1043,14 @@ func (operation *Operation) Validate(ctx context.Context, opts ...ValidationOpti spec. type Origin struct { - Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Key *Location `json:"key,omitempty" yaml:"key,omitempty"` + Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } Origin contains the origin of a collection. Key is the location of the - collection itself. Fields is a map of the location of each field in the - collection. + collection itself. Fields is a map of the location of each scalar field + in the collection. Sequences is a map of the location of each item in + sequence-valued fields. type Parameter struct { Extensions map[string]any `json:"-" yaml:"-"` @@ -1308,8 +1312,8 @@ type Ref struct { https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object type RefNameResolver func(*T, ComponentRef) string - RefNameResolver maps a component to an name that is used as it's - internalized name. + RefNameResolver maps a component to a name that is used as it's internalized + name. The function should avoid name collisions (i.e. be a injective mapping). It must only contain characters valid for fixed field names: IdentifierRegExp. diff --git a/README.md b/README.md index 58a612844..951ae0204 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,10 @@ for _, path := range doc.Paths.InMatchingOrder() { ## CHANGELOG: Sub-v1 breaking API changes +### v0.134.0 +* `openapi3.Location` gained `File` and `Name` fields (`string` type, replacing previous `int`-only struct layout) +* `openapi3.Origin` gained `Sequences` field (`map[string][]Location`, extending previous `map[string]Location`-only struct) + ### v0.131.0 * No longer `openapi3filter.RegisterBodyDecoder` the `openapi3filter.ZipFileBodyDecoder` by default. diff --git a/docs.sh b/docs.sh index ca3029a75..bdcfce4ec 100755 --- a/docs.sh +++ b/docs.sh @@ -3,7 +3,7 @@ set -o pipefail outdir=.github/docs mkdir -p "$outdir" -for pkgpath in $(git ls-files | grep / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|testdata|internal|cmd/'); do +for pkgpath in $(git ls-files | grep / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|[.]claude|testdata|internal|cmd/'); do echo $pkgpath go doc -all ./"$pkgpath" | tee "$outdir/${pkgpath////_}.txt" done @@ -17,7 +17,7 @@ count_missing_mentions() { | grep -Eo '^-[^ ]+ ([^ (]+)[ (]' \ | sed 's%(% %' \ | cut -d' ' -f2); do - if ! grep -A999999 '## Sub-v0 breaking API changes' README.md | grep -F "$thing"; then + if ! grep -A999999 'breaking API changes' README.md | grep -F "$thing"; then ((errors++)) || true fi done diff --git a/go.mod b/go.mod index 6f53d9f7e..ad387803f 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 github.com/gorilla/mux v1.8.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 + github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b github.com/perimeterx/marshmallow v1.1.5 github.com/stretchr/testify v1.9.0 github.com/woodsbury/decimal128 v1.3.0 diff --git a/go.sum b/go.sum index 34001ef0a..ca05f258b 100644 --- a/go.sum +++ b/go.sum @@ -18,10 +18,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 h1:hSY3jBzVizJgAAIFz1KnNzRBsvfYugpTDImHQKXyulQ= +github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666/go.mod h1:fJaKnR28y1gcnKBH3jmYS583SFfvje/pXHrq09/rp/I= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go index b725bafc0..dbfa57b84 100644 --- a/openapi3/internalize_refs.go +++ b/openapi3/internalize_refs.go @@ -6,7 +6,7 @@ import ( "strings" ) -// RefNameResolver maps a component to an name that is used as it's internalized name. +// RefNameResolver maps a component to a name that is used as it's internalized name. // // The function should avoid name collisions (i.e. be a injective mapping). // It must only contain characters valid for fixed field names: [IdentifierRegExp]. diff --git a/openapi3/loader.go b/openapi3/loader.go index 67674fc65..ddffa73a8 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -108,7 +108,7 @@ func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, el if err != nil { return nil, err } - if err := unmarshal(data, element, IncludeOrigin); err != nil { + if err := unmarshal(data, element, IncludeOrigin, resolvedPath); err != nil { return nil, err } @@ -144,7 +144,7 @@ func (loader *Loader) LoadFromIoReader(reader io.Reader) (*T, error) { func (loader *Loader) LoadFromData(data []byte) (*T, error) { loader.resetVisitedPathItemRefs() doc := &T{} - if err := unmarshal(data, doc, IncludeOrigin); err != nil { + if err := unmarshal(data, doc, IncludeOrigin, nil); err != nil { return nil, err } if err := loader.ResolveRefsIn(doc, nil); err != nil { @@ -173,7 +173,7 @@ func (loader *Loader) loadFromDataWithPathInternal(data []byte, location *url.UR doc := &T{} loader.visitedDocuments[uri] = doc - if err := unmarshal(data, doc, IncludeOrigin); err != nil { + if err := unmarshal(data, doc, IncludeOrigin, location); err != nil { return nil, err } @@ -427,7 +427,7 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv if err2 != nil { return nil, nil, err } - if err2 = unmarshal(data, &cursor, IncludeOrigin); err2 != nil { + if err2 = unmarshal(data, &cursor, IncludeOrigin, path); err2 != nil { return nil, nil, err } if cursor, err2 = drill(cursor); err2 != nil || cursor == nil { diff --git a/openapi3/marsh.go b/openapi3/marsh.go index 2f00828a6..5d081f9ea 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -3,6 +3,7 @@ package openapi3 import ( "encoding/json" "fmt" + "net/url" "strings" "github.com/oasdiff/yaml" @@ -16,7 +17,7 @@ func unmarshalError(jsonUnmarshalErr error) error { return jsonUnmarshalErr } -func unmarshal(data []byte, v any, includeOrigin bool) error { +func unmarshal(data []byte, v any, includeOrigin bool, location *url.URL) error { var jsonErr, yamlErr error // See https://github.com/getkin/kin-openapi/issues/680 @@ -25,7 +26,11 @@ func unmarshal(data []byte, v any, includeOrigin bool) error { } // UnmarshalStrict(data, v) TODO: investigate how ymlv3 handles duplicate map keys - if yamlErr = yaml.UnmarshalWithOrigin(data, v, includeOrigin); yamlErr == nil { + var file string + if location != nil { + file = location.Path + } + if yamlErr = yaml.UnmarshalWithOrigin(data, v, includeOrigin, file); yamlErr == nil { return nil } diff --git a/openapi3/origin.go b/openapi3/origin.go index d2a51d946..bf12d617a 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -4,14 +4,18 @@ const originKey = "__origin__" // Origin contains the origin of a collection. // Key is the location of the collection itself. -// Fields is a map of the location of each field in the collection. +// Fields is a map of the location of each scalar field in the collection. +// Sequences is a map of the location of each item in sequence-valued fields. type Origin struct { - Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Key *Location `json:"key,omitempty" yaml:"key,omitempty"` + Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } // Location is a struct that contains the location of a field. type Location struct { - Line int `json:"line,omitempty" yaml:"line,omitempty"` - Column int `json:"column,omitempty" yaml:"column,omitempty"` + File string `json:"file,omitempty" yaml:"file,omitempty"` + Line int `json:"line,omitempty" yaml:"line,omitempty"` + Column int `json:"column,omitempty" yaml:"column,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index 2c1d46de0..f2183f5eb 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -26,22 +26,28 @@ func TestOrigin_Info(t *testing.T) { require.NotNil(t, doc.Info.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 2, Column: 1, + Name: "info", }, doc.Info.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 3, Column: 3, + Name: "title", }, doc.Info.Origin.Fields["title"]) require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 4, Column: 3, + Name: "version", }, doc.Info.Origin.Fields["version"]) } @@ -61,8 +67,10 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, doc.Paths.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 5, Column: 1, + Name: "paths", }, doc.Paths.Origin.Key) @@ -71,16 +79,20 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 13, Column: 3, + Name: "/partner-api/test/another-method", }, base.Origin.Key) require.NotNil(t, base.Get.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 14, Column: 5, + Name: "get", }, base.Get.Origin.Key) } @@ -101,16 +113,20 @@ func TestOrigin_RequestBody(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/request_body.yaml", Line: 8, Column: 7, + Name: "requestBody", }, base.Origin.Key) require.NotNil(t, base.Content["application/json"].Origin) require.Equal(t, &Location{ + File: "testdata/origin/request_body.yaml", Line: 10, Column: 11, + Name: "application/json", }, base.Content["application/json"].Origin.Key) } @@ -131,8 +147,10 @@ func TestOrigin_Responses(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 17, Column: 7, + Name: "responses", }, base.Origin.Key) @@ -140,15 +158,19 @@ func TestOrigin_Responses(t *testing.T) { require.Nil(t, base.Value("200").Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 18, Column: 9, + Name: "200", }, base.Value("200").Value.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 19, Column: 11, + Name: "description", }, base.Value("200").Value.Origin.Fields["description"]) } @@ -169,22 +191,28 @@ func TestOrigin_Parameters(t *testing.T) { require.NotNil(t, base) require.Equal(t, &Location{ + File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, + Name: "name", }, base.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/parameters.yaml", Line: 10, Column: 11, + Name: "in", }, base.Origin.Fields["in"]) require.Equal(t, Location{ + File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, + Name: "name", }, base.Origin.Fields["name"]) } @@ -207,15 +235,19 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { require.NotNil(t, base.Schema.Value.Origin) require.Equal(t, &Location{ + File: "testdata/origin/additional_properties.yaml", Line: 14, Column: 17, + Name: "additionalProperties", }, base.Schema.Value.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/additional_properties.yaml", Line: 15, Column: 19, + Name: "type", }, base.Schema.Value.Origin.Fields["type"]) } @@ -237,22 +269,28 @@ func TestOrigin_ExternalDocs(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/external_docs.yaml", Line: 13, Column: 1, + Name: "externalDocs", }, base.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/external_docs.yaml", Line: 14, Column: 3, + Name: "description", }, base.Origin.Fields["description"]) require.Equal(t, Location{ + File: "testdata/origin/external_docs.yaml", Line: 15, Column: 3, + Name: "url", }, base.Origin.Fields["url"]) } @@ -274,36 +312,46 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 29, Column: 5, + Name: "petstore_auth", }, base.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/security.yaml", Line: 30, Column: 7, + Name: "type", }, base.Origin.Fields["type"]) require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 31, Column: 7, + Name: "flows", }, base.Flows.Origin.Key) require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 32, Column: 9, + Name: "implicit", }, base.Flows.Implicit.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/security.yaml", Line: 33, Column: 11, + Name: "authorizationUrl", }, base.Flows.Implicit.Origin.Fields["authorizationUrl"]) } @@ -324,15 +372,19 @@ func TestOrigin_Example(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/example.yaml", Line: 14, Column: 15, + Name: "bar", }, base.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/example.yaml", Line: 15, Column: 17, + Name: "summary", }, base.Origin.Fields["summary"]) @@ -363,22 +415,28 @@ func TestOrigin_XML(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/xml.yaml", Line: 21, Column: 19, + Name: "xml", }, base.Origin.Key) require.Equal(t, Location{ + File: "testdata/origin/xml.yaml", Line: 22, Column: 21, + Name: "namespace", }, base.Origin.Fields["namespace"]) require.Equal(t, Location{ + File: "testdata/origin/xml.yaml", Line: 23, Column: 21, + Name: "prefix", }, base.Origin.Fields["prefix"]) } @@ -416,3 +474,40 @@ components: require.Equal(t, `failed to unmarshal data: json error: invalid character 'p' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: unmarshal errors: line 0: mapping key "__origin__" already defined at line 17`, err.Error()) } + +func TestOrigin_WithExternalRef(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + + IncludeOrigin = true + defer unsetIncludeOrigin() + + loader.Context = context.Background() + + doc, err := loader.LoadFromFile("testdata/origin/external.yaml") + require.NoError(t, err) + + base := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["name"].Value + require.NotNil(t, base.XML.Origin) + require.Equal(t, base.XML.Origin.Key.File, "testdata/origin/external-schema.yaml") + // require.Equal(t, + // &Location{ + // Line: 1, + // Column: 1, + // }, + // base.Origin.Key) + + // require.Equal(t, + // Location{ + // Line: 2, + // Column: 3, + // }, + // base.Origin.Fields["namespace"]) + + // require.Equal(t, + // Location{ + // Line: 3, + // Column: 3, + // }, + // base.Origin.Fields["prefix"]) +} diff --git a/openapi3/testdata/origin/external-schema.yaml b/openapi3/testdata/origin/external-schema.yaml new file mode 100644 index 000000000..45376d0ad --- /dev/null +++ b/openapi3/testdata/origin/external-schema.yaml @@ -0,0 +1,4 @@ +type: string +xml: + namespace: http://example.com/schema/sample + prefix: sample \ No newline at end of file diff --git a/openapi3/testdata/origin/external.yaml b/openapi3/testdata/origin/external.yaml new file mode 100644 index 000000000..8bb4a5303 --- /dev/null +++ b/openapi3/testdata/origin/external.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: External Reference Example + version: 1.0.0 +paths: + /subscribe: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int32 + xml: + attribute: true + name: + $ref: "./external-schema.yaml" + responses: + "200": + description: OK \ No newline at end of file