Skip to content

Commit 0a6a0de

Browse files
fix: validate empty component names in schema $ref and use spec-compliant separator (#106)
1 parent c4b3dac commit 0a6a0de

File tree

10 files changed

+283
-34
lines changed

10 files changed

+283
-34
lines changed

cmd/openapi/commands/openapi/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ openapi spec bundle --naming filepath ./spec.yaml ./bundled.yaml
410410

411411
**Naming Strategies:**
412412

413-
- `filepath` (default): Uses file path-based naming like `external_api_yaml~User` for conflicts
413+
- `filepath` (default): Uses file path-based naming like `external_api_yaml__User` for conflicts
414414
- `counter`: Uses counter-based suffixes like `User_1`, `User_2` for conflicts
415415

416416
What bundling does:

cmd/openapi/commands/openapi/bundle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ This operation is useful when you want to:
2828
2929
The bundle command supports two naming strategies:
3030
• counter: Uses counter-based suffixes like User_1, User_2 for conflicts
31-
• filepath: Uses file path-based naming like external_api_yaml~User
31+
• filepath: Uses file path-based naming like external_api_yaml__User
3232
3333
Examples:
3434
# Bundle to stdout (pipe-friendly)

cmd/openapi/commands/openapi/join.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ This command merges OpenAPI specifications by:
3030
3131
The join operation supports two conflict resolution strategies:
3232
• counter: Uses counter-based suffixes like User_1, User_2 for conflicts
33-
• filepath: Uses file path-based naming like second_yaml~User
33+
• filepath: Uses file path-based naming like second_yaml__User
3434
3535
Smart conflict handling:
3636
• Components: Identical components are merged, conflicts are renamed

jsonschema/oas3/jsonschema_validate_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,210 @@ import (
1111
"github.com/stretchr/testify/require"
1212
)
1313

14+
func TestValidate_TopLevel_Success(t *testing.T) {
15+
t.Parallel()
16+
17+
t.Run("nil schema returns nil", func(t *testing.T) {
18+
t.Parallel()
19+
20+
var schema *oas3.JSONSchema[oas3.Referenceable]
21+
errs := oas3.Validate(t.Context(), schema)
22+
require.Nil(t, errs, "nil schema should return nil errors")
23+
})
24+
25+
t.Run("bool schema returns nil", func(t *testing.T) {
26+
t.Parallel()
27+
28+
schema := oas3.NewJSONSchemaFromBool(true)
29+
errs := oas3.Validate(t.Context(), schema)
30+
require.Nil(t, errs, "bool schema should return nil errors")
31+
})
32+
33+
t.Run("bool false schema returns nil", func(t *testing.T) {
34+
t.Parallel()
35+
36+
schema := oas3.NewJSONSchemaFromBool(false)
37+
errs := oas3.Validate(t.Context(), schema)
38+
require.Nil(t, errs, "bool false schema should return nil errors")
39+
})
40+
41+
t.Run("valid schema returns nil errors", func(t *testing.T) {
42+
t.Parallel()
43+
44+
yml := `
45+
type: string
46+
title: Valid Schema
47+
`
48+
var schema oas3.JSONSchema[oas3.Referenceable]
49+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
50+
require.NoError(t, err)
51+
52+
errs := oas3.Validate(t.Context(), &schema)
53+
require.Empty(t, errs, "valid schema should return no errors")
54+
})
55+
}
56+
57+
func TestValidate_TopLevel_Error(t *testing.T) {
58+
t.Parallel()
59+
60+
t.Run("invalid schema returns errors", func(t *testing.T) {
61+
t.Parallel()
62+
63+
yml := `
64+
type: invalid_type
65+
`
66+
var schema oas3.JSONSchema[oas3.Referenceable]
67+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
68+
require.NoError(t, err)
69+
70+
errs := oas3.Validate(t.Context(), &schema)
71+
require.NotEmpty(t, errs, "invalid schema should return errors")
72+
})
73+
}
74+
75+
func TestSchema_Validate_OpenAPIVersions_Success(t *testing.T) {
76+
t.Parallel()
77+
78+
tests := []struct {
79+
name string
80+
version string
81+
yml string
82+
}{
83+
{
84+
name: "OpenAPI 3.0 version via context",
85+
version: "3.0.3",
86+
yml: `
87+
type: string
88+
`,
89+
},
90+
{
91+
name: "OpenAPI 3.1 version via context",
92+
version: "3.1.0",
93+
yml: `
94+
type: string
95+
`,
96+
},
97+
{
98+
name: "OpenAPI 3.2 version via context",
99+
version: "3.2.0",
100+
yml: `
101+
type: string
102+
`,
103+
},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
t.Parallel()
109+
110+
var schema oas3.Schema
111+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &schema)
112+
require.NoError(t, err)
113+
114+
dv := &oas3.ParentDocumentVersion{
115+
OpenAPI: &tt.version,
116+
}
117+
118+
errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
119+
require.Empty(t, errs, "valid schema should return no errors for version %s", tt.version)
120+
})
121+
}
122+
}
123+
124+
func TestSchema_Validate_SchemaField_Success(t *testing.T) {
125+
t.Parallel()
126+
127+
tests := []struct {
128+
name string
129+
yml string
130+
}{
131+
{
132+
name: "explicit 3.0 $schema field",
133+
yml: `
134+
$schema: "https://spec.openapis.org/oas/3.0/dialect/2024-10-18"
135+
type: string
136+
`,
137+
},
138+
{
139+
name: "explicit 3.1 $schema field",
140+
yml: `
141+
$schema: "https://spec.openapis.org/oas/3.1/meta/2024-11-10"
142+
type: string
143+
`,
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
t.Parallel()
150+
151+
var schema oas3.Schema
152+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &schema)
153+
require.NoError(t, err)
154+
155+
errs := schema.Validate(t.Context())
156+
require.Empty(t, errs, "valid schema should return no errors")
157+
})
158+
}
159+
}
160+
161+
func TestSchema_Validate_UnsupportedVersion_Defaults(t *testing.T) {
162+
t.Parallel()
163+
164+
t.Run("unsupported OpenAPI version defaults to 3.1", func(t *testing.T) {
165+
t.Parallel()
166+
167+
yml := `
168+
type: string
169+
`
170+
var schema oas3.Schema
171+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
172+
require.NoError(t, err)
173+
174+
version := "2.0.0"
175+
dv := &oas3.ParentDocumentVersion{
176+
OpenAPI: &version,
177+
}
178+
179+
errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
180+
require.Empty(t, errs, "unsupported version should default to 3.1 and validate successfully")
181+
})
182+
183+
t.Run("Arazzo version is unsupported and defaults to 3.1", func(t *testing.T) {
184+
t.Parallel()
185+
186+
yml := `
187+
type: string
188+
`
189+
var schema oas3.Schema
190+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
191+
require.NoError(t, err)
192+
193+
version := "1.0.0"
194+
dv := &oas3.ParentDocumentVersion{
195+
Arazzo: &version,
196+
}
197+
198+
errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
199+
require.Empty(t, errs, "Arazzo version should default to 3.1 and validate successfully")
200+
})
201+
202+
t.Run("unsupported $schema field defaults to 3.1", func(t *testing.T) {
203+
t.Parallel()
204+
205+
yml := `
206+
$schema: "https://json-schema.org/draft/2020-12/schema"
207+
type: string
208+
`
209+
var schema oas3.Schema
210+
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
211+
require.NoError(t, err)
212+
213+
errs := schema.Validate(t.Context())
214+
require.Empty(t, errs, "unsupported $schema should default to 3.1")
215+
})
216+
}
217+
14218
func TestJSONSchema_Validate_Error(t *testing.T) {
15219
t.Parallel()
16220

jsonschema/oas3/schema_validate_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,48 @@ required: ["name", "email"]
508508
`,
509509
wantErrs: []string{"[2:1] schema. additional properties '$ref' not allowed"},
510510
},
511+
{
512+
name: "empty component name in $ref",
513+
yml: `
514+
$ref: "#/components/schemas/"
515+
`,
516+
wantErrs: []string{"[2:1] invalid reference: component name cannot be empty"},
517+
},
518+
{
519+
name: "missing component name in $ref",
520+
yml: `
521+
$ref: "#/components/schemas"
522+
`,
523+
wantErrs: []string{"[2:1] invalid reference: component name cannot be empty"},
524+
},
525+
{
526+
name: "component name with invalid characters in $ref",
527+
yml: `
528+
$ref: "#/components/schemas/User@Schema"
529+
`,
530+
wantErrs: []string{`[2:1] invalid reference: component name "User@Schema" must match pattern ^[a-zA-Z0-9.\-_]+$`},
531+
},
532+
{
533+
name: "component name with space in $ref",
534+
yml: `
535+
$ref: "#/components/schemas/User Schema"
536+
`,
537+
wantErrs: []string{`[2:1] invalid reference: component name "User Schema" must match pattern ^[a-zA-Z0-9.\-_]+$`},
538+
},
539+
{
540+
name: "invalid JSON pointer - missing leading slash in $ref",
541+
yml: `
542+
$ref: "#components/schemas/User"
543+
`,
544+
wantErrs: []string{"[2:1] invalid reference JSON pointer: validation error -- jsonpointer must start with /: components/schemas/User"},
545+
},
546+
{
547+
name: "empty JSON pointer in $ref",
548+
yml: `
549+
$ref: "#"
550+
`,
551+
wantErrs: []string{"[2:1] invalid reference JSON pointer: empty"},
552+
},
511553
}
512554

513555
for _, tt := range tests {

jsonschema/oas3/validation.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err
8080

8181
dv := validation.GetContextObject[ParentDocumentVersion](o)
8282

83+
var errs []error
84+
85+
// Validate reference string if present
86+
if js.IsReference() {
87+
if err := js.GetRef().Validate(); err != nil {
88+
errs = append(errs, validation.NewValidationError(err, js.GetCore().Ref.GetKeyNodeOrRoot(js.GetRootNode())))
89+
}
90+
}
91+
8392
var schema string
8493
if js.Schema != nil {
8594
switch *js.Schema {
@@ -131,16 +140,13 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err
131140
}
132141
}
133142

134-
var errs []error
135143
err = oasSchemaValidator.Validate(jsAny)
136144
if err != nil {
137145
var validationErr *jsValidator.ValidationError
138146
if errors.As(err, &validationErr) {
139-
errs = getRootCauses(validationErr, *core)
147+
errs = append(errs, getRootCauses(validationErr, *core)...)
140148
} else {
141-
errs = []error{
142-
validation.NewValidationError(validation.NewValueValidationError("schema invalid: %s", err.Error()), core.RootNode),
143-
}
149+
errs = append(errs, validation.NewValidationError(validation.NewValueValidationError("schema invalid: %s", err.Error()), core.RootNode))
144150
}
145151
}
146152

openapi/bundle.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type BundleNamingStrategy int
2323
const (
2424
// BundleNamingCounter uses counter-based suffixes like User_1, User_2 for conflicts
2525
BundleNamingCounter BundleNamingStrategy = iota
26-
// BundleNamingFilePath uses file path-based naming like file_path_somefile_yaml~User
26+
// BundleNamingFilePath uses file path-based naming like file_path_somefile_yaml__User
2727
BundleNamingFilePath
2828
)
2929

@@ -85,7 +85,7 @@ type BundleOptions struct {
8585
// "content": {
8686
// "application/json": {
8787
// "schema": {
88-
// "$ref": "#/components/schemas/external_api_yaml~User"
88+
// "$ref": "#/components/schemas/external_api_yaml__User"
8989
// }
9090
// }
9191
// }
@@ -96,7 +96,7 @@ type BundleOptions struct {
9696
// },
9797
// "components": {
9898
// "schemas": {
99-
// "external_api_yaml~User": {
99+
// "external_api_yaml__User": {
100100
// "type": "object",
101101
// "properties": {
102102
// "id": {"type": "string"},
@@ -843,7 +843,7 @@ func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[s
843843
return generateFilePathBasedName(ref, usedNames, targetLocation)
844844
}
845845

846-
// generateFilePathBasedName creates names like "some_path_external_yaml~User" or "some_path_external_yaml" for top-level refs
846+
// generateFilePathBasedName creates names like "some_path_external_yaml__User" or "some_path_external_yaml" for top-level refs
847847
func generateFilePathBasedName(ref string, usedNames map[string]bool, targetLocation string) (string, error) {
848848
// Parse the reference to extract file path and fragment using references package
849849
reference := references.Reference(ref)
@@ -885,7 +885,7 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool, targetLoca
885885
// Clean up fragment (remove leading slash and convert path separators)
886886
cleanFragment := strings.TrimPrefix(fragment, "/")
887887
cleanFragment = strings.ReplaceAll(cleanFragment, "/", "_")
888-
componentName = safeFileName + "~" + cleanFragment
888+
componentName = safeFileName + "__" + cleanFragment
889889
}
890890

891891
// Ensure uniqueness

0 commit comments

Comments
 (0)