Skip to content

Commit 5ae0b3a

Browse files
openapi3: process discriminator mapping values as refs
While the type of the discriminator mapping values is a string in the upstream specs, it contains a jsonschema reference to a schema object. It is surprising behaviour that these refs are not handled when calling functions such as InternalizeRefs. This patch adds the data structures to store the ref internally, and updates the Loader and InternalizeRefs to handle this case.
1 parent 45db2ad commit 5ae0b3a

File tree

12 files changed

+247
-19
lines changed

12 files changed

+247
-19
lines changed

.github/docs/openapi3.txt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,8 @@ type Discriminator struct {
430430
Extensions map[string]any `json:"-" yaml:"-"`
431431
Origin *Origin `json:"__origin__,omitempty" yaml:"__origin__,omitempty"`
432432

433-
PropertyName string `json:"propertyName" yaml:"propertyName"` // required
434-
Mapping StringMap `json:"mapping,omitempty" yaml:"mapping,omitempty"`
433+
PropertyName string `json:"propertyName" yaml:"propertyName"` // required
434+
Mapping StringMap[MappingRef] `json:"mapping,omitempty" yaml:"mapping,omitempty"`
435435
}
436436
Discriminator is specified by OpenAPI/Swagger standard version 3. See
437437
https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object
@@ -847,6 +847,15 @@ type Location struct {
847847
}
848848
Location is a struct that contains the location of a field.
849849

