Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/openapi/commands/openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ openapi spec bundle --naming filepath ./spec.yaml ./bundled.yaml

**Naming Strategies:**

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

What bundling does:
Expand Down
2 changes: 1 addition & 1 deletion cmd/openapi/commands/openapi/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This operation is useful when you want to:

The bundle command supports two naming strategies:
• counter: Uses counter-based suffixes like User_1, User_2 for conflicts
• filepath: Uses file path-based naming like external_api_yaml~User
• filepath: Uses file path-based naming like external_api_yaml__User

Examples:
# Bundle to stdout (pipe-friendly)
Expand Down
2 changes: 1 addition & 1 deletion cmd/openapi/commands/openapi/join.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ This command merges OpenAPI specifications by:

The join operation supports two conflict resolution strategies:
• counter: Uses counter-based suffixes like User_1, User_2 for conflicts
• filepath: Uses file path-based naming like second_yaml~User
• filepath: Uses file path-based naming like second_yaml__User

Smart conflict handling:
• Components: Identical components are merged, conflicts are renamed
Expand Down
204 changes: 204 additions & 0 deletions jsonschema/oas3/jsonschema_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,210 @@ import (
"github.com/stretchr/testify/require"
)

func TestValidate_TopLevel_Success(t *testing.T) {
t.Parallel()

t.Run("nil schema returns nil", func(t *testing.T) {
t.Parallel()

var schema *oas3.JSONSchema[oas3.Referenceable]
errs := oas3.Validate(t.Context(), schema)
require.Nil(t, errs, "nil schema should return nil errors")
})

t.Run("bool schema returns nil", func(t *testing.T) {
t.Parallel()

schema := oas3.NewJSONSchemaFromBool(true)
errs := oas3.Validate(t.Context(), schema)
require.Nil(t, errs, "bool schema should return nil errors")
})

t.Run("bool false schema returns nil", func(t *testing.T) {
t.Parallel()

schema := oas3.NewJSONSchemaFromBool(false)
errs := oas3.Validate(t.Context(), schema)
require.Nil(t, errs, "bool false schema should return nil errors")
})

t.Run("valid schema returns nil errors", func(t *testing.T) {
t.Parallel()

yml := `
type: string
title: Valid Schema
`
var schema oas3.JSONSchema[oas3.Referenceable]
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
require.NoError(t, err)

errs := oas3.Validate(t.Context(), &schema)
require.Empty(t, errs, "valid schema should return no errors")
})
}

func TestValidate_TopLevel_Error(t *testing.T) {
t.Parallel()

t.Run("invalid schema returns errors", func(t *testing.T) {
t.Parallel()

yml := `
type: invalid_type
`
var schema oas3.JSONSchema[oas3.Referenceable]
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
require.NoError(t, err)

errs := oas3.Validate(t.Context(), &schema)
require.NotEmpty(t, errs, "invalid schema should return errors")
})
}

