From eab5230f5ae92c88ea7d2c50358a1a4008a6c7d0 Mon Sep 17 00:00:00 2001 From: Pierre Fenoll Date: Sat, 11 Jul 2026 15:03:21 +0200 Subject: [PATCH 1/6] openapi3: fix test that relied on now non-resolving domain Signed-off-by: Pierre Fenoll --- openapi3/issue513_test.go | 64 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/openapi3/issue513_test.go b/openapi3/issue513_test.go index d26fb7667..2801d5a46 100644 --- a/openapi3/issue513_test.go +++ b/openapi3/issue513_test.go @@ -2,6 +2,7 @@ package openapi3 import ( "encoding/json" + "net/url" "strings" "testing" @@ -34,40 +35,47 @@ paths: $ref: http://schemas.sentex.io/store/categories.json ` - // When that site fails to respond: - // see https://github.com/getkin/kin-openapi/issues/495 + resolver := func(loader *Loader, url *url.URL) (data []byte, err error) { + switch url.String() { + case "http://schemas.sentex.io/store/categories.json": + data = []byte(` + { + "$id": "http://schemas.sentex.io/store/categories.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "array of category strings", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "http://schemas.sentex.io/store/category.json" + } + ] + } + }`) - // http://schemas.sentex.io/store/categories.json - // { - // "$id": "http://schemas.sentex.io/store/categories.json", - // "$schema": "http://json-schema.org/draft-07/schema#", - // "description": "array of category strings", - // "type": "array", - // "items": { - // "allOf": [ - // { - // "$ref": "http://schemas.sentex.io/store/category.json" - // } - // ] - // } - // } + case "http://schemas.sentex.io/store/category.json": + data = []byte(` + { + "$id": "http://schemas.sentex.io/store/category.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "category name for products", + "type": "string", + "pattern": "^[A-Za-z0-9\\-]+$", + "minimum": 1, + "maximum": 30 + }`) - // http://schemas.sentex.io/store/category.json - // { - // "$id": "http://schemas.sentex.io/store/category.json", - // "$schema": "http://json-schema.org/draft-07/schema#", - // "description": "category name for products", - // "type": "string", - // "pattern": "^[A-Za-z0-9\\-]+$", - // "minimum": 1, - // "maximum": 30 - // } + default: + panic(url) + } + return + } - for _, majmin := range []string{"'3.0'", "'3.1'"} { + for _, majmin := range []string{"'3.0'", "'3.1'", "'3.2'"} { t.Run(majmin, func(t *testing.T) { t.Parallel() sl := NewLoader() - sl.IsExternalRefsAllowed = true + sl.ReadFromURIFunc = resolver doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.1", majmin))) require.NoError(t, err) From 61f37b693702a391985bd49638954004eda0135e Mon Sep 17 00:00:00 2001 From: Pierre Fenoll Date: Sat, 11 Jul 2026 15:05:18 +0200 Subject: [PATCH 2/6] openapi3: move a test file under a test package Signed-off-by: Pierre Fenoll --- openapi3/issue513_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/openapi3/issue513_test.go b/openapi3/issue513_test.go index 2801d5a46..55a399d02 100644 --- a/openapi3/issue513_test.go +++ b/openapi3/issue513_test.go @@ -1,4 +1,4 @@ -package openapi3 +package openapi3_test import ( "encoding/json" @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" ) func TestExtraSiblingsInRemoteRef(t *testing.T) { @@ -35,7 +37,7 @@ paths: $ref: http://schemas.sentex.io/store/categories.json ` - resolver := func(loader *Loader, url *url.URL) (data []byte, err error) { + resolver := func(loader *openapi3.Loader, url *url.URL) (data []byte, err error) { switch url.String() { case "http://schemas.sentex.io/store/categories.json": data = []byte(` @@ -74,13 +76,13 @@ paths: for _, majmin := range []string{"'3.0'", "'3.1'", "'3.2'"} { t.Run(majmin, func(t *testing.T) { t.Parallel() - sl := NewLoader() + sl := openapi3.NewLoader() sl.ReadFromURIFunc = resolver doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.1", majmin))) require.NoError(t, err) - err = doc.Validate(sl.Context, AllowExtraSiblingFields("$id", "$schema")) + err = doc.Validate(sl.Context, openapi3.AllowExtraSiblingFields("$id", "$schema")) require.NoError(t, err) }) } @@ -122,7 +124,7 @@ components: for _, majmin := range []string{"3.0", "3.1"} { t.Run(majmin, func(t *testing.T) { t.Parallel() - sl := NewLoader() + sl := openapi3.NewLoader() doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) require.NoError(t, err) err = doc.Validate(sl.Context) @@ -173,7 +175,7 @@ components: for _, majmin := range []string{"3.0", "3.1"} { t.Run(majmin, func(t *testing.T) { t.Parallel() - sl := NewLoader() + sl := openapi3.NewLoader() doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) require.NoError(t, err) require.Contains(t, doc.Paths.Value("/v1/operation").Delete.Responses.Default().Value.Extensions, `x-my-extension`) @@ -220,7 +222,7 @@ components: for _, majmin := range []string{"3.0", "3.1"} { t.Run(majmin, func(t *testing.T) { t.Parallel() - sl := NewLoader() + sl := openapi3.NewLoader() doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) require.NoError(t, err) err = doc.Validate(sl.Context) @@ -266,10 +268,10 @@ components: for _, majmin := range []string{"3.0", "3.1"} { t.Run(majmin, func(t *testing.T) { t.Parallel() - sl := NewLoader() + sl := openapi3.NewLoader() doc, err := sl.LoadFromData([]byte(strings.ReplaceAll(spec, "3.0.3", majmin))) require.NoError(t, err) - err = doc.Validate(sl.Context, AllowExtraSiblingFields("description")) + err = doc.Validate(sl.Context, openapi3.AllowExtraSiblingFields("description")) require.NoError(t, err) }) } From 1223a0f215d2cf9beb2d9eb9ea2649d001c21388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Sat, 11 Jul 2026 17:56:57 -0300 Subject: [PATCH 3/6] openapi3filter: fix unbounded allocation in deepObject array decoding (C-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sliceMapToSlice built a []any sized to the largest attacker-supplied array index before schema validation (maxItems) ever ran, letting a ~24-byte deepObject query parameter (e.g. param[items][50000000]=x) force multi-gigabyte heap allocation. Cap the gap between supplied elements and the reconstructed slice size, and reject negative indexes. Signed-off-by: Matías Insaurralde --- openapi3filter/req_resp_decoder.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 539603898..6e7efdeef 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -932,6 +932,13 @@ func makeObject(props map[string]string, schema *openapi3.SchemaRef) (map[string return result, nil } +// maxSliceMapToSliceGap bounds how many synthesized nil holes sliceMapToSlice +// will fill in for a sparse array before rejecting the input. Without this, +// an attacker-supplied index (e.g. from a deepObject query parameter) drives +// an allocation proportional to the index itself, regardless of how many +// elements were actually provided. +const maxSliceMapToSliceGap = 10000 + // example: map[0:map[key:true] 1:map[key:false]] -> [map[key:true] map[key:false]] func sliceMapToSlice(m map[string]any) ([]any, error) { var result []any @@ -942,6 +949,9 @@ func sliceMapToSlice(m map[string]any) ([]any, error) { if err != nil { return nil, fmt.Errorf("array indexes must be integers: %w", err) } + if key < 0 { + return nil, fmt.Errorf("array indexes must not be negative: %d", key) + } keys = append(keys, key) } max := -1 @@ -950,6 +960,12 @@ func sliceMapToSlice(m map[string]any) ([]any, error) { max = k } } + // max+1 is the size of the slice this loop is about to build; bound the + // gap between what was actually supplied (len(m)) and that size so a + // single huge index can't force an outsized allocation. + if gap := max + 1 - len(m); gap > maxSliceMapToSliceGap { + return nil, fmt.Errorf("array index %d is too sparse relative to the %d supplied items", max, len(m)) + } for i := 0; i <= max; i++ { val, ok := m[strconv.Itoa(i)] if !ok { From ca3200e483a526f4379acdfc4bf3e11cbc20e8f1 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Mon, 13 Jul 2026 00:08:29 +0300 Subject: [PATCH 4/6] openapi3: remove StringMap and its pre-OriginTree origin-stripping machinery (#1219) --- .github/docs/openapi3.txt | 50 +----------------------- README.md | 3 ++ openapi3/callback.go | 6 --- openapi3/content.go | 6 --- openapi3/discriminator.go | 2 +- openapi3/encoding.go | 6 --- openapi3/example.go | 6 --- openapi3/header.go | 6 --- openapi3/link.go | 6 --- openapi3/media_type.go | 1 - openapi3/openapi3.go | 1 - openapi3/origin.go | 2 - openapi3/parameter.go | 6 --- openapi3/request_body.go | 6 --- openapi3/response.go | 6 --- openapi3/schema.go | 6 --- openapi3/security_requirements.go | 6 --- openapi3/security_scheme.go | 8 +--- openapi3/server.go | 7 ---- openapi3/stringmap.go | 64 ------------------------------- 20 files changed, 7 insertions(+), 197 deletions(-) delete mode 100644 openapi3/stringmap.go diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 78a4f7e6c..622ae2ef0 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -407,9 +407,6 @@ func (m Callbacks) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (callbacks *Callbacks) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Callbacks to a copy of data. - type CommentFieldFor31Plus struct{ ValidationError } func (e *CommentFieldFor31Plus) As(target any) bool @@ -534,9 +531,6 @@ func NewContentWithSchemaRef(schema *SchemaRef, consumes []string) Content func (content Content) Get(mime string) *MediaType -func (content *Content) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Content to a copy of data. - func (content Content) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Content does not comply with the OpenAPI spec. @@ -569,7 +563,7 @@ type Discriminator struct { Origin *Origin `json:"-" yaml:"-"` PropertyName string `json:"propertyName" yaml:"propertyName"` // required - Mapping StringMap[MappingRef] `json:"mapping,omitempty" yaml:"mapping,omitempty"` + Mapping map[string]MappingRef `json:"mapping,omitempty" yaml:"mapping,omitempty"` } Discriminator is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object @@ -714,9 +708,6 @@ func (encoding *Encoding) WithHeaderRef(name string, ref *HeaderRef) *Encoding type Encodings map[string]*Encoding Encodings is a map of encoding objects keyed by field name. -func (encodings *Encodings) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Encodings to a copy of data. - type ExactlyOneFieldError struct { // Fields is the set of fields, exactly one of which must be set // (e.g. ["content", "schema"]). @@ -816,9 +807,6 @@ func (m Examples) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (examples *Examples) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Examples to a copy of data. - type ExamplesFieldFor31Plus struct{ ValidationError } func (e *ExamplesFieldFor31Plus) As(target any) bool @@ -1059,9 +1047,6 @@ func (m Headers) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (headers *Headers) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Headers to a copy of data. - type IDFieldFor31Plus struct{ ValidationError } func (e *IDFieldFor31Plus) As(target any) bool @@ -1310,9 +1295,6 @@ func (m Links) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (links *Links) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Links to a copy of data. - type Loader struct { // IsExternalRefsAllowed enables visiting other files. Enforced only when // ReadFromURIFunc is nil; a custom ReadFromURIFunc bypasses this flag and @@ -1529,7 +1511,7 @@ type OAuthFlow struct { AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` - Scopes StringMap[string] `json:"scopes" yaml:"scopes"` // required + Scopes map[string]string `json:"scopes" yaml:"scopes"` // required } OAuthFlow is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object @@ -1881,9 +1863,6 @@ func (m ParametersMap) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (parametersMap *ParametersMap) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets ParametersMap to a copy of data. - type PathItem struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -2129,9 +2108,6 @@ func (m RequestBodies) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (requestBodies *RequestBodies) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets RequestBodies to a copy of data. - type RequestBody struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -2290,9 +2266,6 @@ func (m ResponseBodies) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (responseBodies *ResponseBodies) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets ResponseBodies to a copy of data. - type ResponseDescriptionRequired struct{ ValidationError } func (e *ResponseDescriptionRequired) As(target any) bool @@ -2925,9 +2898,6 @@ func (m Schemas) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (schemas *Schemas) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets Schemas to a copy of data. - type SectionValidationError struct { Section string Cause error @@ -2952,9 +2922,6 @@ func NewSecurityRequirement() SecurityRequirement func (security SecurityRequirement) Authenticate(provider string, scopes ...string) SecurityRequirement -func (security *SecurityRequirement) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets SecurityRequirement to a copy of data. - func (security *SecurityRequirement) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if SecurityRequirement does not comply with the OpenAPI spec. @@ -3098,9 +3065,6 @@ func (m SecuritySchemes) JSONLookup(token string) (any, error) JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable -func (securitySchemes *SecuritySchemes) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets SecuritySchemes to a copy of data. - type SerializationMethod struct { Style string Explode bool @@ -3199,9 +3163,6 @@ type ServerVariables map[string]*ServerVariable https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object ServerVariables is a map of ServerVariable objects keyed by variable name. -func (serverVariables *ServerVariables) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets ServerVariables to a copy of data. - type Servers []*Server Servers is specified by OpenAPI/Swagger standard version 3. @@ -3241,13 +3202,6 @@ func NewRegexpFormatValidator(pattern string) StringFormatValidator NewRegexpFormatValidator creates a new FormatValidator that uses a regular expression to validate the value. -type StringMap[V any] map[string]V - StringMap is a map[string]string that ignores the origin in the underlying - json representation. - -func (stringMap *StringMap[V]) UnmarshalJSON(data []byte) (err error) - UnmarshalJSON sets StringMap to a copy of data. - type T struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` diff --git a/README.md b/README.md index b0ee339a2..5cf2fbbab 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,9 @@ for _, path := range doc.Paths.InMatchingOrder() { ## CHANGELOG: Sub-v1 breaking API changes +### v0.142.0 +* Removed the `openapi3.StringMap[V]` type (an internal helper for origin-aware map unmarshalling, obsolete since origin tracking moved to a separate `OriginTree` pass). `openapi3.Discriminator.Mapping` field type changed from `StringMap[MappingRef]` to `map[string]MappingRef`, and `openapi3.OAuthFlow.Scopes` from `StringMap[string]` to `map[string]string`. + ### v0.137.0 * Reinstated `openapi3.*Ptr(..)` funcs for Go 1.25 diff --git a/openapi3/callback.go b/openapi3/callback.go index 407f730a9..6350d7947 100644 --- a/openapi3/callback.go +++ b/openapi3/callback.go @@ -47,9 +47,3 @@ func (callback *Callback) Validate(ctx context.Context, opts ...ValidationOption return validateExtensions(ctx, callback.Extensions, callback.Origin) } - -// UnmarshalJSON sets Callbacks to a copy of data. -func (callbacks *Callbacks) UnmarshalJSON(data []byte) (err error) { - *callbacks, err = unmarshalStringMapP[CallbackRef](data) - return -} diff --git a/openapi3/content.go b/openapi3/content.go index 8bf7921a5..ab79147f8 100644 --- a/openapi3/content.go +++ b/openapi3/content.go @@ -115,9 +115,3 @@ func (content Content) Validate(ctx context.Context, opts ...ValidationOption) e } return nil } - -// UnmarshalJSON sets Content to a copy of data. -func (content *Content) UnmarshalJSON(data []byte) (err error) { - *content, err = unmarshalStringMapP[MediaType](data) - return -} diff --git a/openapi3/discriminator.go b/openapi3/discriminator.go index d797701c3..64219d873 100644 --- a/openapi3/discriminator.go +++ b/openapi3/discriminator.go @@ -13,7 +13,7 @@ type Discriminator struct { Origin *Origin `json:"-" yaml:"-"` PropertyName string `json:"propertyName" yaml:"propertyName"` // required - Mapping StringMap[MappingRef] `json:"mapping,omitempty" yaml:"mapping,omitempty"` + Mapping map[string]MappingRef `json:"mapping,omitempty" yaml:"mapping,omitempty"` } // MappingRef is a ref to a Schema objects. Unlike SchemaRefs it is serialised diff --git a/openapi3/encoding.go b/openapi3/encoding.go index fd9316ae2..1094488a4 100644 --- a/openapi3/encoding.go +++ b/openapi3/encoding.go @@ -26,12 +26,6 @@ func NewEncoding() *Encoding { // Encodings is a map of encoding objects keyed by field name. type Encodings map[string]*Encoding -// UnmarshalJSON sets Encodings to a copy of data. -func (encodings *Encodings) UnmarshalJSON(data []byte) (err error) { - *encodings, err = unmarshalStringMapP[Encoding](data) - return -} - func (encoding *Encoding) WithHeader(name string, header *Header) *Encoding { return encoding.WithHeaderRef(name, &HeaderRef{ Value: header, diff --git a/openapi3/example.go b/openapi3/example.go index eeb4c6d4f..eeda1370e 100644 --- a/openapi3/example.go +++ b/openapi3/example.go @@ -82,9 +82,3 @@ func (example *Example) Validate(ctx context.Context, opts ...ValidationOption) return validateExtensions(ctx, example.Extensions, example.Origin) } - -// UnmarshalJSON sets Examples to a copy of data. -func (examples *Examples) UnmarshalJSON(data []byte) (err error) { - *examples, err = unmarshalStringMapP[ExampleRef](data) - return -} diff --git a/openapi3/header.go b/openapi3/header.go index 466c8ea21..36b74dd87 100644 --- a/openapi3/header.go +++ b/openapi3/header.go @@ -92,9 +92,3 @@ func (header *Header) Validate(ctx context.Context, opts ...ValidationOption) er } return nil } - -// UnmarshalJSON sets Headers to a copy of data. -func (headers *Headers) UnmarshalJSON(data []byte) (err error) { - *headers, err = unmarshalStringMapP[HeaderRef](data) - return -} diff --git a/openapi3/link.go b/openapi3/link.go index 60f78a029..eafeb42da 100644 --- a/openapi3/link.go +++ b/openapi3/link.go @@ -91,9 +91,3 @@ func (link *Link) Validate(ctx context.Context, opts ...ValidationOption) error return validateExtensions(ctx, link.Extensions, link.Origin) } - -// UnmarshalJSON sets Links to a copy of data. -func (links *Links) UnmarshalJSON(data []byte) (err error) { - *links, err = unmarshalStringMapP[LinkRef](data) - return -} diff --git a/openapi3/media_type.go b/openapi3/media_type.go index 842b320d6..e07026028 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -110,7 +110,6 @@ func (mediaType *MediaType) UnmarshalJSON(data []byte) error { if len(x.Extensions) == 0 { x.Extensions = nil } - delete(x.Encoding, originKey) *mediaType = MediaType(x) return nil } diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index 287327fa2..9671b913c 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -177,7 +177,6 @@ func (doc *T) UnmarshalJSON(data []byte) error { if len(x.Extensions) == 0 { x.Extensions = nil } - delete(x.Webhooks, originKey) *doc = T(x) return nil } diff --git a/openapi3/origin.go b/openapi3/origin.go index c6890e8ca..e55695a5b 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -8,8 +8,6 @@ import ( "github.com/oasdiff/yaml" ) -const originKey = "__origin__" - var originPtrType = reflect.TypeFor[*Origin]() // Origin contains the origin of a collection. diff --git a/openapi3/parameter.go b/openapi3/parameter.go index b1010a61e..2b9962676 100644 --- a/openapi3/parameter.go +++ b/openapi3/parameter.go @@ -409,9 +409,3 @@ func (parameter *Parameter) Validate(ctx context.Context, opts ...ValidationOpti return validateExtensions(ctx, parameter.Extensions, parameter.Origin) } - -// UnmarshalJSON sets ParametersMap to a copy of data. -func (parametersMap *ParametersMap) UnmarshalJSON(data []byte) (err error) { - *parametersMap, err = unmarshalStringMapP[ParameterRef](data) - return -} diff --git a/openapi3/request_body.go b/openapi3/request_body.go index e8c52a1cb..55c9b972f 100644 --- a/openapi3/request_body.go +++ b/openapi3/request_body.go @@ -135,9 +135,3 @@ func (requestBody *RequestBody) Validate(ctx context.Context, opts ...Validation return validateExtensions(ctx, requestBody.Extensions, requestBody.Origin) } - -// UnmarshalJSON sets RequestBodies to a copy of data. -func (requestBodies *RequestBodies) UnmarshalJSON(data []byte) (err error) { - *requestBodies, err = unmarshalStringMapP[RequestBodyRef](data) - return -} diff --git a/openapi3/response.go b/openapi3/response.go index 87a52fe24..2cbf83375 100644 --- a/openapi3/response.go +++ b/openapi3/response.go @@ -217,9 +217,3 @@ func (response *Response) Validate(ctx context.Context, opts ...ValidationOption return validateExtensions(ctx, response.Extensions, response.Origin) } - -// UnmarshalJSON sets ResponseBodies to a copy of data. -func (responseBodies *ResponseBodies) UnmarshalJSON(data []byte) (err error) { - *responseBodies, err = unmarshalStringMapP[ResponseRef](data) - return -} diff --git a/openapi3/schema.go b/openapi3/schema.go index 2934971d2..4388a3981 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -3186,9 +3186,3 @@ func deepCopyJSONValue(v any) any { return v // string, float64, bool, nil — all immutable } } - -// UnmarshalJSON sets Schemas to a copy of data. -func (schemas *Schemas) UnmarshalJSON(data []byte) (err error) { - *schemas, err = unmarshalStringMapP[SchemaRef](data) - return -} diff --git a/openapi3/security_requirements.go b/openapi3/security_requirements.go index 36b6401b1..3f5bd9510 100644 --- a/openapi3/security_requirements.go +++ b/openapi3/security_requirements.go @@ -49,9 +49,3 @@ func (security *SecurityRequirement) Validate(ctx context.Context, opts ...Valid return nil } - -// UnmarshalJSON sets SecurityRequirement to a copy of data. -func (security *SecurityRequirement) UnmarshalJSON(data []byte) (err error) { - *security, err = unmarshalStringMap[[]string](data) - return -} diff --git a/openapi3/security_scheme.go b/openapi3/security_scheme.go index efe7f20bb..0905b61aa 100644 --- a/openapi3/security_scheme.go +++ b/openapi3/security_scheme.go @@ -325,7 +325,7 @@ type OAuthFlow struct { AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` - Scopes StringMap[string] `json:"scopes" yaml:"scopes"` // required + Scopes map[string]string `json:"scopes" yaml:"scopes"` // required } // MarshalJSON returns the JSON encoding of OAuthFlow. @@ -426,9 +426,3 @@ func (flow *OAuthFlow) validate(ctx context.Context, typ oAuthFlowType, opts ... return flow.Validate(ctx, opts...) } - -// UnmarshalJSON sets SecuritySchemes to a copy of data. -func (securitySchemes *SecuritySchemes) UnmarshalJSON(data []byte) (err error) { - *securitySchemes, err = unmarshalStringMapP[SecuritySchemeRef](data) - return -} diff --git a/openapi3/server.go b/openapi3/server.go index 181eace24..ca460d7c2 100644 --- a/openapi3/server.go +++ b/openapi3/server.go @@ -119,7 +119,6 @@ func (server *Server) UnmarshalJSON(data []byte) error { if len(x.Extensions) == 0 { x.Extensions = nil } - delete(x.Variables, originKey) *server = Server(x) return nil } @@ -244,12 +243,6 @@ func (server *Server) Validate(ctx context.Context, opts ...ValidationOption) er // ServerVariables is a map of ServerVariable objects keyed by variable name. type ServerVariables map[string]*ServerVariable -// UnmarshalJSON sets ServerVariables to a copy of data. -func (serverVariables *ServerVariables) UnmarshalJSON(data []byte) (err error) { - *serverVariables, err = unmarshalStringMapP[ServerVariable](data) - return -} - type ServerVariable struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` diff --git a/openapi3/stringmap.go b/openapi3/stringmap.go deleted file mode 100644 index f416899e7..000000000 --- a/openapi3/stringmap.go +++ /dev/null @@ -1,64 +0,0 @@ -package openapi3 - -import "encoding/json" - -// StringMap is a map[string]string that ignores the origin in the underlying json representation. -type StringMap[V any] map[string]V - -// UnmarshalJSON sets StringMap to a copy of data. -func (stringMap *StringMap[V]) UnmarshalJSON(data []byte) (err error) { - *stringMap, err = unmarshalStringMap[V](data) - return -} - -// unmarshalStringMapP unmarshals given json into a map[string]*V -func unmarshalStringMapP[V any](data []byte) (map[string]*V, error) { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - - result := make(map[string]*V, len(m)) - for _, k := range componentNames(m) { - value, err := deepCast[V](m[k]) - if err != nil { - return nil, err - } - result[k] = value - } - - return result, nil -} - -// unmarshalStringMap unmarshals given json into a map[string]V -func unmarshalStringMap[V any](data []byte) (map[string]V, error) { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - - result := make(map[string]V, len(m)) - for _, k := range componentNames(m) { - value, err := deepCast[V](m[k]) - if err != nil { - return nil, err - } - result[k] = *value - } - - return result, nil -} - -// deepCast casts any value to a value of type V. -func deepCast[V any](value any) (*V, error) { - data, err := json.Marshal(value) - if err != nil { - return nil, err - } - - var result V - if err = json.Unmarshal(data, &result); err != nil { - return nil, err - } - return &result, nil -} From fc77a8f18b1b529f9a7cffcd9fa47d02eb655b5d Mon Sep 17 00:00:00 2001 From: Pierre Fenoll Date: Mon, 13 Jul 2026 10:24:04 +0200 Subject: [PATCH 5/6] openapi3: huge test suite: make it more parallel (#1221) * openapi3: huge test suite: make it more parallel Signed-off-by: Pierre Fenoll * openapi2: huge test suite: make it more parallel Signed-off-by: Pierre Fenoll * openapi3: huge test suite: cleanup stale goldens properly Signed-off-by: Pierre Fenoll --------- Signed-off-by: Pierre Fenoll --- .../v2_apis_guru_openapi_directory_test.go | 79 +++++++++------- .../v3_apis_guru_openapi_directory_test.go | 94 ++++++++++--------- 2 files changed, 97 insertions(+), 76 deletions(-) diff --git a/openapi2/v2_apis_guru_openapi_directory_test.go b/openapi2/v2_apis_guru_openapi_directory_test.go index 4622f06c4..dd4838880 100644 --- a/openapi2/v2_apis_guru_openapi_directory_test.go +++ b/openapi2/v2_apis_guru_openapi_directory_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/oasdiff/yaml" @@ -35,6 +36,7 @@ func isOpenAPIVersion(t *testing.T, path, str string) bool { r := bufio.NewScanner(file) for r.Scan() { + require.NoError(t, r.Err()) if strings.Contains(r.Text(), str) { return true } @@ -128,6 +130,7 @@ func TestV2ApisGuruOpenapiDirectory(t *testing.T) { root := filepath.Join(targetDir, dirName, "APIs") underscorer := newUnderscorer() + var checkedMu sync.Mutex checked := make(map[string]struct{}) var paths []string @@ -140,48 +143,56 @@ func TestV2ApisGuruOpenapiDirectory(t *testing.T) { require.NoError(t, err) t.Logf("found %v files in %q", len(paths), root) + // Runs after all parallel subtests have completed. + t.Cleanup(func() { + err := filepath.Walk(goldens, func(path string, info os.FileInfo, err error) error { + require.NotNil(t, info) + if !info.IsDir() { + shortName := shortNameFromPath(path) + delete(checked, shortName) + } + return nil + }) + require.NoError(t, err) + + files, err := filepath.Glob(goldens + "/*") + require.NoError(t, err) + for _, file := range files { + shortName := shortNameFromPath(file) + if _, ok := checked[shortName]; ok || disabled(shortName) { + err := os.Remove(file) + require.NoError(t, err) + } + } + }) + for _, path := range paths { shortName := underscorer.Replace(strings.TrimPrefix(path, root)[1:]) - if isOpenAPIVersion(t, path, "swagger: ") { - t.Run(shortName, func(t *testing.T) { - if disabled(shortName) { - t.SkipNow() - return - } - checked[shortName] = struct{}{} - t.Parallel() - - data, err := os.ReadFile(path) - require.NoError(t, err) + t.Run(shortName, func(t *testing.T) { + if disabled(shortName) { + t.SkipNow() + return + } + t.Parallel() - var doc openapi2.T - _, err = yaml.Unmarshal(data, &doc, yaml.DecodeOpts{DisableTimestamps: true}) - golden(t, err, shortName, "load") - }) - } - } + if !isOpenAPIVersion(t, path, "swagger: ") { + t.SkipNow() + return + } - err = filepath.Walk(goldens, func(path string, info os.FileInfo, err error) error { - require.NotNil(t, info) - if !info.IsDir() { - shortName := shortNameFromPath(path) - delete(checked, shortName) - } - return nil - }) - require.NoError(t, err) + checkedMu.Lock() + checked[shortName] = struct{}{} + checkedMu.Unlock() - files, err := filepath.Glob(goldens + "*") - require.NoError(t, err) - for _, file := range files { - shortName := shortNameFromPath(file) - if _, ok := checked[shortName]; ok || disabled(shortName) { - err := os.Remove(file) + data, err := os.ReadFile(path) require.NoError(t, err) - } - } + var doc openapi2.T + _, err = yaml.Unmarshal(data, &doc, yaml.DecodeOpts{DisableTimestamps: true}) + golden(t, err, shortName, "load") + }) + } } func disabled(shortName string) bool { diff --git a/openapi3/v3_apis_guru_openapi_directory_test.go b/openapi3/v3_apis_guru_openapi_directory_test.go index 986a5724e..3772a58fa 100644 --- a/openapi3/v3_apis_guru_openapi_directory_test.go +++ b/openapi3/v3_apis_guru_openapi_directory_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/stretchr/testify/require" @@ -35,6 +36,7 @@ func isOpenAPIVersion(t *testing.T, path, str string) bool { r := bufio.NewScanner(file) for r.Scan() { + require.NoError(t, r.Err()) if strings.Contains(r.Text(), str) { return true } @@ -129,6 +131,7 @@ func TestV3ApisGuruOpenapiDirectory(t *testing.T) { root := filepath.Join(targetDir, dirName, "APIs") underscorer := newUnderscorer() + var checkedMu sync.Mutex checked := make(map[string]struct{}) var paths []string @@ -141,57 +144,64 @@ func TestV3ApisGuruOpenapiDirectory(t *testing.T) { require.NoError(t, err) t.Logf("found %v files in %q", len(paths), root) + t.Cleanup(func() { + err := filepath.Walk(goldens, func(path string, info os.FileInfo, err error) error { + if !info.IsDir() { + shortName := shortNameFromPath(path) + delete(checked, shortName) + } + return nil + }) + require.NoError(t, err) + + files, err := filepath.Glob(goldens + "/*") + require.NoError(t, err) + for _, file := range files { + shortName := shortNameFromPath(file) + if _, ok := checked[shortName]; ok || disabled(shortName) { + err := os.Remove(file) + require.NoError(t, err) + } + } + }) + for _, path := range paths { shortName := underscorer.Replace(strings.TrimPrefix(path, root)[1:]) - if isOpenAPIVersion(t, path, "openapi: 3") { - t.Run(shortName, func(t *testing.T) { - if disabled(shortName) { - t.SkipNow() - return - } - checked[shortName] = struct{}{} - t.Parallel() + t.Run(shortName, func(t *testing.T) { + if disabled(shortName) { + t.SkipNow() + return + } + t.Parallel() - loader := openapi3.NewLoader() - loader.Context = t.Context() + if !isOpenAPIVersion(t, path, "openapi: 3") { + t.SkipNow() + return + } - doc, err := loader.LoadFromFile(path) - golden(t, err, shortName, "load") - if doc != nil { - var opts []openapi3.ValidationOption - err = doc.Validate(loader.Context, opts...) - golden(t, err, shortName, "validate") + checkedMu.Lock() + checked[shortName] = struct{}{} + checkedMu.Unlock() - if err == nil { - openapi3conv.Upgrade(doc, openapi3conv.WithWriter(t.Output())) - err = doc.Validate(loader.Context, opts...) - golden(t, err, shortName, "validatebis") - } - } - }) - } - } + loader := openapi3.NewLoader() + loader.Context = t.Context() - err = filepath.Walk(goldens, func(path string, info os.FileInfo, err error) error { - if !info.IsDir() { - shortName := shortNameFromPath(path) - delete(checked, shortName) - } - return nil - }) - require.NoError(t, err) + doc, err := loader.LoadFromFile(path) + golden(t, err, shortName, "load") + if doc != nil { + var opts []openapi3.ValidationOption + err = doc.Validate(loader.Context, opts...) + golden(t, err, shortName, "validate") - files, err := filepath.Glob(goldens + "*") - require.NoError(t, err) - for _, file := range files { - shortName := shortNameFromPath(file) - if _, ok := checked[shortName]; ok || disabled(shortName) { - err := os.Remove(file) - require.NoError(t, err) - } + if err == nil { + openapi3conv.Upgrade(doc, openapi3conv.WithWriter(t.Output())) + err = doc.Validate(loader.Context, opts...) + golden(t, err, shortName, "validatebis") + } + } + }) } - } func disabled(shortName string) bool { From 3a8a887779757377f44788413b6a5114733b9387 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Sat, 11 Jul 2026 12:31:10 +0300 Subject: [PATCH 6/6] openapi3: preserve origin for a $ref to a schema under an arbitrary top-level key A $ref to a schema stored under an arbitrary top-level key in another file (e.g. "./schemas.yaml#/User", the Swagger-2-era definitions bag) resolves through T.Extensions, a generic map that carries no origin, and the json round-trip that decodes it into the typed component drops origin entirely. With IncludeOrigin enabled the resolved schema then has a nil Origin, unlike the /components/schemas path. Re-attach origins on that path: re-derive the component file's origin tree, walk to the ref fragment, and apply the subtree, so the resolved object carries the same origins a typed resolution would, with the original file's line numbers. Best-effort; any failure leaves the object without origins, as before. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BqJA1X6suZYtR3tRbX8sLj --- openapi3/loader.go | 34 +++++++++++++++++++ openapi3/marsh.go | 21 ++++++++++++ openapi3/origin_external_ref_test.go | 31 +++++++++++++++++ openapi3/testdata/origin/arbitrary_key.yaml | 14 ++++++++ .../origin/arbitrary_key_schemas.yaml | 7 ++++ 5 files changed, 107 insertions(+) create mode 100644 openapi3/origin_external_ref_test.go create mode 100644 openapi3/testdata/origin/arbitrary_key.yaml create mode 100644 openapi3/testdata/origin/arbitrary_key_schemas.yaml diff --git a/openapi3/loader.go b/openapi3/loader.go index f7fc8cd87..a48b001ad 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -516,6 +516,10 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv if err := codec(cursor, resolved); err != nil { return nil, nil, fmt.Errorf("bad data in %q (expecting %s)", ref, readableType(resolved)) } + // The value came from a generic map in T.Extensions (a $ref to an + // arbitrary top-level key), so the json round-trip above stripped its + // origins. Re-attach them from the file, best-effort. + loader.attachOriginToResolved(resolved, componentPath, fragment) return componentDoc, componentPath, nil default: @@ -523,6 +527,36 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv } } +// attachOriginToResolved re-attaches source origins to a component resolved +// through the generic-map path: a $ref to a schema under an arbitrary top-level +// key lands in T.Extensions, and the json round-trip in resolveComponent strips +// its origin. It re-derives the component file's origin tree, walks to the ref +// fragment, and applies that subtree, so the object carries the same origins a +// typed resolution would, with the original file's line numbers. Best-effort: +// any failure leaves the object without origins, exactly as before. +func (loader *Loader) attachOriginToResolved(resolved any, componentPath *url.URL, fragment string) { + if !loader.IncludeOrigin || componentPath == nil { + return + } + data, err := loader.readURL(componentPath) + if err != nil { + return + } + tree := originTree(data, componentPath) + if tree == nil { + return + } + for _, part := range strings.Split(strings.Trim(fragment, "/"), "/") { + if part == "" { + continue + } + if tree = tree.Fields[unescapeRefString(part)]; tree == nil { + return + } + } + applyOrigins(resolved, tree) +} + func readableType(x any) string { switch x.(type) { case *Callback: diff --git a/openapi3/marsh.go b/openapi3/marsh.go index 6483fa9a4..45e0c229a 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -43,3 +43,24 @@ func unmarshal(data []byte, v any, includeOrigin bool, location *url.URL) error // If both unmarshaling attempts fail, return a new error that includes both errors return fmt.Errorf("failed to unmarshal data: json error: %v, yaml error: %v", jsonErr, yamlErr) } + +// originTree parses data solely for its origin tree, used to re-attach source +// origins to a component resolved through the generic-map path: a $ref to a +// schema under an arbitrary top-level key lands in T.Extensions and loses its +// origin in the json round-trip (see resolveComponent). Returns nil when parsing +// fails; callers degrade to no origin. +func originTree(data []byte, location *url.URL) *yaml.OriginTree { + var file string + if location != nil { + file = location.String() + } + var v any + tree, err := yaml.Unmarshal(data, &v, yaml.DecodeOpts{ + Origin: yaml.OriginOpt{Enabled: true, File: file}, + DisableTimestamps: true, + }) + if err != nil { + return nil + } + return tree +} diff --git a/openapi3/origin_external_ref_test.go b/openapi3/origin_external_ref_test.go new file mode 100644 index 000000000..5410310e6 --- /dev/null +++ b/openapi3/origin_external_ref_test.go @@ -0,0 +1,31 @@ +package openapi3 + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// A $ref to a schema stored under an arbitrary top-level key in another file +// (e.g. "./schemas.yaml#/User", the Swagger-2-era "definitions bag") must carry +// the origin of the file it lives in, like a $ref into /components/schemas does. +// The key is not a typed field of T, so the loader reaches it through T.Extensions +// (a generic map); the origin must survive that path. +func TestOrigin_ExternalRefToArbitraryTopLevelKey(t *testing.T) { + loader := NewLoader() + loader.IncludeOrigin = true + loader.IsExternalRefsAllowed = true + + doc, err := loader.LoadFromFile("testdata/origin/arbitrary_key.yaml") + require.NoError(t, err) + + user := doc.Paths.Value("/users").Get.Responses.Value("200").Value.Content["application/json"].Schema.Value + require.NotNil(t, user, "the User schema resolves") + + require.NotNil(t, user.Origin, "a schema $ref'd under an arbitrary top-level key must carry an origin") + require.NotNil(t, user.Origin.Key, "the origin has a key location") + require.Equal(t, "arbitrary_key_schemas.yaml", filepath.Base(user.Origin.Key.File), + "origin points at the file the schema lives in, not the referencing document") + require.NotZero(t, user.Origin.Key.Line, "the origin has a source line") +} diff --git a/openapi3/testdata/origin/arbitrary_key.yaml b/openapi3/testdata/origin/arbitrary_key.yaml new file mode 100644 index 000000000..11cf195fe --- /dev/null +++ b/openapi3/testdata/origin/arbitrary_key.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + title: Arbitrary top-level key ref + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "./arbitrary_key_schemas.yaml#/User" diff --git a/openapi3/testdata/origin/arbitrary_key_schemas.yaml b/openapi3/testdata/origin/arbitrary_key_schemas.yaml new file mode 100644 index 000000000..8474e357b --- /dev/null +++ b/openapi3/testdata/origin/arbitrary_key_schemas.yaml @@ -0,0 +1,7 @@ +User: + type: object + properties: + id: + type: string + name: + type: string