850+
type MappingRef SchemaRef
851+
MappingRef is a ref to a Schema objects. Unlike SchemaRefs it is serialised
852+
as a plain string instead of an object with a $ref key, as such it also does
853+
not support extensions.
854+
855+
func (mr MappingRef) MarshalText() ([]byte, error)
856+
857+
func (mr *MappingRef) UnmarshalText(data []byte) error
858+
850859
type MediaType struct {
851860
Extensions map[string]any `json:"-" yaml:"-"`
852861
Origin *Origin `json:"__origin__,omitempty" yaml:"__origin__,omitempty"`
@@ -929,10 +938,10 @@ type OAuthFlow struct {
929938
Extensions map[string]any `json:"-" yaml:"-"`
930939
Origin *Origin `json:"__origin__,omitempty" yaml:"__origin__,omitempty"`
931940

932-
AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"`
933-
TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"`
934-
RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"`
935-
Scopes StringMap `json:"scopes" yaml:"scopes"` // required
941+
AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"`
942+
TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"`
943+
RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"`
944+
Scopes StringMap[string] `json:"scopes" yaml:"scopes"` // required
936945
}
937946
OAuthFlow is specified by OpenAPI/Swagger standard version 3. See
938947
https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object
@@ -2083,11 +2092,11 @@ func NewRegexpFormatValidator(pattern string) StringFormatValidator
20832092
NewRegexpFormatValidator creates a new FormatValidator that uses a regular
20842093
expression to validate the value.
20852094

2086-
type StringMap map[string]string
2095+
type StringMap[V any] map[string]V
20872096
StringMap is a map[string]string that ignores the origin in the underlying
20882097
json representation.
20892098

2090-
func (stringMap *StringMap) UnmarshalJSON(data []byte) (err error)
2099+
func (stringMap *StringMap[V]) UnmarshalJSON(data []byte) (err error)
20912100
UnmarshalJSON sets StringMap to a copy of data.
20922101

20932102
type T struct {

openapi3/discriminator.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,22 @@ type Discriminator struct {
1111
Extensions map[string]any `json:"-" yaml:"-"`
1212
Origin *Origin `json:"__origin__,omitempty" yaml:"__origin__,omitempty"`
1313

14-
PropertyName string `json:"propertyName" yaml:"propertyName"` // required
15-
Mapping StringMap `json:"mapping,omitempty" yaml:"mapping,omitempty"`
14+
PropertyName string `json:"propertyName" yaml:"propertyName"` // required
15+
Mapping StringMap[MappingRef] `json:"mapping,omitempty" yaml:"mapping,omitempty"`
16+
}
17+
18+
// MappingRef is a ref to a Schema objects. Unlike SchemaRefs it is serialised
19+
// as a plain string instead of an object with a $ref key, as such it also does
20+
// not support extensions.
21+
type MappingRef SchemaRef
22+
23+
func (mr *MappingRef) UnmarshalText(data []byte) error {
24+
mr.Ref = string(data)
25+
return nil
26+
}
27+
28+
func (mr MappingRef) MarshalText() ([]byte, error) {
29+
return []byte(mr.Ref), nil
1630
}
1731

1832
// MarshalJSON returns the JSON encoding of Discriminator.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package openapi3
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// TestDiscriminatorMappingRefsInternalize tests that discriminator mapping refs
12+
// are properly internalized when calling InternalizeRefs.
13+
// This test demonstrates the issue that discriminator mapping values, which are
14+
// JSON schema references serialized as plain strings, are not handled by InternalizeRefs.
15+
func TestDiscriminatorMappingRefsInternalize(t *testing.T) {
16+
ctx := context.Background()
17+
18+
// Load the spec with external discriminator mapping refs
19+
sl := NewLoader()
20+
sl.IsExternalRefsAllowed = true
21+
doc, err := sl.LoadFromFile("testdata/discriminator.yml")
22+
require.NoError(t, err, "loading test file")
23+
err = doc.Validate(ctx)
24+
require.NoError(t, err, "validating spec")
25+
26+
// Verify the discriminator mapping refs are external before internalization
27+
schema := doc.Paths.Value("/").Get.Responses.Status(200).Value.Content.Get("application/json").Schema.Value.Items.Value
28+
require.NotNil(t, schema.Discriminator, "discriminator should exist")
29+
require.NotNil(t, schema.Discriminator.Mapping, "discriminator mapping should exist")
30+
31+
// Before internalization, the mapping refs should be external
32+
fooMapping := schema.Discriminator.Mapping["foo"].Ref
33+
barMapping := schema.Discriminator.Mapping["bar"].Ref
34+
t.Logf("Before internalization - foo mapping: %s", fooMapping)
35+
t.Logf("Before internalization - bar mapping: %s", barMapping)
36+
37+
require.True(t, strings.Contains(fooMapping, "ext.yml"), "foo mapping should reference external file before internalization")
38+
require.True(t, strings.Contains(barMapping, "ext.yml"), "bar mapping should reference external file before internalization")
39+
40+
// Internalize the references
41+
doc.InternalizeRefs(ctx, nil)
42+
43+
// Validate the internalized spec
44+
err = doc.Validate(ctx)
45+
require.NoError(t, err, "validating internalized spec")
46+
47+
// After internalization, the mapping refs should be internal (#/components/...)
48+
schema = doc.Paths.Value("/").Get.Responses.Status(200).Value.Content.Get("application/json").Schema.Value.Items.Value
49+
fooMapping = schema.Discriminator.Mapping["foo"].Ref
50+
barMapping = schema.Discriminator.Mapping["bar"].Ref
51+
t.Logf("After internalization - foo mapping: %s", fooMapping)
52+
t.Logf("After internalization - bar mapping: %s", barMapping)
53+
54+
// This is where the test fails currently - the mapping refs are NOT being internalized
55+
require.True(t, strings.HasPrefix(fooMapping, "#/components/"), "foo mapping should be internalized to #/components/...")
56+
require.True(t, strings.HasPrefix(barMapping, "#/components/"), "bar mapping should be internalized to #/components/...")
57+
}
58+

openapi3/internalize_refs.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,17 @@ func (doc *T) derefSchema(s *Schema, refNameResolver RefNameResolver, parentIsEx
345345
}
346346
}
347347

348+
// Discriminator mapping values are special cases since they are not full
349+
// ref objects but are string references to schema objects.
350+
if s.Discriminator != nil && s.Discriminator.Mapping != nil {
351+
for k, mapRef := range s.Discriminator.Mapping {
352+
s2 := (*SchemaRef)(&mapRef)
353+
isExternal := doc.addSchemaToSpec(s2, refNameResolver, parentIsExternal)
354+
doc.derefSchema(s2.Value, refNameResolver, isExternal || parentIsExternal)
355+
s.Discriminator.Mapping[k] = MappingRef(*s2)
356+
}
357+
}
358+
348359
for _, name := range componentNames(s.Properties) {
349360
s2 := s.Properties[name]
350361
isExternal := doc.addSchemaToSpec(s2, refNameResolver, parentIsExternal)

openapi3/internalize_refs_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func TestInternalizeRefs(t *testing.T) {
2525
{"testdata/issue831/testref.internalizepath.openapi.yml"},
2626
{"testdata/issue959/openapi.yml"},
2727
{"testdata/interalizationNameCollision/api.yml"},
28+
{"testdata/discriminator.yml"},
2829
}
2930

3031
for _, test := range tests {

openapi3/loader.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,22 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat
970970
return err
971971
}
972972
}
973+
// Discriminator mapping refs are a special case since they are not full
974+
// ref objects but are plain strings that reference schema objects.
975+
// Only resolve refs that look like external references (contain a path).
976+
// Plain schema names like "Dog" or internal refs like "#/components/schemas/Dog"
977+
// don't need to be resolved by the loader.
978+
if value.Discriminator != nil && value.Discriminator.Mapping != nil {
979+
for k, v := range value.Discriminator.Mapping {
980+
// Only resolve if it looks like an external ref (contains path separator)
981+
if strings.Contains(v.Ref, "/") && !strings.HasPrefix(v.Ref, "#") {
982+
if err := loader.resolveSchemaRef(doc, (*SchemaRef)(&v), documentPath, visited); err != nil {
983+
return err
984+
}
985+
value.Discriminator.Mapping[k] = v
986+
}
987+
}
988+
}
973989
return nil
974990
}
975991