func TestSchema_Validate_OpenAPIVersions_Success(t *testing.T) {
t.Parallel()

tests := []struct {
name string
version string
yml string
}{
{
name: "OpenAPI 3.0 version via context",
version: "3.0.3",
yml: `
type: string
`,
},
{
name: "OpenAPI 3.1 version via context",
version: "3.1.0",
yml: `
type: string
`,
},
{
name: "OpenAPI 3.2 version via context",
version: "3.2.0",
yml: `
type: string
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var schema oas3.Schema
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &schema)
require.NoError(t, err)

dv := &oas3.ParentDocumentVersion{
OpenAPI: &tt.version,
}

errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
require.Empty(t, errs, "valid schema should return no errors for version %s", tt.version)
})
}
}

func TestSchema_Validate_SchemaField_Success(t *testing.T) {
t.Parallel()

tests := []struct {
name string
yml string
}{
{
name: "explicit 3.0 $schema field",
yml: `
$schema: "https://spec.openapis.org/oas/3.0/dialect/2024-10-18"
type: string
`,
},
{
name: "explicit 3.1 $schema field",
yml: `
$schema: "https://spec.openapis.org/oas/3.1/meta/2024-11-10"
type: string
`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var schema oas3.Schema
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &schema)
require.NoError(t, err)

errs := schema.Validate(t.Context())
require.Empty(t, errs, "valid schema should return no errors")
})
}
}

func TestSchema_Validate_UnsupportedVersion_Defaults(t *testing.T) {
t.Parallel()

t.Run("unsupported OpenAPI version defaults to 3.1", func(t *testing.T) {
t.Parallel()

yml := `
type: string
`
var schema oas3.Schema
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
require.NoError(t, err)

version := "2.0.0"
dv := &oas3.ParentDocumentVersion{
OpenAPI: &version,
}

errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
require.Empty(t, errs, "unsupported version should default to 3.1 and validate successfully")
})

t.Run("Arazzo version is unsupported and defaults to 3.1", func(t *testing.T) {
t.Parallel()

yml := `
type: string
`
var schema oas3.Schema
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
require.NoError(t, err)

version := "1.0.0"
dv := &oas3.ParentDocumentVersion{
Arazzo: &version,
}

errs := schema.Validate(t.Context(), validation.WithContextObject(dv))
require.Empty(t, errs, "Arazzo version should default to 3.1 and validate successfully")
})

t.Run("unsupported $schema field defaults to 3.1", func(t *testing.T) {
t.Parallel()

yml := `
$schema: "https://json-schema.org/draft/2020-12/schema"
type: string
`
var schema oas3.Schema
_, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &schema)
require.NoError(t, err)

errs := schema.Validate(t.Context())
require.Empty(t, errs, "unsupported $schema should default to 3.1")
})
}

func TestJSONSchema_Validate_Error(t *testing.T) {
t.Parallel()

Expand Down
42 changes: 42 additions & 0 deletions jsonschema/oas3/schema_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,48 @@ required: ["name", "email"]
`,
wantErrs: []string{"[2:1] schema. additional properties '$ref' not allowed"},
},
{
name: "empty component name in $ref",
yml: `
$ref: "#/components/schemas/"
`,
wantErrs: []string{"[2:1] invalid reference: component name cannot be empty"},
},
{
name: "missing component name in $ref",
yml: `
$ref: "#/components/schemas"
`,
wantErrs: []string{"[2:1] invalid reference: component name cannot be empty"},
},
{
name: "component name with invalid characters in $ref",
yml: `
$ref: "#/components/schemas/User@Schema"
`,
wantErrs: []string{`[2:1] invalid reference: component name "User@Schema" must match pattern ^[a-zA-Z0-9.\-_]+$`},
},
{
name: "component name with space in $ref",
yml: `
$ref: "#/components/schemas/User Schema"
`,
wantErrs: []string{`[2:1] invalid reference: component name "User Schema" must match pattern ^[a-zA-Z0-9.\-_]+$`},
},
{
name: "invalid JSON pointer - missing leading slash in $ref",
yml: `
$ref: "#components/schemas/User"
`,
wantErrs: []string{"[2:1] invalid reference JSON pointer: validation error -- jsonpointer must start with /: components/schemas/User"},
},
{
name: "empty JSON pointer in $ref",
yml: `
$ref: "#"
`,
wantErrs: []string{"[2:1] invalid reference JSON pointer: empty"},
},
}

for _, tt := range tests {
Expand Down
16 changes: 11 additions & 5 deletions jsonschema/oas3/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err

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

var errs []error

// Validate reference string if present
if js.IsReference() {
if err := js.GetRef().Validate(); err != nil {
errs = append(errs, validation.NewValidationError(err, js.GetCore().Ref.GetKeyNodeOrRoot(js.GetRootNode())))
}
}

var schema string
if js.Schema != nil {
switch *js.Schema {
Expand Down Expand Up @@ -131,16 +140,13 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err
}
}

var errs []error
err = oasSchemaValidator.Validate(jsAny)
if err != nil {
var validationErr *jsValidator.ValidationError
if errors.As(err, &validationErr) {
errs = getRootCauses(validationErr, *core)
errs = append(errs, getRootCauses(validationErr, *core)...)
} else {
errs = []error{
validation.NewValidationError(validation.NewValueValidationError("schema invalid: %s", err.Error()), core.RootNode),
}
errs = append(errs, validation.NewValidationError(validation.NewValueValidationError("schema invalid: %s", err.Error()), core.RootNode))
}
}

Expand Down
10 changes: 5 additions & 5 deletions openapi/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type BundleNamingStrategy int
const (
// BundleNamingCounter uses counter-based suffixes like User_1, User_2 for conflicts
BundleNamingCounter BundleNamingStrategy = iota
// BundleNamingFilePath uses file path-based naming like file_path_somefile_yaml~User
// BundleNamingFilePath uses file path-based naming like file_path_somefile_yaml__User
BundleNamingFilePath
)

Expand Down Expand Up @@ -85,7 +85,7 @@ type BundleOptions struct {
// "content": {
// "application/json": {
// "schema": {
// "$ref": "#/components/schemas/external_api_yaml~User"
// "$ref": "#/components/schemas/external_api_yaml__User"
// }
// }
// }
Expand All @@ -96,7 +96,7 @@ type BundleOptions struct {
// },
// "components": {
// "schemas": {
// "external_api_yaml~User": {
// "external_api_yaml__User": {
// "type": "object",
// "properties": {
// "id": {"type": "string"},
Expand Down Expand Up @@ -843,7 +843,7 @@ func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[s
return generateFilePathBasedName(ref, usedNames, targetLocation)
}

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

// Ensure uniqueness
Expand Down
Loading
Loading