From 9a40c95b0c9d320649be57ca35cd301e3dfe90bd Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 4 Jun 2026 15:08:01 +0300 Subject: [PATCH 1/3] openapi3: enforce unique required entries and unique tag names in Validate() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spec MUSTs that Validate() did not check (#1200): - The elements of a schema's `required` array MUST be unique (JSON Schema 2020-12 §6.5.3 for OAS 3.1, draft-04 for OAS 3.0). schema.validate now returns DuplicateRequiredFieldError on a repeat. - Each tag name in the document-root `tags` list MUST be unique (OpenAPI Object). Tags.Validate now emits DuplicateTagError on a repeat. Both follow the existing Duplicate* cluster pattern (cf. DuplicateParameterError) and carry Origin when the document was loaded with IncludeOrigin. This is a behavior change: documents carrying these duplicates that passed Validate() before now fail. No apis-guru golden fixture changed (the corpus trips neither check). Co-Authored-By: Claude Opus 4.8 (1M context) --- openapi3/schema.go | 12 ++++++++++ openapi3/tag.go | 13 ++++++++++ openapi3/validation_error.go | 29 ++++++++++++++++++++++ openapi3/validation_error_test.go | 40 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/openapi3/schema.go b/openapi3/schema.go index 3505e4999..89cfa22f7 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -1406,6 +1406,18 @@ func (schema *Schema) validate(ctx context.Context, stack []*Schema) ([]*Schema, return stack, newSchemaReadOnlyWriteOnlyExclusive(schema.Origin) } + // The elements of `required` MUST be unique (JSON Schema 2020-12 §6.5.3 + // for OAS 3.1, draft-04 for OAS 3.0). + if len(schema.Required) > 1 { + seen := make(map[string]struct{}, len(schema.Required)) + for _, name := range schema.Required { + if _, dup := seen[name]; dup { + return stack, &DuplicateRequiredFieldError{Field: name, Origin: schema.Origin} + } + seen[name] = struct{}{} + } + } + // Reject fields that only exist in OAS 3.1 / JSON Schema 2020-12 when the // document is OAS 3.0. Fields explicitly allowed via AllowExtraSiblingFields // are skipped (opt-in escape hatch for 3.0 docs that reference external diff --git a/openapi3/tag.go b/openapi3/tag.go index 98b4a9167..316364c7f 100644 --- a/openapi3/tag.go +++ b/openapi3/tag.go @@ -23,7 +23,20 @@ func (tags Tags) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) me := newErrCollector(ctx) + // Each tag name in the list MUST be unique (OpenAPI Object). Empty names + // are left to per-tag validation, not flagged as duplicates here. + seen := make(map[string]struct{}, len(tags)) + for _, v := range tags { + if v.Name != "" { + if _, dup := seen[v.Name]; dup { + if err := me.emit(&DuplicateTagError{Name: v.Name, Origin: v.Origin}); err != nil { + return err + } + } else { + seen[v.Name] = struct{}{} + } + } wrap := func(e error) error { return &TagValidationError{Name: v.Name, Cause: e} } if err := me.emitWrapped(wrap, v.Validate(ctx)); err != nil { return err diff --git a/openapi3/validation_error.go b/openapi3/validation_error.go index 8929a1f6e..b01180376 100644 --- a/openapi3/validation_error.go +++ b/openapi3/validation_error.go @@ -535,6 +535,35 @@ func (e *DuplicateParameterError) Error() string { return fmt.Sprintf("more than one %q parameter has name %q", e.In, e.Name) } +// DuplicateRequiredFieldError clusters "duplicate field in required" failures. +// The elements of a schema's `required` array MUST be unique (JSON Schema +// 2020-12 §6.5.3 for OpenAPI 3.1, draft-04 for OpenAPI 3.0). +type DuplicateRequiredFieldError struct { + // Field is the field name listed more than once in `required`. + Field string + // Origin is the source location of the offending schema when the + // document was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + +func (e *DuplicateRequiredFieldError) Error() string { + return fmt.Sprintf("duplicate field %q in required", e.Field) +} + +// DuplicateTagError clusters "more than one tag has name X" failures. Each tag +// name in the document-root `tags` list MUST be unique (OpenAPI Object). +type DuplicateTagError struct { + // Name is the tag name that appears more than once. + Name string + // Origin is the source location of the offending tag when the document + // was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + +func (e *DuplicateTagError) Error() string { + return fmt.Sprintf("more than one tag has name %q", e.Name) +} + // InvalidSerializationMethodError clusters "serialization method with // style=X and explode=Y is not supported by Z" failures. Fires for // invalid (style, explode) combinations on encodings, parameters, diff --git a/openapi3/validation_error_test.go b/openapi3/validation_error_test.go index 455a65cdf..ebae2a290 100644 --- a/openapi3/validation_error_test.go +++ b/openapi3/validation_error_test.go @@ -2127,6 +2127,46 @@ func TestValidationError_SchemaCombinatorElementValidationError_NoStutter(t *tes require.Equal(t, "invalid oneOf element: invalid allOf element: boom", mixed.Error()) } +func TestValidationError_DuplicateRequiredFieldError(t *testing.T) { + doc := loadDocFromYAML(t, ` +openapi: 3.0.3 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Bad: + type: object + required: [id, id] + properties: + id: { type: string } +`) + err := doc.Validate(context.Background()) + require.Error(t, err) + + var dup *openapi3.DuplicateRequiredFieldError + require.True(t, errors.As(err, &dup)) + require.Equal(t, "id", dup.Field) + require.Contains(t, dup.Error(), `duplicate field "id" in required`) +} + +func TestValidationError_DuplicateTagError(t *testing.T) { + doc := loadDocFromYAML(t, ` +openapi: 3.0.3 +info: { title: t, version: "1" } +paths: {} +tags: + - name: pet + - name: pet +`) + err := doc.Validate(context.Background()) + require.Error(t, err) + + var dup *openapi3.DuplicateTagError + require.True(t, errors.As(err, &dup)) + require.Equal(t, "pet", dup.Name) + require.Contains(t, dup.Error(), `more than one tag has name "pet"`) +} + func TestValidationError_TagValidationError(t *testing.T) { doc := loadDocFromYAML(t, ` openapi: 3.0.3 From bebcac90cfcaab6763af01c4d09abf2bc65ae404 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 4 Jun 2026 15:19:29 +0300 Subject: [PATCH 2/3] openapi3: regenerate .github/docs for the new error types go doc dump (docs.sh) now lists DuplicateRequiredFieldError and DuplicateTagError, which the CI `git diff --exit-code` gate after generation requires to be committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/docs/openapi3.txt | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 352ca8ae4..ae6b9e48f 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -608,6 +608,31 @@ type DuplicateParameterError struct { func (e *DuplicateParameterError) Error() string +type DuplicateRequiredFieldError struct { + // Field is the field name listed more than once in `required`. + Field string + // Origin is the source location of the offending schema when the + // document was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + DuplicateRequiredFieldError clusters "duplicate field in required" failures. + The elements of a schema's `required` array MUST be unique (JSON Schema + 2020-12 §6.5.3 for OpenAPI 3.1, draft-04 for OpenAPI 3.0). + +func (e *DuplicateRequiredFieldError) Error() string + +type DuplicateTagError struct { + // Name is the tag name that appears more than once. + Name string + // Origin is the source location of the offending tag when the document + // was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + DuplicateTagError clusters "more than one tag has name X" failures. Each tag + name in the document-root `tags` list MUST be unique (OpenAPI Object). + +func (e *DuplicateTagError) Error() string + type DynamicAnchorFieldFor31Plus struct{ ValidationError } func (e *DynamicAnchorFieldFor31Plus) As(target any) bool From e7300ce4f41e373312f0d680ca2e94682efd8f75 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 4 Jun 2026 23:46:10 +0300 Subject: [PATCH 3/3] openapi3: drop redundant else in tag uniqueness check Re-inserting an existing key into the seen set is a no-op, so the seen update can run unconditionally after the duplicate check instead of in an else. No behavior change. (review feedback) Co-Authored-By: Claude Opus 4.8 (1M context) --- openapi3/tag.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openapi3/tag.go b/openapi3/tag.go index 316364c7f..55b302fa5 100644 --- a/openapi3/tag.go +++ b/openapi3/tag.go @@ -33,9 +33,8 @@ func (tags Tags) Validate(ctx context.Context, opts ...ValidationOption) error { if err := me.emit(&DuplicateTagError{Name: v.Name, Origin: v.Origin}); err != nil { return err } - } else { - seen[v.Name] = struct{}{} } + seen[v.Name] = struct{}{} } wrap := func(e error) error { return &TagValidationError{Name: v.Name, Cause: e} } if err := me.emitWrapped(wrap, v.Validate(ctx)); err != nil {