openapi3/schema.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,7 +1299,7 @@ func (schema *Schema) visitNotOperation(settings *schemaValidationSettings, valu
12991299
func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, value any) (err error, run bool) {
13001300
var visitedOneOf, visitedAnyOf, visitedAllOf bool
13011301
if v := schema.OneOf; len(v) > 0 {
1302-
var discriminatorRef string
1302+
var discriminatorRef MappingRef
13031303
if schema.Discriminator != nil {
13041304
pn := schema.Discriminator.PropertyName
13051305
if valuemap, okcheck := value.(map[string]any); okcheck {
@@ -1345,7 +1345,7 @@ func (schema *Schema) visitXOFOperations(settings *schemaValidationSettings, val
13451345
return foundUnresolvedRef(item.Ref), false
13461346
}
13471347

1348-
if discriminatorRef != "" && discriminatorRef != item.Ref {
1348+
if discriminatorRef.Ref != "" && discriminatorRef.Ref != item.Ref {
13491349
continue
13501350
}
13511351

openapi3/security_scheme.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,10 +322,10 @@ type OAuthFlow struct {
322322
Extensions map[string]any `json:"-" yaml:"-"`
323323
Origin *Origin `json:"__origin__,omitempty" yaml:"__origin__,omitempty"`
324324

325-
AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"`
326-
TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"`
327-
RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"`
328-
Scopes StringMap `json:"scopes" yaml:"scopes"` // required
325+
AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"`
326+
TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"`
327+
RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"`
328+
Scopes StringMap[string] `json:"scopes" yaml:"scopes"` // required
329329
}
330330

331331
// MarshalJSON returns the JSON encoding of OAuthFlow.

openapi3/stringmap.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ package openapi3
33
import "encoding/json"
44

55
// StringMap is a map[string]string that ignores the origin in the underlying json representation.
6-
type StringMap map[string]string
6+
type StringMap[V any] map[string]V
77

88
// UnmarshalJSON sets StringMap to a copy of data.
9-
func (stringMap *StringMap) UnmarshalJSON(data []byte) (err error) {
10-
*stringMap, _, err = unmarshalStringMap[string](data)
9+
func (stringMap *StringMap[V]) UnmarshalJSON(data []byte) (err error) {
10+
*stringMap, _, err = unmarshalStringMap[V](data)
1111
return
1212
}
1313

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
openapi: 3.1.0
2+
info:
3+
title: foo
4+
version: 1.0.0
5+
paths:
6+
/:
7+
get:
8+
operationId: list
9+
responses:
10+
"200":
11+
description: list
12+
content:
13+
application/json:
14+
schema:
15+
type: array
16+
items:
17+
oneOf:
18+
- $ref: "./ext.yml#/schemas/Foo"
19+
- $ref: "./ext.yml#/schemas/Bar"
20+
discriminator:
21+
propertyName: cat
22+
mapping:
23+
foo: "./ext.yml#/schemas/Foo"
24+
bar: "./ext.yml#/schemas/Bar"
25+

0 commit comments

Comments
 (0)