From 6c5141601d178130c4ccf0a81101ed292530cab6 Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Thu, 25 Sep 2025 23:25:27 +0000 Subject: [PATCH 01/26] chore: Update golangci and fix issues it found --- .mise.toml | 5 +++-- validation/errors.go | 16 ++++++++-------- validation/validation_test.go | 8 ++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.mise.toml b/.mise.toml index 7f8c034..e787ecd 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,17 +1,18 @@ [tools] go = "1.24.3" -golangci-lint = "2.1.1" gotestsum = "latest" [tasks.setup-vscode-symlinks] description = "Create VSCode symlinks for tools not automatically handled by mise-vscode" run = [ "mkdir -p .vscode/mise-tools", - "ln -sf $(mise exec golangci-lint@2.1.1 -- which golangci-lint) .vscode/mise-tools/golangci-lint", + "ln -sf $(mise exec golangci-lint@2.5.0 -- which golangci-lint) .vscode/mise-tools/golangci-lint", ] [hooks] postinstall = [ + "git submodule update --init --recursive", + "mise exec go@1.24.3 -- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0", "mise run setup-vscode-symlinks", "go install go.uber.org/nilaway/cmd/nilaway@8ad05f0", ] diff --git a/validation/errors.go b/validation/errors.go index 68a630c..13a1d16 100644 --- a/validation/errors.go +++ b/validation/errors.go @@ -37,19 +37,19 @@ func (e Error) GetColumnNumber() int { return e.Node.Column } -type valueNodeGetter interface { +type ValueNodeGetter interface { GetValueNodeOrRoot(root *yaml.Node) *yaml.Node } -type sliceNodeGetter interface { +type SliceNodeGetter interface { GetSliceValueNodeOrRoot(index int, root *yaml.Node) *yaml.Node } -type mapKeyNodeGetter interface { +type MapKeyNodeGetter interface { GetMapKeyNodeOrRoot(key string, root *yaml.Node) *yaml.Node } -type mapValueNodeGetter interface { +type MapValueNodeGetter interface { GetMapValueNodeOrRoot(key string, root *yaml.Node) *yaml.Node } @@ -64,7 +64,7 @@ type CoreModeler interface { GetRootNode() *yaml.Node } -func NewValueError(err error, core CoreModeler, node valueNodeGetter) error { +func NewValueError(err error, core CoreModeler, node ValueNodeGetter) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -82,7 +82,7 @@ func NewValueError(err error, core CoreModeler, node valueNodeGetter) error { } } -func NewSliceError(err error, core CoreModeler, node sliceNodeGetter, index int) error { +func NewSliceError(err error, core CoreModeler, node SliceNodeGetter, index int) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -100,7 +100,7 @@ func NewSliceError(err error, core CoreModeler, node sliceNodeGetter, index int) } } -func NewMapKeyError(err error, core CoreModeler, node mapKeyNodeGetter, key string) error { +func NewMapKeyError(err error, core CoreModeler, node MapKeyNodeGetter, key string) error { rootNode := core.GetRootNode() if rootNode == nil { @@ -118,7 +118,7 @@ func NewMapKeyError(err error, core CoreModeler, node mapKeyNodeGetter, key stri } } -func NewMapValueError(err error, core CoreModeler, node mapValueNodeGetter, key string) error { +func NewMapValueError(err error, core CoreModeler, node MapValueNodeGetter, key string) error { rootNode := core.GetRootNode() if rootNode == nil { diff --git a/validation/validation_test.go b/validation/validation_test.go index 0b13241..23c1ae3 100644 --- a/validation/validation_test.go +++ b/validation/validation_test.go @@ -230,7 +230,7 @@ func TestNewValueError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter valueNodeGetter + nodeGetter ValueNodeGetter expectedNode *yaml.Node }{ { @@ -287,7 +287,7 @@ func TestNewSliceError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter sliceNodeGetter + nodeGetter SliceNodeGetter index int expectedNode *yaml.Node }{ @@ -337,7 +337,7 @@ func TestNewMapKeyError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter mapKeyNodeGetter + nodeGetter MapKeyNodeGetter key string expectedNode *yaml.Node }{ @@ -387,7 +387,7 @@ func TestNewMapValueError_Success(t *testing.T) { tests := []struct { name string core CoreModeler - nodeGetter mapValueNodeGetter + nodeGetter MapValueNodeGetter key string expectedNode *yaml.Node }{ From ee2f0a2eb11ce390103e8895ccfda0ad8b85e8fc Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Fri, 26 Sep 2025 06:11:33 +0000 Subject: [PATCH 02/26] feat: add support for 3.2.0 openapi spec --- arazzo/arazzo.go | 38 ++++-- arazzo/arazzo_test.go | 2 +- internal/utils/versions.go | 40 ------ internal/version/version.go | 103 ++++++++++++++ .../version_test.go} | 16 +-- openapi/bundle_test.go | 4 +- openapi/callbacks_validate_test.go | 9 +- openapi/clean_test.go | 4 +- openapi/cmd/upgrade.go | 10 +- openapi/components_validate_test.go | 11 +- openapi/core/factory_registration.go | 6 + openapi/core/paths.go | 13 ++ openapi/factory_registration.go | 3 + openapi/inline_test.go | 2 +- openapi/openapi.go | 59 ++++---- openapi/openapi_examples_test.go | 12 +- openapi/openapi_unmarshal_test.go | 3 +- openapi/openapi_validate_test.go | 3 +- openapi/operation_validate_test.go | 3 +- openapi/optimize_test.go | 2 +- openapi/paths.go | 113 +++++++++++++-- openapi/paths_validate_test.go | 115 ++++++++++++++-- openapi/testdata/bootstrap_expected.yaml | 2 +- openapi/testdata/test.openapi.yaml | 13 +- openapi/testdata/upgrade/3_1_0.yaml | 2 +- openapi/testdata/upgrade/3_2_0.yaml | 39 ++++++ .../upgrade/expected_3_0_0_upgraded.yaml | 2 +- .../upgrade/expected_3_0_2_upgraded.json | 2 +- .../upgrade/expected_3_0_2_upgraded.yaml | 84 ------------ .../upgrade/expected_3_0_3_upgraded.yaml | 2 +- .../upgrade/expected_3_1_0_upgraded.yaml | 4 +- .../expected_minimal_nullable_upgraded.json | 2 +- openapi/upgrade.go | 129 +++++++++++++----- openapi/upgrade_test.go | 100 ++++++++------ 34 files changed, 656 insertions(+), 296 deletions(-) delete mode 100644 internal/utils/versions.go create mode 100644 internal/version/version.go rename internal/{utils/versions_test.go => version/version_test.go} (86%) create mode 100644 openapi/testdata/upgrade/3_2_0.yaml delete mode 100644 openapi/testdata/upgrade/expected_3_0_2_upgraded.yaml diff --git a/arazzo/arazzo.go b/arazzo/arazzo.go index a81aa7c..6ef94a7 100644 --- a/arazzo/arazzo.go +++ b/arazzo/arazzo.go @@ -11,7 +11,7 @@ import ( "github.com/speakeasy-api/openapi/arazzo/core" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/internal/interfaces" - "github.com/speakeasy-api/openapi/internal/utils" + "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/pointer" @@ -20,12 +20,31 @@ import ( // Version is the version of the Arazzo Specification that this package conforms to. const ( - Version = "1.0.1" - VersionMajor = 1 - VersionMinor = 0 - VersionPatch = 1 + Version = "1.0.1" ) +func MinimumSupportedVersion() version.Version { + v, err := version.ParseVersion("1.0.0") + if err != nil { + panic("failed to parse minimum supported Arazzo version: " + err.Error()) + } + if v == nil { + panic("minimum supported Arazzo version is nil") + } + return *v +} + +func MaximumSupportedVersion() version.Version { + v, err := version.ParseVersion(Version) + if err != nil { + panic("failed to parse maximum supported Arazzo version: " + err.Error()) + } + if v == nil { + panic("maximum supported Arazzo version is nil") + } + return *v +} + // Arazzo is the root object for an Arazzo document. type Arazzo struct { marshaller.Model[core.Arazzo] @@ -105,13 +124,14 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro core := a.GetCore() errs := []error{} - arazzoMajor, arazzoMinor, arazzoPatch, err := utils.ParseVersion(a.Arazzo) + arazzoVersion, err := version.ParseVersion(a.Arazzo) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo)) } - - if arazzoMajor != VersionMajor || arazzoMinor != VersionMinor || arazzoPatch > VersionPatch { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version only %s and below is supported", Version), core, core.Arazzo)) + if arazzoVersion != nil { + if arazzoVersion.GreaterThan(MaximumSupportedVersion()) { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion(), MaximumSupportedVersion()), core, core.Arazzo)) + } } errs = append(errs, a.Info.Validate(ctx, opts...)...) diff --git a/arazzo/arazzo_test.go b/arazzo/arazzo_test.go index 401569d..0fa2a8b 100644 --- a/arazzo/arazzo_test.go +++ b/arazzo/arazzo_test.go @@ -304,7 +304,7 @@ sourceDescriptions: underlyingError error }{ {line: 1, column: 1, underlyingError: validation.NewMissingFieldError("arazzo field workflows is missing")}, - {line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo field version only 1.0.1 and below is supported")}, + {line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo field version only Arazzo versions between 1.0.0 and 1.0.1 are supported")}, {line: 4, column: 3, underlyingError: validation.NewMissingFieldError("info field version is missing")}, {line: 6, column: 5, underlyingError: validation.NewMissingFieldError("sourceDescription field url is missing")}, {line: 7, column: 11, underlyingError: validation.NewValueValidationError("sourceDescription field type must be one of [openapi, arazzo]")}, diff --git a/internal/utils/versions.go b/internal/utils/versions.go deleted file mode 100644 index 300b1ca..0000000 --- a/internal/utils/versions.go +++ /dev/null @@ -1,40 +0,0 @@ -package utils - -import ( - "fmt" - "strconv" - "strings" -) - -func ParseVersion(version string) (int, int, int, error) { - parts := strings.Split(version, ".") - if len(parts) != 3 { - return 0, 0, 0, fmt.Errorf("invalid version %s", version) - } - - major, err := strconv.Atoi(parts[0]) - if err != nil { - return 0, 0, 0, fmt.Errorf("invalid major version %s: %w", parts[0], err) - } - if major < 0 { - return 0, 0, 0, fmt.Errorf("invalid major version %s: cannot be negative", parts[0]) - } - - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return 0, 0, 0, fmt.Errorf("invalid minor version %s: %w", parts[1], err) - } - if minor < 0 { - return 0, 0, 0, fmt.Errorf("invalid minor version %s: cannot be negative", parts[1]) - } - - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return 0, 0, 0, fmt.Errorf("invalid patch version %s: %w", parts[2], err) - } - if patch < 0 { - return 0, 0, 0, fmt.Errorf("invalid patch version %s: cannot be negative", parts[2]) - } - - return major, minor, patch, nil -} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..4c83a26 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,103 @@ +package version + +import ( + "fmt" + "strconv" + "strings" +) + +type Version struct { + Major int + Minor int + Patch int +} + +func New(major, minor, patch int) *Version { + return &Version{ + Major: major, + Minor: minor, + Patch: patch, + } +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func (v Version) Equal(other Version) bool { + return v.Major == other.Major && v.Minor == other.Minor && v.Patch == other.Patch +} + +func (v Version) GreaterThan(other Version) bool { + if v.Major > other.Major { + return true + } else if v.Major < other.Major { + return false + } + + if v.Minor > other.Minor { + return true + } else if v.Minor < other.Minor { + return false + } + + return v.Patch > other.Patch +} + +func (v Version) LessThan(other Version) bool { + return !v.Equal(other) && !v.GreaterThan(other) +} + +func ParseVersion(version string) (*Version, error) { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid version %s", version) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid major version %s: %w", parts[0], err) + } + if major < 0 { + return nil, fmt.Errorf("invalid major version %s: cannot be negative", parts[0]) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid minor version %s: %w", parts[1], err) + } + if minor < 0 { + return nil, fmt.Errorf("invalid minor version %s: cannot be negative", parts[1]) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid patch version %s: %w", parts[2], err) + } + if patch < 0 { + return nil, fmt.Errorf("invalid patch version %s: cannot be negative", parts[2]) + } + + return New(major, minor, patch), nil +} + +func IsVersionGreaterOrEqual(a, b string) (bool, error) { + versionA, err := ParseVersion(a) + if err != nil { + return false, fmt.Errorf("invalid version %s: %w", a, err) + } + + versionB, err := ParseVersion(b) + if err != nil { + return false, fmt.Errorf("invalid version %s: %w", b, err) + } + return versionA.Equal(*versionB) || versionA.GreaterThan(*versionB), nil +} + +func IsVersionLessThan(a, b string) (bool, error) { + greaterOrEqual, err := IsVersionGreaterOrEqual(a, b) + if err != nil { + return false, err + } + return !greaterOrEqual, nil +} diff --git a/internal/utils/versions_test.go b/internal/version/version_test.go similarity index 86% rename from internal/utils/versions_test.go rename to internal/version/version_test.go index 505f5df..8a41682 100644 --- a/internal/utils/versions_test.go +++ b/internal/version/version_test.go @@ -1,4 +1,4 @@ -package utils +package version import ( "testing" @@ -52,11 +52,11 @@ func Test_ParseVersion_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - major, minor, patch, err := ParseVersion(tt.args.version) + version, err := ParseVersion(tt.args.version) require.NoError(t, err) - assert.Equal(t, tt.expectedMajor, major) - assert.Equal(t, tt.expectedMinor, minor) - assert.Equal(t, tt.expectedPatch, patch) + assert.Equal(t, tt.expectedMajor, version.Major) + assert.Equal(t, tt.expectedMinor, version.Minor) + assert.Equal(t, tt.expectedPatch, version.Patch) }) } } @@ -131,11 +131,9 @@ func Test_ParseVersion_Error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - major, minor, patch, err := ParseVersion(tt.args.version) + version, err := ParseVersion(tt.args.version) require.Error(t, err) - assert.Equal(t, 0, major) - assert.Equal(t, 0, minor) - assert.Equal(t, 0, patch) + assert.Nil(t, version) }) } } diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index 478985f..eb4d50d 100644 --- a/openapi/bundle_test.go +++ b/openapi/bundle_test.go @@ -103,7 +103,7 @@ func TestBundle_EmptyDocument(t *testing.T) { // Test with minimal document doc := &openapi.OpenAPI{ - OpenAPI: "3.1.0", + OpenAPI: openapi.Version, Info: openapi.Info{ Title: "Empty API", Version: "1.0.0", @@ -122,7 +122,7 @@ func TestBundle_EmptyDocument(t *testing.T) { require.NoError(t, err) // Document should remain unchanged - assert.Equal(t, "3.1.0", doc.OpenAPI) + assert.Equal(t, openapi.Version, doc.OpenAPI) assert.Equal(t, "Empty API", doc.Info.Title) assert.Equal(t, "1.0.0", doc.Info.Version) diff --git a/openapi/callbacks_validate_test.go b/openapi/callbacks_validate_test.go index c77b264..ad0dd21 100644 --- a/openapi/callbacks_validate_test.go +++ b/openapi/callbacks_validate_test.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/validation" "github.com/stretchr/testify/require" ) @@ -92,7 +93,7 @@ x-timeout: 30 require.NoError(t, err) require.Empty(t, validationErrs) - errs := callback.Validate(t.Context()) + errs := callback.Validate(t.Context(), validation.WithContextObject(openapi.NewOpenAPI())) require.Empty(t, errs, "Expected no validation errors") }) } @@ -267,7 +268,7 @@ func TestCallback_Validate_Error(t *testing.T) { var allErrors []error allErrors = append(allErrors, validationErrs...) - validateErrs := callback.Validate(t.Context()) + validateErrs := callback.Validate(t.Context(), validation.WithContextObject(openapi.NewOpenAPI())) allErrors = append(allErrors, validateErrs...) require.NotEmpty(t, allErrors, "expected validation errors") @@ -411,7 +412,7 @@ func TestCallback_Validate_ComplexExpressions(t *testing.T) { require.NoError(t, err) require.Empty(t, validationErrs) - errs := callback.Validate(t.Context()) + errs := callback.Validate(t.Context(), validation.WithContextObject(openapi.NewOpenAPI())) require.Empty(t, errs, "Expected no validation errors") }) } @@ -508,7 +509,7 @@ x-rate-limit: 100 require.NoError(t, err) require.Empty(t, validationErrs) - errs := callback.Validate(t.Context()) + errs := callback.Validate(t.Context(), validation.WithContextObject(openapi.NewOpenAPI())) require.Empty(t, errs, "Expected no validation errors") }) } diff --git a/openapi/clean_test.go b/openapi/clean_test.go index 10070fc..67c8e56 100644 --- a/openapi/clean_test.go +++ b/openapi/clean_test.go @@ -85,7 +85,7 @@ func TestClean_EmptyDocument_Success(t *testing.T) { // Test with minimal document (no components) doc := &openapi.OpenAPI{ - OpenAPI: "3.1.0", + OpenAPI: openapi.Version, Info: openapi.Info{ Title: "Empty API", Version: "1.0.0", @@ -103,7 +103,7 @@ func TestClean_NoComponents_Success(t *testing.T) { // Test with document that has no components section doc := &openapi.OpenAPI{ - OpenAPI: "3.1.0", + OpenAPI: openapi.Version, Info: openapi.Info{ Title: "API without components", Version: "1.0.0", diff --git a/openapi/cmd/upgrade.go b/openapi/cmd/upgrade.go index fb30251..01e88a0 100644 --- a/openapi/cmd/upgrade.go +++ b/openapi/cmd/upgrade.go @@ -59,13 +59,13 @@ func runUpgrade(cmd *cobra.Command, args []string) { os.Exit(1) } - if err := upgradeOpenAPI(ctx, processor, minorOnly); err != nil { + if err := upgradeOpenAPI(ctx, processor, !minorOnly); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } -func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, minorOnly bool) error { +func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, upgradeSameMinorVersion bool) error { // Load the OpenAPI document doc, validationErrors, err := processor.LoadDocument(ctx) if err != nil { @@ -80,11 +80,11 @@ func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, minorOnly // Prepare upgrade options var opts []openapi.Option[openapi.UpgradeOptions] - if !minorOnly { + if upgradeSameMinorVersion { // By default, upgrade all versions including patch versions (3.1.x to 3.1.1) - opts = append(opts, openapi.WithUpgradeSamePatchVersion()) + opts = append(opts, openapi.WithUpgradeSameMinorVersion()) } - // When minorOnly is true, only 3.0.x versions will be upgraded to 3.1.1 + // When skipPatchOnly is true, only 3.0.x versions will be upgraded to 3.1.1 // 3.1.x versions will be skipped unless they need minor version upgrade // Perform the upgrade diff --git a/openapi/components_validate_test.go b/openapi/components_validate_test.go index ad61a3b..d4efb19 100644 --- a/openapi/components_validate_test.go +++ b/openapi/components_validate_test.go @@ -220,13 +220,10 @@ securitySchemes: require.NoError(t, err) require.Empty(t, validationErrs) - // Create a minimal OpenAPI document for operationId validation - var opts []validation.Option + openAPIDoc := openapi.NewOpenAPI() if tt.name == "valid_components_with_links" { // Create OpenAPI document with the required operationId for link validation - openAPIDoc := &openapi.OpenAPI{ - Paths: openapi.NewPaths(), - } + openAPIDoc.Paths = openapi.NewPaths() // Add path with operation that matches the operationId in the test pathItem := openapi.NewPathItem() @@ -235,11 +232,9 @@ securitySchemes: } pathItem.Set("get", operation) openAPIDoc.Paths.Set("/users/{username}/repos", &openapi.ReferencedPathItem{Object: pathItem}) - - opts = append(opts, validation.WithContextObject(openAPIDoc)) } - errs := components.Validate(t.Context(), opts...) + errs := components.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) require.Empty(t, errs, "Expected no validation errors") }) } diff --git a/openapi/core/factory_registration.go b/openapi/core/factory_registration.go index 42cb06d..ee441ab 100644 --- a/openapi/core/factory_registration.go +++ b/openapi/core/factory_registration.go @@ -117,4 +117,10 @@ func init() { marshaller.RegisterType(func() *marshaller.Node[[]marshaller.Node[string]] { return &marshaller.Node[[]marshaller.Node[string]]{} }) + marshaller.RegisterType(func() *marshaller.Node[*Operation] { + return &marshaller.Node[*Operation]{} + }) + marshaller.RegisterType(func() *sequencedmap.Map[string, marshaller.Node[*Operation]] { + return &sequencedmap.Map[string, marshaller.Node[*Operation]]{} + }) } diff --git a/openapi/core/paths.go b/openapi/core/paths.go index 50f9185..4789855 100644 --- a/openapi/core/paths.go +++ b/openapi/core/paths.go @@ -1,9 +1,13 @@ package core import ( + "context" + "github.com/speakeasy-api/openapi/extensions/core" "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/speakeasy-api/openapi/yml" + "gopkg.in/yaml.v3" ) type Paths struct { @@ -29,6 +33,8 @@ type PathItem struct { Servers marshaller.Node[[]*Server] `key:"servers"` Parameters marshaller.Node[[]*Reference[*Parameter]] `key:"parameters"` + AdditionalOperations marshaller.Node[*sequencedmap.Map[string, marshaller.Node[*Operation]]] `key:"additionalOperations"` + Extensions core.Extensions `key:"extensions"` } @@ -37,3 +43,10 @@ func NewPathItem() *PathItem { Map: *sequencedmap.New[string, *Operation](), } } +func (n PathItem) GetMapKeyNodeOrRoot(key string, rootNode *yaml.Node) *yaml.Node { + keyNode, _, found := yml.GetMapElementNodes(context.Background(), n.RootNode, key) + if found { + return keyNode + } + return rootNode +} diff --git a/openapi/factory_registration.go b/openapi/factory_registration.go index c23304d..73c6dac 100644 --- a/openapi/factory_registration.go +++ b/openapi/factory_registration.go @@ -47,6 +47,9 @@ func init() { marshaller.RegisterType(func() *Reference[PathItem, *PathItem, *core.PathItem] { return &Reference[PathItem, *PathItem, *core.PathItem]{} }) + marshaller.RegisterType(func() *sequencedmap.Map[string, *Operation] { + return &sequencedmap.Map[string, *Operation]{} + }) marshaller.RegisterType(func() *Reference[Example, *Example, *core.Example] { return &Reference[Example, *Example, *core.Example]{} }) diff --git a/openapi/inline_test.go b/openapi/inline_test.go index e69857f..5f6bdaf 100644 --- a/openapi/inline_test.go +++ b/openapi/inline_test.go @@ -62,7 +62,7 @@ func TestInline_EmptyDocument(t *testing.T) { // Test with minimal document doc := &openapi.OpenAPI{ - OpenAPI: "3.1.0", + OpenAPI: openapi.Version, Info: openapi.Info{ Title: "Empty API", Version: "1.0.0", diff --git a/openapi/openapi.go b/openapi/openapi.go index ef4d8e6..0ce7c6a 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -3,11 +3,10 @@ package openapi import ( "context" "net/url" - "slices" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/internal/interfaces" - "github.com/speakeasy-api/openapi/internal/utils" + "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi/core" @@ -18,15 +17,31 @@ import ( // Version is the version of the OpenAPI Specification that this package conforms to. const ( - Version = "3.1.1" - VersionMajor = 3 - VersionMinor = 1 - VersionPatch = 1 - - Version30XMaxPatch = 4 - Version31XMaxPatch = 1 + Version = "3.2.0" ) +func MinimumSupportedVersion() version.Version { + v, err := version.ParseVersion("3.0.0") + if err != nil { + panic("failed to parse minimum supported OpenAPI version: " + err.Error()) + } + if v == nil { + panic("minimum supported OpenAPI version is nil") + } + return *v +} + +func MaximumSupportedVersion() version.Version { + v, err := version.ParseVersion(Version) + if err != nil { + panic("failed to parse maximum supported OpenAPI version: " + err.Error()) + } + if v == nil { + panic("maximum supported OpenAPI version is nil") + } + return *v +} + // OpenAPI represents an OpenAPI document compatible with the OpenAPI Specification 3.0.X and 3.1.X. // Where the specification differs between versions the type OpenAPI struct { @@ -61,6 +76,13 @@ type OpenAPI struct { var _ interfaces.Model[core.OpenAPI] = (*OpenAPI)(nil) +// NewOpenAPI creates a new OpenAPI object with version set +func NewOpenAPI() *OpenAPI { + return &OpenAPI{ + OpenAPI: Version, + } +} + // GetOpenAPI returns the value of the OpenAPI field. Returns empty string if not set. func (o *OpenAPI) GetOpenAPI() string { if o == nil { @@ -161,23 +183,14 @@ func (o *OpenAPI) Validate(ctx context.Context, opts ...validation.Option) []err opts = append(opts, validation.WithContextObject(o)) opts = append(opts, validation.WithContextObject(&oas3.ParentDocumentVersion{OpenAPI: pointer.From(o.OpenAPI)})) - openAPIMajor, openAPIMinor, openAPIPatch, err := utils.ParseVersion(o.OpenAPI) + docVersion, err := version.ParseVersion(o.OpenAPI) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi field openapi invalid OpenAPI version %s: %s", o.OpenAPI, err.Error()), core, core.OpenAPI)) } - - minorVersionSupported := slices.Contains([]int{0, 1}, openAPIMinor) - patchVersionSupported := false - - switch openAPIMinor { - case 0: - patchVersionSupported = openAPIPatch <= Version30XMaxPatch - case 1: - patchVersionSupported = openAPIPatch <= Version31XMaxPatch - } - - if openAPIMajor != VersionMajor || !minorVersionSupported || !patchVersionSupported { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi field openapi only OpenAPI version %s and below is supported", Version), core, core.OpenAPI)) + if docVersion != nil { + if docVersion.LessThan(MinimumSupportedVersion()) || docVersion.GreaterThan(MaximumSupportedVersion()) { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi field only OpenAPI versions between %s and %s are supported", MinimumSupportedVersion(), MaximumSupportedVersion()), core, core.OpenAPI)) + } } errs = append(errs, o.Info.Validate(ctx, opts...)...) diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index 062adea..eee6272 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -46,7 +46,7 @@ func Example_reading() { fmt.Printf("OpenAPI Version: %s\n", doc.OpenAPI) fmt.Printf("API Title: %s\n", doc.Info.Title) fmt.Printf("API Version: %s\n", doc.Info.Version) - // Output: OpenAPI Version: 3.1.1 + // Output: OpenAPI Version: 3.2.0 // API Title: Test OpenAPI Document // API Version: 1.0.0 } @@ -131,7 +131,7 @@ func Example_marshaling() { } fmt.Printf("%s", buf.String()) - // Output: openapi: 3.1.1 + // Output: openapi: 3.2.0 // info: // title: Example API // version: 1.0.0 @@ -588,7 +588,7 @@ func Example_creating() { } fmt.Printf("%s", buf.String()) - // Output: openapi: 3.1.1 + // Output: openapi: 3.2.0 // info: // title: My API // version: 1.0.0 @@ -712,7 +712,7 @@ func Example_workingWithComponents() { // Output: Found schema component: User // Type: object // Document with components: - // openapi: 3.1.1 + // openapi: 3.2.0 // info: // title: API with Components // version: 1.0.0 @@ -956,10 +956,10 @@ components: panic(err) } fmt.Printf("%s", buf.String()) - // Output: Upgraded OpenAPI Version: 3.1.1 + // Output: Upgraded OpenAPI Version: 3.2.0 // // After upgrade: - // openapi: 3.1.1 + // openapi: 3.2.0 // info: // title: Legacy API // version: 1.0.0 diff --git a/openapi/openapi_unmarshal_test.go b/openapi/openapi_unmarshal_test.go index 0205c55..ea973f3 100644 --- a/openapi/openapi_unmarshal_test.go +++ b/openapi/openapi_unmarshal_test.go @@ -1,6 +1,7 @@ package openapi_test import ( + "fmt" "strings" "testing" @@ -133,7 +134,7 @@ info: title: Test API version: 1.0.0 paths: {}`, - expectedError: "only OpenAPI version 3.1.1 and below is supported", + expectedError: fmt.Sprintf("openapi field only OpenAPI versions between %s and %s are supported", openapi.MinimumSupportedVersion(), openapi.MaximumSupportedVersion()), }, } diff --git a/openapi/openapi_validate_test.go b/openapi/openapi_validate_test.go index cbcf5b9..ad35cfb 100644 --- a/openapi/openapi_validate_test.go +++ b/openapi/openapi_validate_test.go @@ -2,6 +2,7 @@ package openapi_test import ( "bytes" + "fmt" "strings" "testing" @@ -185,7 +186,7 @@ info: version: 1.0.0 paths: {} `, - wantErrs: []string{"only OpenAPI version 3.1.1 and below is supported"}, + wantErrs: []string{fmt.Sprintf("openapi field only OpenAPI versions between %s and %s are supported", openapi.MinimumSupportedVersion(), openapi.MaximumSupportedVersion())}, }, { name: "invalid_info_missing_title", diff --git a/openapi/operation_validate_test.go b/openapi/operation_validate_test.go index 9fa2402..e1f9a52 100644 --- a/openapi/operation_validate_test.go +++ b/openapi/operation_validate_test.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/validation" "github.com/stretchr/testify/require" ) @@ -113,7 +114,7 @@ responses: require.NoError(t, err) require.Empty(t, validationErrs) - errs := operation.Validate(t.Context()) + errs := operation.Validate(t.Context(), validation.WithContextObject(openapi.NewOpenAPI())) require.Empty(t, errs, "expected no validation errors") require.True(t, operation.Valid, "expected operation to be valid") }) diff --git a/openapi/optimize_test.go b/openapi/optimize_test.go index bc54341..0c12890 100644 --- a/openapi/optimize_test.go +++ b/openapi/optimize_test.go @@ -54,7 +54,7 @@ func TestOptimize_EmptyDocument_Success(t *testing.T) { // Test with minimal document (no components) doc := &openapi.OpenAPI{ - OpenAPI: "3.1.0", + OpenAPI: openapi.Version, Info: openapi.Info{ Title: "Empty API", Version: "1.0.0", diff --git a/openapi/paths.go b/openapi/paths.go index da7a7c4..88d2c25 100644 --- a/openapi/paths.go +++ b/openapi/paths.go @@ -2,10 +2,12 @@ package openapi import ( "context" + "slices" "strings" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/internal/interfaces" + "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi/core" "github.com/speakeasy-api/openapi/sequencedmap" @@ -72,12 +74,30 @@ const ( HTTPMethodPatch HTTPMethod = "patch" // HTTPMethodTrace represents the HTTP TRACE method. HTTPMethodTrace HTTPMethod = "trace" + // HTTPMethodQuery represents the HTTP QUERY method. + HTTPMethodQuery HTTPMethod = "query" ) +var standardHttpMethods = []HTTPMethod{ + HTTPMethodGet, + HTTPMethodPut, + HTTPMethodPost, + HTTPMethodDelete, + HTTPMethodOptions, + HTTPMethodHead, + HTTPMethodPatch, + HTTPMethodTrace, + HTTPMethodQuery, +} + func (m HTTPMethod) Is(method string) bool { return strings.EqualFold(string(m), method) } +func IsStandardMethod(s string) bool { + return slices.Contains(standardHttpMethods, HTTPMethod(s)) +} + // PathItem represents the available operations for a specific endpoint path. // PathItem embeds sequencedmap.Map[HTTPMethod, *Operation] so all map operations are supported for working with HTTP methods. type PathItem struct { @@ -94,6 +114,9 @@ type PathItem struct { // Parameters are a list of parameters that can be used by the operations represented by this path. Parameters []*ReferencedParameter + // AdditionalOperations contains HTTP operations not covered by standard fixed fields (GET, POST, etc.). + AdditionalOperations *sequencedmap.Map[string, *Operation] + // Extensions provides a list of extensions to the PathItem object. Extensions *extensions.Extensions } @@ -121,46 +144,86 @@ func (p *PathItem) GetOperation(method HTTPMethod) *Operation { return op } -// Get returns the GET operation for this path item. +// Get returns the GET operation for this path item. Returns nil if not set. func (p *PathItem) Get() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodGet) } -// Put returns the PUT operation for this path item. +// Put returns the PUT operation for this path item. Returns nil if not set. func (p *PathItem) Put() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodPut) } -// Post returns the POST operation for this path item. +// Post returns the POST operation for this path item. Returns nil if not set. func (p *PathItem) Post() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodPost) } -// Delete returns the DELETE operation for this path item. +// Delete returns the DELETE operation for this path item. Returns nil if not set. func (p *PathItem) Delete() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodDelete) } -// Options returns the OPTIONS operation for this path item. +// Options returns the OPTIONS operation for this path item. Returns nil if not set. func (p *PathItem) Options() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodOptions) } -// Head returns the HEAD operation for this path item. +// Head returns the HEAD operation for this path item. Returns nil if not set. func (p *PathItem) Head() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodHead) } -// Patch returns the PATCH operation for this path item. +// Patch returns the PATCH operation for this path item. Returns nil if not set. func (p *PathItem) Patch() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodPatch) } -// Trace returns the TRACE operation for this path item. +// Trace returns the TRACE operation for this path item. Returns nil if not set. func (p *PathItem) Trace() *Operation { + if p == nil { + return nil + } return p.GetOperation(HTTPMethodTrace) } +// Query returns the QUERY operation for this path item. Returns nil if not set. +func (p *PathItem) Query() *Operation { + if p == nil { + return nil + } + return p.GetOperation(HTTPMethodQuery) +} + +// GetAdditionalOperations returns the value of the AdditionalOperations field. Returns nil if not set. +func (p *PathItem) GetAdditionalOperations() *sequencedmap.Map[string, *Operation] { + if p == nil { + return nil + } + return p.AdditionalOperations +} + // GetSummary returns the value of the Summary field. Returns empty string if not set. func (p *PathItem) GetSummary() string { if p == nil || p.Summary == nil { @@ -206,6 +269,13 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er core := p.GetCore() errs := []error{} + o := validation.NewOptions(opts...) + + openapi := validation.GetContextObject[OpenAPI](o) + if openapi == nil { + panic("OpenAPI is required") + } + for _, op := range p.All() { errs = append(errs, op.Validate(ctx, opts...)...) } @@ -218,6 +288,33 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, parameter.Validate(ctx, opts...)...) } + supportsAdditionalOperations, err := version.IsVersionGreaterOrEqual(openapi.OpenAPI, "3.2.0") + switch { + case err != nil: + errs = append(errs, err) + + case supportsAdditionalOperations: + if p.AdditionalOperations != nil { + for methodName, op := range p.AdditionalOperations.All() { + errs = append(errs, op.Validate(ctx, opts...)...) + if IsStandardMethod(strings.ToLower(methodName)) { + errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("method [%s] is a standard HTTP method and must be defined in its own field", methodName), core, core.AdditionalOperations, methodName)) + } + } + } + + for methodName := range p.Keys() { + if !IsStandardMethod(strings.ToLower(string(methodName))) { + errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("method [%s] is not a standard HTTP method and must be defined in the additionalOperations field", methodName), core, core, string(methodName))) + } + } + + case !supportsAdditionalOperations: + if core.AdditionalOperations.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("additionalOperations is not supported in OpenAPI version %s", openapi.OpenAPI), core, core.AdditionalOperations)) + } + } + p.Valid = len(errs) == 0 && core.GetValid() return errs diff --git a/openapi/paths_validate_test.go b/openapi/paths_validate_test.go index 90a9d50..774e334 100644 --- a/openapi/paths_validate_test.go +++ b/openapi/paths_validate_test.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/validation" "github.com/stretchr/testify/require" ) @@ -14,8 +15,9 @@ func TestPaths_Validate_Success(t *testing.T) { t.Parallel() tests := []struct { - name string - yml string + name string + yml string + openApiVersion string }{ { name: "valid_empty_paths", @@ -85,7 +87,12 @@ x-another: 123 require.NoError(t, err) require.Empty(t, validationErrs) - errs := paths.Validate(t.Context()) + openAPIDoc := openapi.NewOpenAPI() + if tt.openApiVersion != "" { + openAPIDoc.OpenAPI = tt.openApiVersion + } + + errs := paths.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) require.Empty(t, errs, "Expected no validation errors") }) } @@ -95,8 +102,9 @@ func TestPathItem_Validate_Success(t *testing.T) { t.Parallel() tests := []struct { - name string - yml string + name string + yml string + openApiVersion string }{ { name: "valid_get_operation", @@ -242,6 +250,22 @@ trace: description: Trace response `, }, + { + name: "valid_additional_operation", + yml: ` +additionalOperations: + COPY: + summary: Copy operation + description: Custom COPY operation for the test endpoint + operationId: copyTest + tags: + - test + responses: + 201: + description: Created + x-test: some-value + `, + }, } for _, tt := range tests { @@ -254,7 +278,12 @@ trace: require.NoError(t, err) require.Empty(t, validationErrs) - errs := pathItem.Validate(t.Context()) + openAPIDoc := openapi.NewOpenAPI() + if tt.openApiVersion != "" { + openAPIDoc.OpenAPI = tt.openApiVersion + } + + errs := pathItem.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) require.Empty(t, errs, "Expected no validation errors") }) } @@ -264,9 +293,10 @@ func TestPathItem_Validate_Error(t *testing.T) { t.Parallel() tests := []struct { - name string - yml string - wantErrs []string + name string + yml string + openApiVersion string + wantErrs []string }{ { name: "invalid_server", @@ -296,6 +326,66 @@ get: `, wantErrs: []string{"field name is missing"}, }, + { + name: "unexpected_additional_operations", + openApiVersion: "3.1.2", + yml: ` +additionalOperations: + COPY: + summary: Copy operation + description: Custom COPY operation for the test endpoint + operationId: copyTest + tags: + - test + responses: + 201: + description: Created + x-test: some-value + `, + wantErrs: []string{"additionalOperations is not supported in OpenAPI version 3.1.2"}, + }, + { + name: "standard_method_in_additional_operations", + openApiVersion: "3.2.0", + yml: ` +additionalOperations: + GET: + summary: Get operation + description: Custom GET operation for the test endpoint + operationId: getTest + tags: + - test + responses: + 200: + description: Successful response + x-test: some-value + `, + wantErrs: []string{"method [GET] is a standard HTTP method and must be defined in its own field"}, + }, + { + name: "invalid_openapi_version", + openApiVersion: "invalid-version", + yml: ` +get: + summary: Get resource + responses: + '200': + description: Successful response + `, + wantErrs: []string{"invalid version invalid-version"}, + }, + { + name: "not_using_additional_operations_for_non_standard_method", + openApiVersion: "3.2.0", + yml: ` +copy: + summary: Copy resource + responses: + '201': + description: Resource copied + `, + wantErrs: []string{"method [copy] is not a standard HTTP method and must be defined in the additionalOperations field"}, + }, } for _, tt := range tests { @@ -310,7 +400,12 @@ get: require.NoError(t, err) allErrors = append(allErrors, validationErrs...) - validateErrs := pathItem.Validate(t.Context()) + openAPIDoc := openapi.NewOpenAPI() + if tt.openApiVersion != "" { + openAPIDoc.OpenAPI = tt.openApiVersion + } + + validateErrs := pathItem.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) allErrors = append(allErrors, validateErrs...) require.NotEmpty(t, allErrors, "Expected validation errors") diff --git a/openapi/testdata/bootstrap_expected.yaml b/openapi/testdata/bootstrap_expected.yaml index 4387b0b..e5461a1 100644 --- a/openapi/testdata/bootstrap_expected.yaml +++ b/openapi/testdata/bootstrap_expected.yaml @@ -1,4 +1,4 @@ -openapi: "3.1.1" +openapi: "3.2.0" info: title: "My API" version: "1.0.0" diff --git a/openapi/testdata/test.openapi.yaml b/openapi/testdata/test.openapi.yaml index 5ecf5c7..a05bdad 100644 --- a/openapi/testdata/test.openapi.yaml +++ b/openapi/testdata/test.openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.1 +openapi: 3.2.0 info: title: Test OpenAPI Document summary: A summary @@ -63,6 +63,17 @@ paths: description: OK x-test: some-value x-test: some-value + additionalOperations: + COPY: + summary: Copy operation + description: Custom COPY operation for the test endpoint + operationId: copyTest + tags: + - test + responses: + 201: + description: Created + x-test: some-value /users/{userId}: summary: User management endpoint description: Endpoint for managing user data with comprehensive parameter examples diff --git a/openapi/testdata/upgrade/3_1_0.yaml b/openapi/testdata/upgrade/3_1_0.yaml index 2a9c729..0832111 100644 --- a/openapi/testdata/upgrade/3_1_0.yaml +++ b/openapi/testdata/upgrade/3_1_0.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Test API for 3.1.0 Upgrade version: 1.0.0 - description: Test document to verify WithUpgradeSamePatchVersion option + description: Test document to verify WithUpgradeSameMinorVersion option paths: /test: get: diff --git a/openapi/testdata/upgrade/3_2_0.yaml b/openapi/testdata/upgrade/3_2_0.yaml new file mode 100644 index 0000000..0832111 --- /dev/null +++ b/openapi/testdata/upgrade/3_2_0.yaml @@ -0,0 +1,39 @@ +openapi: 3.1.0 +info: + title: Test API for 3.1.0 Upgrade + version: 1.0.0 + description: Test document to verify WithUpgradeSameMinorVersion option +paths: + /test: + get: + summary: Test endpoint + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TestResponse" +components: + schemas: + TestResponse: + type: object + nullable: true + properties: + id: + type: integer + example: 123 + name: + type: string + nullable: true + score: + type: number + minimum: 0 + exclusiveMinimum: true + maximum: 100 + exclusiveMaximum: false + metadata: + anyOf: + - type: string + - type: object + nullable: true diff --git a/openapi/testdata/upgrade/expected_3_0_0_upgraded.yaml b/openapi/testdata/upgrade/expected_3_0_0_upgraded.yaml index efefa58..3faa759 100644 --- a/openapi/testdata/upgrade/expected_3_0_0_upgraded.yaml +++ b/openapi/testdata/upgrade/expected_3_0_0_upgraded.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.1 +openapi: 3.2.0 info: title: Test API for Upgrade version: 1.0.0 diff --git a/openapi/testdata/upgrade/expected_3_0_2_upgraded.json b/openapi/testdata/upgrade/expected_3_0_2_upgraded.json index d97aa4d..0402876 100644 --- a/openapi/testdata/upgrade/expected_3_0_2_upgraded.json +++ b/openapi/testdata/upgrade/expected_3_0_2_upgraded.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.1", + "openapi": "3.1.2", "info": { "title": "JSON Test API for Upgrade", "version": "1.0.0", diff --git a/openapi/testdata/upgrade/expected_3_0_2_upgraded.yaml b/openapi/testdata/upgrade/expected_3_0_2_upgraded.yaml deleted file mode 100644 index 8b17361..0000000 --- a/openapi/testdata/upgrade/expected_3_0_2_upgraded.yaml +++ /dev/null @@ -1,84 +0,0 @@ -{ - "openapi": "3.1.1", - "info": - { - "title": "JSON Test API for Upgrade", - "version": "1.0.0", - "description": "JSON API to test upgrading from 3.0.2 to 3.1.1", - }, - "paths": - { - "/items": - { - "get": - { - "operationId": "getItems", - "responses": - { - "200": - { - "description": "Success", - "content": - { - "application/json": - { - "schema": - { "$ref": "#/components/schemas/ItemList" }, - }, - }, - }, - }, - }, - }, - }, - "components": - { - "schemas": - { - "Item": - { - "type": ["object", "null"], - "properties": - { - "id": { "type": "integer", "examples": [789] }, - "title": { "type": "string", "examples": ["Sample Item"] }, - "value": - { - "type": "number", - "minimum": 0, - "exclusiveMaximum": 1000, - }, - "rating": - { - "type": "number", - "exclusiveMinimum": 0, - "exclusiveMaximum": 5, - }, - }, - }, - "ItemList": - { - "type": "object", - "properties": - { - "items": - { - "type": "array", - "items": { "$ref": "#/components/schemas/Item" }, - }, - "total": { "type": "integer", "examples": [100] }, - }, - }, - "NullableString": { "type": ["string", "null"] }, - "NullableWithAnyOf": - { - "anyOf": - [ - { "type": "string" }, - { "type": "integer" }, - { "type": ["null"] }, - ], - }, - }, - }, -} diff --git a/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml b/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml index a4c2c44..53c8202 100644 --- a/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml +++ b/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.1 +openapi: 3.1.2 info: title: Test API for Upgrade 3.0.3 version: 1.0.0 diff --git a/openapi/testdata/upgrade/expected_3_1_0_upgraded.yaml b/openapi/testdata/upgrade/expected_3_1_0_upgraded.yaml index 34b9ac3..d283d12 100644 --- a/openapi/testdata/upgrade/expected_3_1_0_upgraded.yaml +++ b/openapi/testdata/upgrade/expected_3_1_0_upgraded.yaml @@ -1,8 +1,8 @@ -openapi: 3.1.1 +openapi: 3.1.2 info: title: Test API for 3.1.0 Upgrade version: 1.0.0 - description: Test document to verify WithUpgradeSamePatchVersion option + description: Test document to verify WithUpgradeSameMinorVersion option paths: /test: get: diff --git a/openapi/testdata/upgrade/expected_minimal_nullable_upgraded.json b/openapi/testdata/upgrade/expected_minimal_nullable_upgraded.json index 036b083..eba14ec 100644 --- a/openapi/testdata/upgrade/expected_minimal_nullable_upgraded.json +++ b/openapi/testdata/upgrade/expected_minimal_nullable_upgraded.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.1", + "openapi": "3.2.0", "info": { "title": "Test API", "version": "1.0.0" diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 7bdae7a..0f759ba 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -2,22 +2,30 @@ package openapi import ( "context" + "fmt" "slices" - "strings" + "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/marshaller" "gopkg.in/yaml.v3" ) type UpgradeOptions struct { - upgradeSamePatchVersion bool + upgradeSameMinorVersion bool + targetVersion string } -// WithUpgradeSamePatchVersion will upgrade the same patch version of the OpenAPI document. For example 3.1.0 to 3.1.1. -func WithUpgradeSamePatchVersion() Option[UpgradeOptions] { +// WithUpgradeSameMinorVersion will upgrade the same minor version of the OpenAPI document. For example 3.1.0 to 3.1.1. +func WithUpgradeSameMinorVersion() Option[UpgradeOptions] { return func(uo *UpgradeOptions) { - uo.upgradeSamePatchVersion = true + uo.upgradeSameMinorVersion = true + } +} + +func WithUpgradeTargetVersion(version string) Option[UpgradeOptions] { + return func(uo *UpgradeOptions) { + uo.targetVersion = version } } @@ -28,54 +36,113 @@ func Upgrade(ctx context.Context, doc *OpenAPI, opts ...Option[UpgradeOptions]) return false, nil } - o := UpgradeOptions{} + options := UpgradeOptions{} for _, opt := range opts { - opt(&o) + opt(&options) + } + if options.targetVersion == "" { + options.targetVersion = Version } - // Only upgrade if: - // 1. Document is 3.0.x (always upgrade these) - // 2. Document is 3.1.x and upgradeSamePatchVersion is true (upgrade to 3.1.1) - switch { - case strings.HasPrefix(doc.OpenAPI, "3.0"): - // Always upgrade 3.0.x versions - case strings.HasPrefix(doc.OpenAPI, "3.1") && o.upgradeSamePatchVersion && doc.OpenAPI != Version: - // Upgrade 3.1.x versions to 3.1.1 if option is set and not already 3.1.1 - default: - // Don't upgrade other versions + currentVersion, err := version.ParseVersion(doc.OpenAPI) + if err != nil { + return false, err + } + + targetVersion, err := version.ParseVersion(options.targetVersion) + if err != nil { + return false, err + } + + invalidVersion := targetVersion.LessThan(*currentVersion) + if invalidVersion { + return false, fmt.Errorf("cannot downgrade OpenAPI document version from %s to %s", currentVersion, targetVersion) + } + + if currentVersion.Major < 3 { + return false, fmt.Errorf("cannot upgrade OpenAPI document version from %s to %s: only OpenAPI 3.x.x is supported", currentVersion, targetVersion) + } + + if targetVersion.Equal(*currentVersion) { return false, nil } + // Skip patch-only upgrades if 'upgradeSameMinorVersion' is not set + if targetVersion.Major == currentVersion.Major && targetVersion.Minor == currentVersion.Minor && !options.upgradeSameMinorVersion { + return false, nil + } + + // We're passing current and target version to each upgrade function in case we want to + // add logic to skip certain upgrades in certain situations in the future + upgradeFrom30To31(ctx, doc, currentVersion, targetVersion) + upgradeFrom310To312(ctx, doc, currentVersion, targetVersion) + upgradeFrom31To32(ctx, doc, currentVersion, targetVersion) + + _, err = marshaller.Sync(ctx, doc) + return true, err +} + +func upgradeFrom30To31(ctx context.Context, doc *OpenAPI, _ *version.Version, _ *version.Version) { + // Always run the upgrade logic, because 3.1 is backwards compatible, but we want to migrate if we can + for item := range Walk(ctx, doc) { _ = item.Match(Matcher{ - OpenAPI: func(o *OpenAPI) error { - o.OpenAPI = Version - return nil - }, Schema: func(js *oas3.JSONSchema[oas3.Referenceable]) error { - upgradeSchema(js) + upgradeSchema30to31(js) return nil }, }) } + doc.OpenAPI = "3.1.0" +} - _, err := marshaller.Sync(ctx, doc) - return true, err +func upgradeFrom310To312(_ context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) { + if !targetVersion.GreaterThan(*currentVersion) { + return + } + + // Currently no breaking changes between 3.1.0 and 3.1.2 that need to be handled + maxVersion, err := version.ParseVersion("3.1.2") + if err != nil { + panic("failed to parse hardcoded version 3.1.2") + } + if targetVersion.LessThan(*maxVersion) { + maxVersion = targetVersion + } + doc.OpenAPI = maxVersion.String() +} + +func upgradeFrom31To32(_ context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) { + if !targetVersion.GreaterThan(*currentVersion) { + return + } + + // TODO: Upgrade path additionalOperations for non-standard HTTP methods + + // Currently no breaking changes between 3.1.x and 3.2.x that need to be handled + maxVersion, err := version.ParseVersion("3.2.0") + if err != nil { + panic("failed to parse hardcoded version 3.2.0") + } + if targetVersion.LessThan(*maxVersion) { + maxVersion = targetVersion + } + doc.OpenAPI = maxVersion.String() } -func upgradeSchema(js *oas3.JSONSchema[oas3.Referenceable]) { +func upgradeSchema30to31(js *oas3.JSONSchema[oas3.Referenceable]) { if js == nil || js.IsReference() || js.IsRight() { return } schema := js.GetResolvedSchema().GetLeft() - upgradeExample(schema) - upgradeExclusiveMinMax(schema) - upgradeNullableSchema(schema) + upgradeExample30to31(schema) + upgradeExclusiveMinMax30to31(schema) + upgradeNullableSchema30to31(schema) } -func upgradeExample(schema *oas3.Schema) { +func upgradeExample30to31(schema *oas3.Schema) { if schema == nil || schema.Example == nil { return } @@ -88,7 +155,7 @@ func upgradeExample(schema *oas3.Schema) { schema.Example = nil } -func upgradeExclusiveMinMax(schema *oas3.Schema) { +func upgradeExclusiveMinMax30to31(schema *oas3.Schema) { if schema.ExclusiveMaximum != nil && schema.ExclusiveMaximum.IsLeft() { if schema.Maximum == nil || !*schema.ExclusiveMaximum.GetLeft() { schema.ExclusiveMaximum = nil @@ -108,7 +175,7 @@ func upgradeExclusiveMinMax(schema *oas3.Schema) { } } -func upgradeNullableSchema(schema *oas3.Schema) { +func upgradeNullableSchema30to31(schema *oas3.Schema) { if schema == nil { return } diff --git a/openapi/upgrade_test.go b/openapi/upgrade_test.go index 5352690..6114064 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -17,39 +17,39 @@ func TestUpgrade_Success(t *testing.T) { t.Parallel() tests := []struct { - name string - inputFile string - expectedFile string - options []openapi.Option[openapi.UpgradeOptions] - description string + name string + inputFile string + expectedFile string + options []openapi.Option[openapi.UpgradeOptions] + description string + targetVersion string }{ { name: "upgrade_3_0_0_yaml", inputFile: "testdata/upgrade/3_0_0.yaml", expectedFile: "testdata/upgrade/expected_3_0_0_upgraded.yaml", - options: nil, description: "3.0.0 should upgrade without options", }, { name: "upgrade_3_0_2_json", inputFile: "testdata/upgrade/3_0_2.json", expectedFile: "testdata/upgrade/expected_3_0_2_upgraded.json", - options: nil, + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeTargetVersion("3.1.2")}, description: "3.0.2 should upgrade without options", }, { name: "upgrade_3_0_3_yaml", inputFile: "testdata/upgrade/3_0_3.yaml", expectedFile: "testdata/upgrade/expected_3_0_3_upgraded.yaml", - options: nil, + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeTargetVersion("3.1.2")}, description: "3.0.3 should upgrade without options", }, { name: "upgrade_3_1_0_yaml_with_option", inputFile: "testdata/upgrade/3_1_0.yaml", expectedFile: "testdata/upgrade/expected_3_1_0_upgraded.yaml", - options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSamePatchVersion()}, - description: "3.1.0 should upgrade with WithUpgradeSamePatchVersion option", + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion(), openapi.WithUpgradeTargetVersion("3.1.2")}, + description: "3.1.0 should upgrade with WithUpgradeSameMinorVersion option", }, { name: "upgrade_nullable_schema", @@ -101,6 +101,47 @@ func TestUpgrade_Success(t *testing.T) { } } +func TestUpgrade_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + options []openapi.Option[openapi.UpgradeOptions] + wantErrs string + }{ + { + name: "2_0_0_with_upgrade_same_minor_no_upgrade", + version: "2.0.0", + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion()}, + wantErrs: "cannot upgrade OpenAPI document version from 2.0.0 to 3.2.0: only OpenAPI 3.x.x is supported", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a simple document with the specified version + doc := &openapi.OpenAPI{ + OpenAPI: tt.version, + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + // Perform upgrade with options + _, err := openapi.Upgrade(ctx, doc, tt.options...) + require.Error(t, err, "upgrade should fail") + assert.Contains(t, err.Error(), tt.wantErrs) + }) + } +} + func TestUpgrade_NoUpgradeNeeded(t *testing.T) { t.Parallel() @@ -112,46 +153,25 @@ func TestUpgrade_NoUpgradeNeeded(t *testing.T) { expectedVersion string }{ { - name: "already_3_1_0_no_options", - version: "3.1.0", - options: nil, - shouldUpgrade: false, - expectedVersion: "3.1.0", - }, - { - name: "already_3_1_1_no_options", - version: "3.1.1", + name: "already_3_2_0_no_options", + version: "3.2.0", options: nil, shouldUpgrade: false, - expectedVersion: "3.1.1", + expectedVersion: "3.2.0", }, { - name: "not_3_0_x_no_options", - version: "2.0.0", - options: nil, - shouldUpgrade: false, - expectedVersion: "2.0.0", - }, - { - name: "3_1_0_with_upgrade_same_patch", + name: "3_1_0_with_upgrade_same_minor", version: "3.1.0", - options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSamePatchVersion()}, + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion()}, shouldUpgrade: true, expectedVersion: openapi.Version, }, { - name: "3_1_1_with_upgrade_same_patch_no_upgrade", - version: "3.1.1", - options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSamePatchVersion()}, + name: "current_version_with_upgrade_same_minor_no_upgrade", + version: openapi.Version, + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSameMinorVersion()}, shouldUpgrade: false, - expectedVersion: "3.1.1", - }, - { - name: "2_0_0_with_upgrade_same_patch_no_upgrade", - version: "2.0.0", - options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeSamePatchVersion()}, - shouldUpgrade: false, - expectedVersion: "2.0.0", + expectedVersion: openapi.Version, }, } From 87dc8d090895f770843bc88d362cd4b169df297f Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Fri, 26 Sep 2025 06:18:32 +0000 Subject: [PATCH 03/26] fix mise CI test --- openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml | 2 +- openapi/upgrade_test.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml b/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml index 53c8202..503a9f4 100644 --- a/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml +++ b/openapi/testdata/upgrade/expected_3_0_3_upgraded.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.2 +openapi: 3.2.0 info: title: Test API for Upgrade 3.0.3 version: 1.0.0 diff --git a/openapi/upgrade_test.go b/openapi/upgrade_test.go index 6114064..eb2a49d 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -41,7 +41,6 @@ func TestUpgrade_Success(t *testing.T) { name: "upgrade_3_0_3_yaml", inputFile: "testdata/upgrade/3_0_3.yaml", expectedFile: "testdata/upgrade/expected_3_0_3_upgraded.yaml", - options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeTargetVersion("3.1.2")}, description: "3.0.3 should upgrade without options", }, { From 5f0f35038c3e9c285487499eff7c3c8be3c5bb9b Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Mon, 29 Sep 2025 11:53:05 +1000 Subject: [PATCH 04/26] Update walkapi to support 3.2 additionalOperations --- .../walk.additionaloperations.openapi.yaml | 107 ++++++++++++++++++ openapi/walk.go | 9 ++ openapi/walk_test.go | 64 +++++++++++ 3 files changed, 180 insertions(+) create mode 100644 openapi/testdata/walk.additionaloperations.openapi.yaml diff --git a/openapi/testdata/walk.additionaloperations.openapi.yaml b/openapi/testdata/walk.additionaloperations.openapi.yaml new file mode 100644 index 0000000..6aa2f96 --- /dev/null +++ b/openapi/testdata/walk.additionaloperations.openapi.yaml @@ -0,0 +1,107 @@ +openapi: 3.2.0 +info: + title: AdditionalOperations Test API + version: 1.0.0 + description: API for testing additionalOperations walk functionality + +paths: + /custom/{id}: + summary: Custom operations path + description: Path with custom HTTP methods in additionalOperations + parameters: + - name: id + in: path + description: Resource ID + required: true + schema: + type: string + get: + operationId: getCustomResource + summary: Get custom resource + description: Standard GET operation + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + additionalOperations: + COPY: + operationId: copyCustomResource + summary: Copy custom resource + description: Custom COPY operation to duplicate a resource + tags: + - custom + parameters: + - name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + responses: + "201": + description: Resource copied successfully + content: + application/json: + schema: + type: object + properties: + id: + type: string + originalId: + type: string + message: + type: string + "400": + description: Invalid copy request + x-custom: copy-operation-extension + PURGE: + operationId: purgeCustomResource + summary: Purge custom resource + description: Custom PURGE operation to completely remove a resource + tags: + - custom + responses: + "204": + description: Resource purged successfully + "404": + description: Resource not found + x-custom: purge-operation-extension + x-custom: custom-path-item-extension + + /standard: + get: + operationId: getStandardResource + summary: Get standard resource + description: Standard operation without additionalOperations + responses: + "200": + description: Success + +components: + schemas: + CustomResource: + type: object + description: Custom resource object + properties: + id: + type: string + description: Resource identifier + name: + type: string + description: Resource name + data: + type: object + description: Resource data + required: + - id + - name + +x-custom: root-extension \ No newline at end of file diff --git a/openapi/walk.go b/openapi/walk.go index bad42ab..2f5f877 100644 --- a/openapi/walk.go +++ b/openapi/walk.go @@ -275,6 +275,15 @@ func walkPathItem(ctx context.Context, pathItem *PathItem, parent MatchFunc, loc } } + // Walk through additional operations (OpenAPI 3.2+) + if pathItem.AdditionalOperations != nil { + for method, operation := range pathItem.AdditionalOperations.All() { + if !walkOperation(ctx, operation, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "additionalOperations", ParentKey: pointer.From(method)}), openAPI, yield) { + return false + } + } + } + // Visit PathItem Extensions return yield(WalkItem{Match: getMatchFunc(pathItem.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } diff --git a/openapi/walk_test.go b/openapi/walk_test.go index 1975fae..a97f8aa 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -1084,3 +1084,67 @@ func TestWalk_Terminate_Success(t *testing.T) { assert.Equal(t, 1, visits, "expected only one visit before terminating") } + +func TestWalkAdditionalOperations_Success(t *testing.T) { + t.Parallel() + + // Load OpenAPI document with additionalOperations + f, err := os.Open("testdata/walk.additionaloperations.openapi.yaml") + require.NoError(t, err) + defer f.Close() + + openAPIDoc, validationErrs, err := openapi.Unmarshal(t.Context(), f) + require.NoError(t, err) + require.Empty(t, validationErrs, "Document should be valid") + + matchedLocations := []string{} + expectedAssertions := map[string]func(*openapi.Operation){ + "/paths/~1custom~1{id}/get": func(op *openapi.Operation) { + assert.Equal(t, "getCustomResource", op.GetOperationID()) + assert.Equal(t, "Get custom resource", op.GetSummary()) + }, + "/paths/~1custom~1{id}/additionalOperations/COPY": func(op *openapi.Operation) { + assert.Equal(t, "copyCustomResource", op.GetOperationID()) + assert.Equal(t, "Copy custom resource", op.GetSummary()) + assert.Equal(t, "Custom COPY operation to duplicate a resource", op.GetDescription()) + assert.Contains(t, op.GetTags(), "custom") + }, + "/paths/~1custom~1{id}/additionalOperations/PURGE": func(op *openapi.Operation) { + assert.Equal(t, "purgeCustomResource", op.GetOperationID()) + assert.Equal(t, "Purge custom resource", op.GetSummary()) + assert.Equal(t, "Custom PURGE operation to completely remove a resource", op.GetDescription()) + assert.Contains(t, op.GetTags(), "custom") + }, + "/paths/~1standard/get": func(op *openapi.Operation) { + assert.Equal(t, "getStandardResource", op.GetOperationID()) + assert.Equal(t, "Get standard resource", op.GetSummary()) + }, + } + + for item := range openapi.Walk(t.Context(), openAPIDoc) { + err := item.Match(openapi.Matcher{ + Operation: func(op *openapi.Operation) error { + operationLoc := string(item.Location.ToJSONPointer()) + matchedLocations = append(matchedLocations, operationLoc) + + if assertFunc, exists := expectedAssertions[operationLoc]; exists { + assertFunc(op) + } + + return nil + }, + }) + require.NoError(t, err) + } + + // Verify all expected operations were visited + for expectedLoc := range expectedAssertions { + assert.Contains(t, matchedLocations, expectedLoc, "Should visit operation at location: %s", expectedLoc) + } + + // Verify we found both standard and additional operations + assert.Contains(t, matchedLocations, "/paths/~1custom~1{id}/get", "Should visit standard GET operation") + assert.Contains(t, matchedLocations, "/paths/~1custom~1{id}/additionalOperations/COPY", "Should visit additional COPY operation") + assert.Contains(t, matchedLocations, "/paths/~1custom~1{id}/additionalOperations/PURGE", "Should visit additional PURGE operation") + assert.Contains(t, matchedLocations, "/paths/~1standard/get", "Should visit standard operation on path without additionalOperations") +} From 77282cc8eff28ffb5a9d1852457c75e679584e1e Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Mon, 29 Sep 2025 15:09:22 +1000 Subject: [PATCH 05/26] Add inline/bundling tests for additional operations --- openapi/bundle_test.go | 74 ++++ openapi/inline_test.go | 121 +++++++ .../inline/additionaloperations_input.yaml | 231 ++++++++++++ .../inline/external_custom_operations.yaml | 333 ++++++++++++++++++ 4 files changed, 759 insertions(+) create mode 100644 openapi/testdata/inline/additionaloperations_input.yaml create mode 100644 openapi/testdata/inline/external_custom_operations.yaml diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index eb4d50d..89f888b 100644 --- a/openapi/bundle_test.go +++ b/openapi/bundle_test.go @@ -172,3 +172,77 @@ func TestBundle_SiblingDirectories_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") } + +func TestBundle_AdditionalOperations_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document with additionalOperations + inputFile, err := os.Open("testdata/inline/additionaloperations_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure bundling options + opts := openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/inline/additionaloperations_input.yaml", + }, + NamingStrategy: openapi.BundleNamingFilePath, + } + + // Bundle all external references + err = openapi.Bundle(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the bundled document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify that external references in additionalOperations were bundled + assert.Contains(t, actualYAML, "components:", "Components section should be created") + assert.Contains(t, actualYAML, "additionalOperations:", "additionalOperations should be preserved") + + // Verify external schemas were bundled into components + assert.Contains(t, actualYAML, "ResourceMetadata:", "External ResourceMetadata schema should be bundled") + assert.Contains(t, actualYAML, "SyncConfig:", "External SyncConfig schema should be bundled") + assert.Contains(t, actualYAML, "BatchConfig:", "External BatchConfig schema should be bundled") + + // Verify external parameters were bundled + assert.Contains(t, actualYAML, "DestinationParam:", "External DestinationParam should be bundled") + assert.Contains(t, actualYAML, "ConfirmationParam:", "External ConfirmationParam should be bundled") + + // Verify external responses were bundled + assert.Contains(t, actualYAML, "CopyResponse:", "External CopyResponse should be bundled") + assert.Contains(t, actualYAML, "ValidationErrorResponse:", "External ValidationErrorResponse should be bundled") + + // Verify external request bodies were bundled + assert.Contains(t, actualYAML, "CopyRequest:", "External CopyRequest should be bundled") + + // Verify references in additionalOperations now point to components + assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/DestinationParam\"", "COPY operation should reference bundled parameter") + assert.Contains(t, actualYAML, "$ref: \"#/components/requestBodies/CopyRequest\"", "COPY operation should reference bundled request body") + assert.Contains(t, actualYAML, "$ref: \"#/components/responses/CopyResponse\"", "COPY operation should reference bundled response") + + // Verify references in PURGE operation + assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/ConfirmationParam\"", "PURGE operation should reference bundled parameter") + assert.Contains(t, actualYAML, "$ref: \"#/components/responses/ValidationErrorResponse\"", "PURGE operation should reference bundled response") + + // Verify references in SYNC operation + assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncConfig\"", "SYNC operation should reference bundled schema") + assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncResult\"", "SYNC operation should reference bundled schema") + + // Verify references in BATCH operation + assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchConfig\"", "BATCH operation should reference bundled schema") + assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchResult\"", "BATCH operation should reference bundled schema") + + // Verify no external file references remain in additionalOperations + assert.NotContains(t, actualYAML, "external_custom_operations.yaml#/", "No external file references should remain") +} diff --git a/openapi/inline_test.go b/openapi/inline_test.go index 5f6bdaf..7e2b773 100644 --- a/openapi/inline_test.go +++ b/openapi/inline_test.go @@ -3,6 +3,7 @@ package openapi_test import ( "bytes" "os" + "strings" "testing" "github.com/speakeasy-api/openapi/openapi" @@ -120,3 +121,123 @@ func TestInline_SiblingDirectories_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Inlined document should match expected output") } + +func TestInline_AdditionalOperations_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document with additionalOperations + inputFile, err := os.Open("testdata/inline/additionaloperations_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Configure inlining options + opts := openapi.InlineOptions{ + ResolveOptions: openapi.ResolveOptions{ + RootDocument: inputDoc, + TargetLocation: "testdata/inline/additionaloperations_input.yaml", + }, + RemoveUnusedComponents: true, + } + + // Inline all references + err = openapi.Inline(ctx, inputDoc, opts) + require.NoError(t, err) + + // Marshal the inlined document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.String() + + // Verify that additionalOperations are preserved + assert.Contains(t, actualYAML, "additionalOperations:", "additionalOperations should be preserved") + + // Verify that external references in additionalOperations were inlined + assert.NotContains(t, actualYAML, "$ref:", "No references should remain after inlining") + assert.NotContains(t, actualYAML, "external_custom_operations.yaml", "No external file references should remain") + + // Verify that the COPY operation has inlined content + assert.Contains(t, actualYAML, "COPY:", "COPY operation should be present") + assert.Contains(t, actualYAML, "operationId: copyResource", "COPY operation content should be inlined") + + // Verify that external parameter was inlined in COPY operation + copyOperationSection := extractAdditionalOperationSection(actualYAML, "COPY") + assert.Contains(t, copyOperationSection, "name: destination", "DestinationParam should be inlined") + assert.Contains(t, copyOperationSection, "in: header", "DestinationParam should be inlined") + + // Verify that external request body was inlined in COPY operation + assert.Contains(t, copyOperationSection, "source_path:", "CopyRequest schema should be inlined") + assert.Contains(t, copyOperationSection, "destination_path:", "CopyRequest schema should be inlined") + + // Verify that the PURGE operation has inlined content + assert.Contains(t, actualYAML, "PURGE:", "PURGE operation should be present") + assert.Contains(t, actualYAML, "operationId: purgeResource", "PURGE operation content should be inlined") + + // Verify that external parameter was inlined in PURGE operation + purgeOperationSection := extractAdditionalOperationSection(actualYAML, "PURGE") + assert.Contains(t, purgeOperationSection, "name: X-Confirm-Purge", "ConfirmationParam should be inlined") + assert.Contains(t, purgeOperationSection, "pattern: ^CONFIRM-[A-Z0-9]{8}$", "ConfirmationParam schema should be inlined") + + // Verify that the SYNC operation has inlined content + assert.Contains(t, actualYAML, "SYNC:", "SYNC operation should be present") + assert.Contains(t, actualYAML, "operationId: syncResource", "SYNC operation content should be inlined") + + // Verify that external schemas were inlined in SYNC operation + syncOperationSection := extractAdditionalOperationSection(actualYAML, "SYNC") + assert.Contains(t, syncOperationSection, "source:", "SyncConfig schema should be inlined") + assert.Contains(t, syncOperationSection, "destination:", "SyncConfig schema should be inlined") + assert.Contains(t, syncOperationSection, "sync_id:", "SyncResult schema should be inlined") + assert.Contains(t, syncOperationSection, "files_synced:", "SyncResult schema should be inlined") + + // Verify that the BATCH operation has inlined content + assert.Contains(t, actualYAML, "BATCH:", "BATCH operation should be present") + assert.Contains(t, actualYAML, "operationId: batchProcess", "BATCH operation content should be inlined") + + // Verify that nested external schemas were properly inlined + batchOperationSection := extractAdditionalOperationSection(actualYAML, "BATCH") + assert.Contains(t, batchOperationSection, "parallel_execution:", "BatchConfig schema should be inlined") + assert.Contains(t, batchOperationSection, "batch_id:", "BatchResult schema should be inlined") + assert.Contains(t, batchOperationSection, "max_attempts:", "RetryPolicy schema should be inlined") + + // Verify components section was removed (since RemoveUnusedComponents is true) + // Note: Some components might remain if they're still referenced from the main document + if !assert.NotContains(t, actualYAML, "components:", "Components section should be removed after inlining") { + // If components section exists, ensure it doesn't contain the external schemas + assert.NotContains(t, actualYAML, "ResourceMetadata:", "External ResourceMetadata should not be in components after inlining") + assert.NotContains(t, actualYAML, "SyncConfig:", "External SyncConfig should not be in components after inlining") + } +} + +// Helper function to extract a specific additionalOperation section from YAML +func extractAdditionalOperationSection(yamlContent, operationName string) string { + lines := strings.Split(yamlContent, "\n") + var sectionLines []string + inTargetOperation := false + indentLevel := -1 + + for _, line := range lines { + if strings.Contains(line, operationName+":") && strings.Contains(line, "additionalOperations") == false { + inTargetOperation = true + indentLevel = len(line) - len(strings.TrimLeft(line, " ")) + sectionLines = append(sectionLines, line) + continue + } + + if inTargetOperation { + currentIndent := len(line) - len(strings.TrimLeft(line, " ")) + // If we hit a line at the same or lower indent level, we've left the operation + if strings.TrimSpace(line) != "" && currentIndent <= indentLevel { + break + } + sectionLines = append(sectionLines, line) + } + } + + return strings.Join(sectionLines, "\n") +} diff --git a/openapi/testdata/inline/additionaloperations_input.yaml b/openapi/testdata/inline/additionaloperations_input.yaml new file mode 100644 index 0000000..c1b2bfa --- /dev/null +++ b/openapi/testdata/inline/additionaloperations_input.yaml @@ -0,0 +1,231 @@ +openapi: 3.2.0 +info: + title: AdditionalOperations Test API + version: 1.0.0 + description: Test document for additionalOperations bundling and inlining + +paths: + /resources/{id}: + summary: Custom operations with references + description: Path with additionalOperations that use external references + parameters: + - name: id + in: path + description: Resource ID + required: true + schema: + type: string + get: + operationId: getResource + summary: Get resource + description: Standard GET operation + responses: + "200": + $ref: "#/components/responses/ResourceResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + additionalOperations: + COPY: + operationId: copyResource + summary: Copy resource + description: Custom COPY operation with external references + tags: + - custom + parameters: + - $ref: "external_custom_operations.yaml#/components/parameters/DestinationParam" + requestBody: + $ref: "external_custom_operations.yaml#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "external_custom_operations.yaml#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + x-custom-extension: copy-operation + PURGE: + operationId: purgeResource + summary: Purge resource + description: Custom PURGE operation with mixed references + tags: + - custom + - maintenance + parameters: + - name: force + in: query + description: Force purge operation + schema: + type: boolean + default: false + - $ref: "external_custom_operations.yaml#/components/parameters/ConfirmationParam" + responses: + "204": + description: Resource purged successfully + "404": + $ref: "#/components/responses/ErrorResponse" + "422": + $ref: "external_custom_operations.yaml#/components/responses/ValidationErrorResponse" + x-custom-extension: purge-operation + SYNC: + operationId: syncResource + summary: Sync resource + description: Custom SYNC operation with complex references + tags: + - custom + - sync + requestBody: + description: Sync configuration + required: true + content: + application/json: + schema: + $ref: "external_custom_operations.yaml#/components/schemas/SyncConfig" + responses: + "200": + description: Sync completed + content: + application/json: + schema: + $ref: "external_custom_operations.yaml#/components/schemas/SyncResult" + "400": + $ref: "#/components/responses/ErrorResponse" + x-custom-extension: sync-operation + x-custom-path-extension: additional-operations-path + + /bulk-operations: + get: + operationId: listBulkOperations + summary: List bulk operations + description: Standard operation in path with additionalOperations + responses: + "200": + description: List of bulk operations + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BulkOperation" + additionalOperations: + BATCH: + operationId: batchProcess + summary: Batch process resources + description: Custom BATCH operation with circular references + requestBody: + description: Batch operation data + required: true + content: + application/json: + schema: + type: object + properties: + operations: + type: array + items: + $ref: "#/components/schemas/BulkOperation" + config: + $ref: "external_custom_operations.yaml#/components/schemas/BatchConfig" + responses: + "202": + description: Batch operation accepted + content: + application/json: + schema: + $ref: "external_custom_operations.yaml#/components/schemas/BatchResult" + "400": + $ref: "#/components/responses/ErrorResponse" + +components: + schemas: + Resource: + type: object + required: + - id + - name + - type + properties: + id: + type: string + format: uuid + description: Resource identifier + name: + type: string + description: Resource name + maxLength: 100 + type: + type: string + enum: [document, image, video, archive] + metadata: + $ref: "external_custom_operations.yaml#/components/schemas/ResourceMetadata" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + BulkOperation: + type: object + required: + - id + - operation_type + - status + properties: + id: + type: string + format: uuid + operation_type: + type: string + enum: [copy, move, delete, sync] + status: + type: string + enum: [pending, processing, completed, failed] + resource_ids: + type: array + items: + type: string + format: uuid + config: + $ref: "external_custom_operations.yaml#/components/schemas/OperationConfig" + created_at: + type: string + format: date-time + + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + + responses: + ResourceResponse: + description: Single resource response + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + + ErrorResponse: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + parameters: + ResourceIdParam: + name: id + in: path + required: true + description: Resource ID + schema: + type: string + format: uuid \ No newline at end of file diff --git a/openapi/testdata/inline/external_custom_operations.yaml b/openapi/testdata/inline/external_custom_operations.yaml new file mode 100644 index 0000000..53dacfa --- /dev/null +++ b/openapi/testdata/inline/external_custom_operations.yaml @@ -0,0 +1,333 @@ +openapi: 3.2.0 +info: + title: External Custom Operations API + version: 1.0.0 + description: External components for custom operations + +components: + schemas: + ResourceMetadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true + + SyncConfig: + type: object + required: + - source + - destination + properties: + source: + type: string + format: uri + description: Source location for sync + destination: + type: string + format: uri + description: Destination location for sync + mode: + type: string + enum: [full, incremental, differential] + default: incremental + preserve_metadata: + type: boolean + default: true + compression: + $ref: "#/components/schemas/CompressionConfig" + + CompressionConfig: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: [gzip, bzip2, lz4, zstd] + default: gzip + level: + type: integer + minimum: 1 + maximum: 9 + default: 6 + + SyncResult: + type: object + required: + - sync_id + - status + properties: + sync_id: + type: string + format: uuid + status: + type: string + enum: [completed, failed, partial] + files_synced: + type: integer + minimum: 0 + bytes_transferred: + type: integer + minimum: 0 + duration_seconds: + type: number + format: float + errors: + type: array + items: + $ref: "#/components/schemas/SyncError" + + SyncError: + type: object + properties: + file_path: + type: string + error_code: + type: string + error_message: + type: string + + BatchConfig: + type: object + properties: + parallel_execution: + type: boolean + default: false + max_concurrent_operations: + type: integer + minimum: 1 + maximum: 10 + default: 3 + timeout_seconds: + type: integer + minimum: 30 + default: 300 + retry_policy: + $ref: "#/components/schemas/RetryPolicy" + + RetryPolicy: + type: object + properties: + max_attempts: + type: integer + minimum: 1 + maximum: 5 + default: 3 + backoff_strategy: + type: string + enum: [fixed, exponential, linear] + default: exponential + initial_delay_seconds: + type: integer + minimum: 1 + default: 5 + + BatchResult: + type: object + required: + - batch_id + - total_operations + - completed_operations + properties: + batch_id: + type: string + format: uuid + total_operations: + type: integer + minimum: 0 + completed_operations: + type: integer + minimum: 0 + failed_operations: + type: integer + minimum: 0 + execution_time_seconds: + type: number + format: float + results: + type: array + items: + $ref: "#/components/schemas/OperationResult" + + OperationResult: + type: object + properties: + operation_id: + type: string + format: uuid + status: + type: string + enum: [success, failure, skipped] + message: + type: string + details: + type: object + additionalProperties: true + + OperationConfig: + type: object + properties: + priority: + type: string + enum: [low, normal, high, urgent] + default: normal + notification_settings: + $ref: "#/components/schemas/NotificationSettings" + resource_limits: + type: object + properties: + max_memory_mb: + type: integer + minimum: 128 + default: 512 + max_cpu_percent: + type: integer + minimum: 10 + maximum: 100 + default: 50 + + NotificationSettings: + type: object + properties: + on_completion: + type: boolean + default: true + on_failure: + type: boolean + default: true + webhook_url: + type: string + format: uri + email_recipients: + type: array + items: + type: string + format: email + + ValidationError: + type: object + required: + - field + - message + properties: + field: + type: string + description: Field that failed validation + message: + type: string + description: Validation error message + code: + type: string + description: Validation error code + + parameters: + DestinationParam: + name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" + + ConfirmationParam: + name: X-Confirm-Purge + in: header + description: Confirmation token for purge operation + required: true + schema: + type: string + pattern: "^CONFIRM-[A-Z0-9]{8}$" + example: "CONFIRM-ABC12345" + + requestBodies: + CopyRequest: + description: Copy operation request + required: true + content: + application/json: + schema: + type: object + required: + - source_path + - destination_path + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + $ref: "#/components/schemas/CompressionConfig" + + responses: + CopyResponse: + description: Copy operation result + content: + application/json: + schema: + type: object + required: + - copy_id + - status + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: [queued, processing, completed] + source_path: + type: string + destination_path: + type: string + metadata: + $ref: "#/components/schemas/ResourceMetadata" + created_at: + type: string + format: date-time + + ValidationErrorResponse: + description: Validation error response + content: + application/json: + schema: + type: object + required: + - message + - errors + properties: + message: + type: string + description: General error message + errors: + type: array + items: + $ref: "#/components/schemas/ValidationError" \ No newline at end of file From 79a5a476a92a01c1b88cecd4a105001993fe99fc Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Thu, 2 Oct 2025 12:04:20 +1000 Subject: [PATCH 06/26] Fixup walk example test --- openapi/openapi_examples_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index eee6272..c53164f 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -330,8 +330,8 @@ func Example_walking() { fmt.Printf("Found Operation: %s\n", *op.OperationID) } operationCount++ - // Terminate after finding 2 operations - if operationCount >= 2 { + // Terminate after finding 3 operations + if operationCount >= 3 { return walk.ErrTerminate } return nil From b79f07f65d3f8eb4fdece9d4b9481934533e556a Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Mon, 29 Sep 2025 18:03:52 +1000 Subject: [PATCH 07/26] Added support for 3.2 tags --- openapi/bootstrap.go | 15 ++ openapi/core/tag.go | 3 + openapi/tag.go | 88 ++++++++++++ openapi/tag_kind_registry.go | 57 ++++++++ openapi/tag_unmarshal_test.go | 92 ++++++++++++ openapi/tag_validate_test.go | 170 +++++++++++++++++++++++ openapi/testdata/bootstrap_expected.yaml | 11 ++ openapi/upgrade.go | 1 + 8 files changed, 437 insertions(+) create mode 100644 openapi/tag_kind_registry.go diff --git a/openapi/bootstrap.go b/openapi/bootstrap.go index 919cd57..3f8d3ff 100644 --- a/openapi/bootstrap.go +++ b/openapi/bootstrap.go @@ -65,12 +65,27 @@ func createBootstrapTags() []*Tag { return []*Tag{ { Name: "users", + Summary: pointer.From("Users"), Description: pointer.From("User management operations"), + Kind: pointer.From("nav"), ExternalDocs: &oas3.ExternalDocumentation{ Description: pointer.From("User API documentation"), URL: "https://docs.example.com/users", }, }, + { + Name: "admin", + Summary: pointer.From("Admin"), + Description: pointer.From("Administrative operations"), + Parent: pointer.From("users"), + Kind: pointer.From("nav"), + }, + { + Name: "beta-features", + Summary: pointer.From("Beta"), + Description: pointer.From("Experimental features"), + Kind: pointer.From("badge"), + }, } } diff --git a/openapi/core/tag.go b/openapi/core/tag.go index daba309..af41e22 100644 --- a/openapi/core/tag.go +++ b/openapi/core/tag.go @@ -10,7 +10,10 @@ type Tag struct { marshaller.CoreModel `model:"tag"` Name marshaller.Node[string] `key:"name"` + Summary marshaller.Node[*string] `key:"summary"` Description marshaller.Node[*string] `key:"description"` ExternalDocs marshaller.Node[*oas3core.ExternalDocumentation] `key:"externalDocs"` + Parent marshaller.Node[*string] `key:"parent"` + Kind marshaller.Node[*string] `key:"kind"` Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/tag.go b/openapi/tag.go index 421d0c6..a79d8e7 100644 --- a/openapi/tag.go +++ b/openapi/tag.go @@ -17,10 +17,16 @@ type Tag struct { // The name of the tag. Name string + // A short summary of the tag, used for display purposes. + Summary *string // A description for the tag. May contain CommonMark syntax. Description *string // External documentation for this tag. ExternalDocs *oas3.ExternalDocumentation + // The name of a tag that this tag is nested under. The named tag must exist in the API description. + Parent *string + // A machine-readable string to categorize what sort of tag it is. + Kind *string // Extensions provides a list of extensions to the Tag object. Extensions *extensions.Extensions @@ -52,6 +58,30 @@ func (t *Tag) GetExternalDocs() *oas3.ExternalDocumentation { return t.ExternalDocs } +// GetSummary returns the value of the Summary field. Returns empty string if not set. +func (t *Tag) GetSummary() string { + if t == nil || t.Summary == nil { + return "" + } + return *t.Summary +} + +// GetParent returns the value of the Parent field. Returns empty string if not set. +func (t *Tag) GetParent() string { + if t == nil || t.Parent == nil { + return "" + } + return *t.Parent +} + +// GetKind returns the value of the Kind field. Returns empty string if not set. +func (t *Tag) GetKind() string { + if t == nil || t.Kind == nil { + return "" + } + return *t.Kind +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (t *Tag) GetExtensions() *extensions.Extensions { if t == nil || t.Extensions == nil { @@ -77,3 +107,61 @@ func (t *Tag) Validate(ctx context.Context, opts ...validation.Option) []error { return errs } + +// ValidateWithTags validates the Tag object in the context of all tags to check for parent relationships. +// This should be called during document-level validation where all tags are available. +func (t *Tag) ValidateWithTags(ctx context.Context, allTags []*Tag, opts ...validation.Option) []error { + errs := t.Validate(ctx, opts...) + + if t.Parent != nil && *t.Parent != "" { + // Check if parent tag exists + parentExists := false + for _, tag := range allTags { + if tag != nil && tag.Name == *t.Parent { + parentExists = true + break + } + } + + if !parentExists { + core := t.GetCore() + errs = append(errs, validation.NewValueError( + validation.NewMissingValueError("parent tag '%s' does not exist", *t.Parent), + core, core.Parent)) + } + + // Check for circular references + if t.hasCircularParentReference(allTags, make(map[string]bool)) { + core := t.GetCore() + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("circular parent reference detected for tag '%s'", t.Name), + core, core.Parent)) + } + } + + return errs +} + +// hasCircularParentReference checks if this tag has a circular parent reference +func (t *Tag) hasCircularParentReference(allTags []*Tag, visited map[string]bool) bool { + if t == nil || t.Parent == nil || *t.Parent == "" { + return false + } + + // If we've already visited this tag, we have a circular reference + if visited[t.Name] { + return true + } + + // Mark this tag as visited + visited[t.Name] = true + + // Find the parent tag and recursively check + for _, tag := range allTags { + if tag != nil && tag.Name == *t.Parent { + return tag.hasCircularParentReference(allTags, visited) + } + } + + return false +} diff --git a/openapi/tag_kind_registry.go b/openapi/tag_kind_registry.go new file mode 100644 index 0000000..cf7c2f2 --- /dev/null +++ b/openapi/tag_kind_registry.go @@ -0,0 +1,57 @@ +package openapi + +// TagKind represents commonly used values for the Tag.Kind field. +// These values are registered in the OpenAPI Initiative's Tag Kind Registry +// at https://spec.openapis.org/registry/tag-kind/ +type TagKind string + +// Officially registered Tag Kind values from the OpenAPI Initiative registry +const ( + // TagKindNav represents tags used for navigation purposes + TagKindNav TagKind = "nav" + + // TagKindBadge represents tags used for visible badges or labels + TagKindBadge TagKind = "badge" + + // TagKindAudience represents tags that categorize operations by target audience + TagKindAudience TagKind = "audience" +) + +// String returns the string representation of the TagKind +func (tk TagKind) String() string { + return string(tk) +} + +// IsRegistered checks if the TagKind value is one of the officially registered values +func (tk TagKind) IsRegistered() bool { + switch tk { + case TagKindNav, TagKindBadge, TagKindAudience: + return true + default: + return false + } +} + +// GetRegisteredTagKinds returns all officially registered tag kind values +func GetRegisteredTagKinds() []TagKind { + return []TagKind{ + TagKindNav, + TagKindBadge, + TagKindAudience, + } +} + +// TagKindDescriptions provides human-readable descriptions for each registered tag kind +var TagKindDescriptions = map[TagKind]string{ + TagKindNav: "Navigation - Used for structuring API documentation navigation", + TagKindBadge: "Badge - Used for visible badges or labels in documentation", + TagKindAudience: "Audience - Used to categorize operations by target audience", +} + +// GetTagKindDescription returns a human-readable description for a tag kind +func GetTagKindDescription(kind TagKind) string { + if desc, exists := TagKindDescriptions[kind]; exists { + return desc + } + return "Custom tag kind - not in the official registry (any string value is allowed)" +} diff --git a/openapi/tag_unmarshal_test.go b/openapi/tag_unmarshal_test.go index 83533cb..8ae49d7 100644 --- a/openapi/tag_unmarshal_test.go +++ b/openapi/tag_unmarshal_test.go @@ -39,3 +39,95 @@ x-test: some-value require.True(t, ok) require.Equal(t, "some-value", ext.Value) } + +func TestTag_Unmarshal_WithNewFields_Success(t *testing.T) { + t.Parallel() + + yml := ` +name: products +summary: Products +description: All product-related operations +parent: catalog +kind: nav +externalDocs: + description: Product API documentation + url: https://example.com/products +x-custom: custom-value +` + + var tag openapi.Tag + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &tag) + require.NoError(t, err) + require.Empty(t, validationErrs) + + require.Equal(t, "products", tag.GetName()) + require.Equal(t, "Products", tag.GetSummary()) + require.Equal(t, "All product-related operations", tag.GetDescription()) + require.Equal(t, "catalog", tag.GetParent()) + require.Equal(t, "nav", tag.GetKind()) + + extDocs := tag.GetExternalDocs() + require.NotNil(t, extDocs) + require.Equal(t, "Product API documentation", extDocs.GetDescription()) + require.Equal(t, "https://example.com/products", extDocs.GetURL()) + + ext, ok := tag.GetExtensions().Get("x-custom") + require.True(t, ok) + require.Equal(t, "custom-value", ext.Value) +} + +func TestTag_Unmarshal_MinimalNewFields_Success(t *testing.T) { + t.Parallel() + + yml := ` +name: minimal +summary: Minimal Tag +` + + var tag openapi.Tag + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &tag) + require.NoError(t, err) + require.Empty(t, validationErrs) + + require.Equal(t, "minimal", tag.GetName()) + require.Equal(t, "Minimal Tag", tag.GetSummary()) + require.Equal(t, "", tag.GetDescription()) + require.Equal(t, "", tag.GetParent()) + require.Equal(t, "", tag.GetKind()) +} + +func TestTag_Unmarshal_KindValues_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind string + expected string + }{ + {"nav kind", "nav", "nav"}, + {"badge kind", "badge", "badge"}, + {"audience kind", "audience", "audience"}, + {"custom kind", "custom-value", "custom-value"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + yml := ` +name: test +kind: ` + tt.kind + + var tag openapi.Tag + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &tag) + require.NoError(t, err) + require.Empty(t, validationErrs) + + require.Equal(t, "test", tag.GetName()) + require.Equal(t, tt.expected, tag.GetKind()) + }) + } +} diff --git a/openapi/tag_validate_test.go b/openapi/tag_validate_test.go index 5415d5e..843166d 100644 --- a/openapi/tag_validate_test.go +++ b/openapi/tag_validate_test.go @@ -57,6 +57,32 @@ description: Administrative operations externalDocs: description: Admin documentation url: https://admin.example.com/docs +`, + }, + { + name: "valid tag with new 3.2 fields", + yml: ` +name: products +summary: Products +description: All product-related operations +parent: catalog +kind: nav +`, + }, + { + name: "valid tag with registered kind values", + yml: ` +name: user-badge +summary: User Badge +kind: badge +`, + }, + { + name: "valid tag with custom kind value", + yml: ` +name: custom-tag +summary: Custom Tag +kind: custom-lifecycle `, }, } @@ -170,3 +196,147 @@ externalDocs: }) } } + +func TestTag_ValidateWithTags_ParentRelationships_Success(t *testing.T) { + t.Parallel() + + // Create a hierarchy of tags: catalog -> products -> books + catalogTag := &openapi.Tag{Name: "catalog"} + productsTag := &openapi.Tag{Name: "products", Parent: &[]string{"catalog"}[0]} + booksTag := &openapi.Tag{Name: "books", Parent: &[]string{"products"}[0]} + standaloneTag := &openapi.Tag{Name: "standalone"} + + allTags := []*openapi.Tag{catalogTag, productsTag, booksTag, standaloneTag} + + for _, tag := range allTags { + errs := tag.ValidateWithTags(t.Context(), allTags) + require.Empty(t, errs, "expected no validation errors for tag %s", tag.Name) + } +} + +func TestTag_ValidateWithTags_ParentNotFound_Error(t *testing.T) { + t.Parallel() + + // Create a tag with a non-existent parent + tag := &openapi.Tag{Name: "orphan", Parent: &[]string{"nonexistent"}[0]} + allTags := []*openapi.Tag{tag} + + errs := tag.ValidateWithTags(t.Context(), allTags) + require.NotEmpty(t, errs, "expected validation errors") + + found := false + for _, err := range errs { + if strings.Contains(err.Error(), "parent tag 'nonexistent' does not exist") { + found = true + break + } + } + require.True(t, found, "expected parent not found error") +} + +func TestTag_ValidateWithTags_CircularReference_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tags []*openapi.Tag + desc string + }{ + { + name: "direct circular reference", + tags: []*openapi.Tag{ + {Name: "tag1", Parent: &[]string{"tag1"}[0]}, // Self-reference + }, + desc: "tag references itself", + }, + { + name: "two-tag circular reference", + tags: []*openapi.Tag{ + {Name: "tag1", Parent: &[]string{"tag2"}[0]}, + {Name: "tag2", Parent: &[]string{"tag1"}[0]}, + }, + desc: "tag1 -> tag2 -> tag1", + }, + { + name: "three-tag circular reference", + tags: []*openapi.Tag{ + {Name: "tag1", Parent: &[]string{"tag2"}[0]}, + {Name: "tag2", Parent: &[]string{"tag3"}[0]}, + {Name: "tag3", Parent: &[]string{"tag1"}[0]}, + }, + desc: "tag1 -> tag2 -> tag3 -> tag1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Check each tag that has a parent + for _, tag := range tt.tags { + if tag.Parent != nil { + errs := tag.ValidateWithTags(t.Context(), tt.tags) + require.NotEmpty(t, errs, "expected validation errors for %s", tt.desc) + + found := false + for _, err := range errs { + if strings.Contains(err.Error(), "circular parent reference") { + found = true + break + } + } + require.True(t, found, "expected circular reference error for %s", tt.desc) + } + } + }) + } +} + +func TestTag_ValidateWithTags_ComplexHierarchy_Success(t *testing.T) { + t.Parallel() + + // Create a complex but valid hierarchy + // catalog + // โ”œโ”€โ”€ products + // โ”‚ โ”œโ”€โ”€ books + // โ”‚ โ””โ”€โ”€ cds + // โ””โ”€โ”€ services + // โ””โ”€โ”€ delivery + + catalogTag := &openapi.Tag{Name: "catalog", Kind: &[]string{"nav"}[0]} + productsTag := &openapi.Tag{Name: "products", Parent: &[]string{"catalog"}[0], Kind: &[]string{"nav"}[0]} + booksTag := &openapi.Tag{Name: "books", Parent: &[]string{"products"}[0], Kind: &[]string{"nav"}[0]} + cdsTag := &openapi.Tag{Name: "cds", Parent: &[]string{"products"}[0], Kind: &[]string{"nav"}[0]} + servicesTag := &openapi.Tag{Name: "services", Parent: &[]string{"catalog"}[0], Kind: &[]string{"nav"}[0]} + deliveryTag := &openapi.Tag{Name: "delivery", Parent: &[]string{"services"}[0], Kind: &[]string{"badge"}[0]} + + allTags := []*openapi.Tag{catalogTag, productsTag, booksTag, cdsTag, servicesTag, deliveryTag} + + for _, tag := range allTags { + errs := tag.ValidateWithTags(t.Context(), allTags) + require.Empty(t, errs, "expected no validation errors for tag %s", tag.Name) + } +} + +func TestTagKind_Registry_Success(t *testing.T) { + t.Parallel() + + // Test registered kinds + registeredKinds := openapi.GetRegisteredTagKinds() + require.Len(t, registeredKinds, 3) + require.Contains(t, registeredKinds, openapi.TagKindNav) + require.Contains(t, registeredKinds, openapi.TagKindBadge) + require.Contains(t, registeredKinds, openapi.TagKindAudience) + + // Test kind validation + require.True(t, openapi.TagKindNav.IsRegistered()) + require.True(t, openapi.TagKindBadge.IsRegistered()) + require.True(t, openapi.TagKindAudience.IsRegistered()) + require.False(t, openapi.TagKind("custom").IsRegistered()) + + // Test descriptions + require.NotEmpty(t, openapi.GetTagKindDescription(openapi.TagKindNav)) + require.NotEmpty(t, openapi.GetTagKindDescription(openapi.TagKindBadge)) + require.NotEmpty(t, openapi.GetTagKindDescription(openapi.TagKindAudience)) + require.Contains(t, openapi.GetTagKindDescription("custom"), "not in the official registry") +} diff --git a/openapi/testdata/bootstrap_expected.yaml b/openapi/testdata/bootstrap_expected.yaml index e5461a1..95d9eef 100644 --- a/openapi/testdata/bootstrap_expected.yaml +++ b/openapi/testdata/bootstrap_expected.yaml @@ -13,10 +13,21 @@ info: url: "https://opensource.org/licenses/MIT" tags: - name: "users" + summary: "Users" description: "User management operations" externalDocs: description: "User API documentation" url: "https://docs.example.com/users" + kind: "nav" + - name: "admin" + summary: "Admin" + description: "Administrative operations" + parent: "users" + kind: "nav" + - name: "beta-features" + summary: "Beta" + description: "Experimental features" + kind: "badge" servers: - url: "https://api.example.com/v1" description: "Production server" diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 0f759ba..159eade 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -118,6 +118,7 @@ func upgradeFrom31To32(_ context.Context, doc *OpenAPI, currentVersion *version. } // TODO: Upgrade path additionalOperations for non-standard HTTP methods + // TODO: Upgrade tags such as x-displayName to summary, and x-tagGroups with parents, etc. // Currently no breaking changes between 3.1.x and 3.2.x that need to be handled maxVersion, err := version.ParseVersion("3.2.0") From fd2a04c625b65b892a218009f3c6cccb5c92d30e Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Wed, 1 Oct 2025 15:57:00 +1000 Subject: [PATCH 08/26] Added upgrade path for 3.2 additionalOperations --- .../upgrade/3_1_0_with_custom_methods.yaml | 54 +++++++++++++ ...ed_3_1_0_with_custom_methods_upgraded.yaml | 56 +++++++++++++ openapi/upgrade.go | 47 ++++++++++- openapi/upgrade_test.go | 80 +++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 openapi/testdata/upgrade/3_1_0_with_custom_methods.yaml create mode 100644 openapi/testdata/upgrade/expected_3_1_0_with_custom_methods_upgraded.yaml diff --git a/openapi/testdata/upgrade/3_1_0_with_custom_methods.yaml b/openapi/testdata/upgrade/3_1_0_with_custom_methods.yaml new file mode 100644 index 0000000..5a6c63f --- /dev/null +++ b/openapi/testdata/upgrade/3_1_0_with_custom_methods.yaml @@ -0,0 +1,54 @@ +openapi: 3.1.0 +info: + title: Test API with Custom Methods + version: 1.0.0 + description: Test document with non-standard HTTP methods +paths: + /test: + get: + summary: Standard GET operation + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + copy: + summary: Custom COPY operation + responses: + "200": + description: Resource copied + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + move: + summary: Custom MOVE operation + responses: + "200": + description: Resource moved + content: + application/json: + schema: + type: object + properties: + newLocation: + type: string + /another: + post: + summary: Standard POST operation + responses: + "201": + description: Created + purge: + summary: Custom PURGE operation + responses: + "204": + description: Purged \ No newline at end of file diff --git a/openapi/testdata/upgrade/expected_3_1_0_with_custom_methods_upgraded.yaml b/openapi/testdata/upgrade/expected_3_1_0_with_custom_methods_upgraded.yaml new file mode 100644 index 0000000..7f8c5cd --- /dev/null +++ b/openapi/testdata/upgrade/expected_3_1_0_with_custom_methods_upgraded.yaml @@ -0,0 +1,56 @@ +openapi: 3.2.0 +info: + title: Test API with Custom Methods + version: 1.0.0 + description: Test document with non-standard HTTP methods +paths: + /test: + get: + summary: Standard GET operation + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + additionalOperations: + copy: + summary: Custom COPY operation + responses: + "200": + description: Resource copied + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + move: + summary: Custom MOVE operation + responses: + "200": + description: Resource moved + content: + application/json: + schema: + type: object + properties: + newLocation: + type: string + /another: + post: + summary: Standard POST operation + responses: + "201": + description: Created + additionalOperations: + purge: + summary: Custom PURGE operation + responses: + "204": + description: Purged diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 159eade..a8f48e6 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -8,6 +8,7 @@ import ( "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/sequencedmap" "gopkg.in/yaml.v3" ) @@ -112,12 +113,14 @@ func upgradeFrom310To312(_ context.Context, doc *OpenAPI, currentVersion *versio doc.OpenAPI = maxVersion.String() } -func upgradeFrom31To32(_ context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) { +func upgradeFrom31To32(ctx context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) { if !targetVersion.GreaterThan(*currentVersion) { return } - // TODO: Upgrade path additionalOperations for non-standard HTTP methods + // Upgrade path additionalOperations for non-standard HTTP methods + migrateAdditionalOperations31to32(ctx, doc) + // TODO: Upgrade tags such as x-displayName to summary, and x-tagGroups with parents, etc. // Currently no breaking changes between 3.1.x and 3.2.x that need to be handled @@ -131,6 +134,46 @@ func upgradeFrom31To32(_ context.Context, doc *OpenAPI, currentVersion *version. doc.OpenAPI = maxVersion.String() } +// migrateAdditionalOperations31to32 migrates non-standard HTTP methods from the main operations map +// to the additionalOperations field in PathItem objects for OpenAPI 3.2.0+ compatibility. +func migrateAdditionalOperations31to32(_ context.Context, doc *OpenAPI) { + if doc.Paths == nil { + return + } + + for _, referencedPathItem := range doc.Paths.All() { + if referencedPathItem == nil || referencedPathItem.Object == nil { + continue + } + + pathItem := referencedPathItem.Object + nonStandardMethods := sequencedmap.New[string, *Operation]() + + // Find non-standard HTTP methods in the main operations map + for method, operation := range pathItem.All() { + if !IsStandardMethod(string(method)) { + nonStandardMethods.Set(string(method), operation) + } + } + + // If we found non-standard methods, migrate them to additionalOperations + if nonStandardMethods.Len() > 0 { + // Initialize additionalOperations if it doesn't exist + if pathItem.AdditionalOperations == nil { + pathItem.AdditionalOperations = sequencedmap.New[string, *Operation]() + } + + // Move each non-standard operation to additionalOperations + for method, operation := range nonStandardMethods.All() { + pathItem.AdditionalOperations.Set(method, operation) + + // Remove from the main operations map + pathItem.Map.Delete(HTTPMethod(method)) + } + } + } +} + func upgradeSchema30to31(js *oas3.JSONSchema[oas3.Referenceable]) { if js == nil || js.IsReference() || js.IsRight() { return diff --git a/openapi/upgrade_test.go b/openapi/upgrade_test.go index eb2a49d..8609572 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -57,6 +57,14 @@ func TestUpgrade_Success(t *testing.T) { options: nil, description: "nullable schema should upgrade to oneOf without panic", }, + { + name: "upgrade_3_1_0_with_custom_methods", + inputFile: "testdata/upgrade/3_1_0_with_custom_methods.yaml", + expectedFile: "testdata/upgrade/expected_3_1_0_with_custom_methods_upgraded.yaml", + options: []openapi.Option[openapi.UpgradeOptions]{openapi.WithUpgradeTargetVersion("3.2.0")}, + description: "3.1.0 with custom HTTP methods should migrate to additionalOperations", + targetVersion: "3.2.0", + }, } for _, tt := range tests { @@ -311,3 +319,75 @@ components: assert.Nil(t, simpleExample.Example, "example should be nil") assert.NotEmpty(t, simpleExample.Examples, "examples should not be empty") } + +func TestUpgradeAdditionalOperations(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a document with non-standard HTTP methods + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } + + // Add a path with both standard and non-standard methods + pathItem := openapi.NewPathItem() + + // Standard method + pathItem.Set(openapi.HTTPMethodGet, &openapi.Operation{ + Summary: &[]string{"Get operation"}[0], + Responses: openapi.NewResponses(), + }) + + // Non-standard methods + pathItem.Set(openapi.HTTPMethod("copy"), &openapi.Operation{ + Summary: &[]string{"Copy operation"}[0], + Responses: openapi.NewResponses(), + }) + + pathItem.Set(openapi.HTTPMethod("purge"), &openapi.Operation{ + Summary: &[]string{"Purge operation"}[0], + Responses: openapi.NewResponses(), + }) + + doc.Paths.Set("/test", &openapi.ReferencedPathItem{Object: pathItem}) + + // Verify initial state + assert.Equal(t, 3, pathItem.Len(), "should have 3 operations initially") + assert.Nil(t, pathItem.AdditionalOperations, "additionalOperations should be nil initially") + assert.NotNil(t, pathItem.GetOperation(openapi.HTTPMethod("copy")), "copy operation should exist in main map") + assert.NotNil(t, pathItem.GetOperation(openapi.HTTPMethod("purge")), "purge operation should exist in main map") + + // Perform upgrade to 3.2.0 + upgraded, err := openapi.Upgrade(ctx, doc, openapi.WithUpgradeTargetVersion("3.2.0")) + require.NoError(t, err, "upgrade should not fail") + assert.True(t, upgraded, "upgrade should have been performed") + assert.Equal(t, "3.2.0", doc.OpenAPI, "version should be 3.2.0") + + // Verify migration results + assert.Equal(t, 1, pathItem.Len(), "should have only 1 operation in main map after migration") + assert.NotNil(t, pathItem.AdditionalOperations, "additionalOperations should be initialized") + assert.Equal(t, 2, pathItem.AdditionalOperations.Len(), "should have 2 operations in additionalOperations") + + // Verify standard method remains in main map + assert.NotNil(t, pathItem.GetOperation(openapi.HTTPMethodGet), "get operation should remain in main map") + + // Verify non-standard methods are moved to additionalOperations + assert.Nil(t, pathItem.GetOperation(openapi.HTTPMethod("copy")), "copy operation should be removed from main map") + assert.Nil(t, pathItem.GetOperation(openapi.HTTPMethod("purge")), "purge operation should be removed from main map") + + copyOp, exists := pathItem.AdditionalOperations.Get("copy") + assert.True(t, exists, "copy operation should exist in additionalOperations") + assert.NotNil(t, copyOp, "copy operation should not be nil") + assert.Equal(t, "Copy operation", *copyOp.Summary, "copy operation summary should be preserved") + + purgeOp, exists := pathItem.AdditionalOperations.Get("purge") + assert.True(t, exists, "purge operation should exist in additionalOperations") + assert.NotNil(t, purgeOp, "purge operation should not be nil") + assert.Equal(t, "Purge operation", *purgeOp.Summary, "purge operation summary should be preserved") +} From f5724d6d743341454cf08eea9b1334184b4d170f Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Thu, 2 Oct 2025 11:52:17 +1000 Subject: [PATCH 09/26] Add x-tag-groups migration code --- openapi/openapi_examples_test.go | 1 + openapi/upgrade.go | 192 ++++++++++++++++- openapi/upgrade_test.go | 353 +++++++++++++++++++++++++++++++ 3 files changed, 541 insertions(+), 5 deletions(-) diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index c53164f..48fdcc6 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -359,6 +359,7 @@ func Example_walking() { // Found Info: Test OpenAPI Document (version 1.0.0) // Found Schema of type: string // Found Operation: test + // Found Operation: copyTest // Found Schema of type: integer // Found Operation: updateUser // Walk terminated early diff --git a/openapi/upgrade.go b/openapi/upgrade.go index a8f48e6..044cd38 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" + "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/internal/version" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/marshaller" @@ -77,7 +78,9 @@ func Upgrade(ctx context.Context, doc *OpenAPI, opts ...Option[UpgradeOptions]) // add logic to skip certain upgrades in certain situations in the future upgradeFrom30To31(ctx, doc, currentVersion, targetVersion) upgradeFrom310To312(ctx, doc, currentVersion, targetVersion) - upgradeFrom31To32(ctx, doc, currentVersion, targetVersion) + if err := upgradeFrom31To32(ctx, doc, currentVersion, targetVersion); err != nil { + return false, err + } _, err = marshaller.Sync(ctx, doc) return true, err @@ -113,25 +116,30 @@ func upgradeFrom310To312(_ context.Context, doc *OpenAPI, currentVersion *versio doc.OpenAPI = maxVersion.String() } -func upgradeFrom31To32(ctx context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) { +func upgradeFrom31To32(ctx context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) error { if !targetVersion.GreaterThan(*currentVersion) { - return + return nil } // Upgrade path additionalOperations for non-standard HTTP methods migrateAdditionalOperations31to32(ctx, doc) - // TODO: Upgrade tags such as x-displayName to summary, and x-tagGroups with parents, etc. + // Upgrade tags from extensions to new 3.2 fields + if err := migrateTags31to32(ctx, doc); err != nil { + return err + } // Currently no breaking changes between 3.1.x and 3.2.x that need to be handled maxVersion, err := version.ParseVersion("3.2.0") if err != nil { - panic("failed to parse hardcoded version 3.2.0") + return err } if targetVersion.LessThan(*maxVersion) { maxVersion = targetVersion } doc.OpenAPI = maxVersion.String() + + return nil } // migrateAdditionalOperations31to32 migrates non-standard HTTP methods from the main operations map @@ -174,6 +182,180 @@ func migrateAdditionalOperations31to32(_ context.Context, doc *OpenAPI) { } } +// migrateTags31to32 migrates tag extensions to new OpenAPI 3.2 tag fields +func migrateTags31to32(_ context.Context, doc *OpenAPI) error { + if doc == nil { + return nil + } + + // First, migrate x-displayName to summary for individual tags + if doc.Tags != nil { + for _, tag := range doc.Tags { + if err := migrateTagDisplayName(tag); err != nil { + return err + } + } + } + + // Second, migrate x-tagGroups to parent relationships + // This should always run to process extensions, even if no tags exist yet + if err := migrateTagGroups(doc); err != nil { + return err + } + + return nil +} + +// migrateTagDisplayName migrates x-displayName extension to summary field +func migrateTagDisplayName(tag *Tag) error { + if tag == nil || tag.Extensions == nil { + return nil + } + + // Check if x-displayName extension exists and summary is not already set + if displayNameExt, exists := tag.Extensions.Get("x-displayName"); exists { + if tag.Summary != nil { + // Error out if we can't migrate as summary is already set + return fmt.Errorf("cannot migrate x-displayName to summary for tag %q as summary is already set", tag.Name) + } + // The extension value is stored as a string + if displayNameExt.Value != "" { + displayName := displayNameExt.Value + tag.Summary = &displayName + // Remove the extension after migration + tag.Extensions.Delete("x-displayName") + } + } + return nil +} + +// TagGroup represents a single tag group from x-tagGroups extension +type TagGroup struct { + Name string `yaml:"name"` + Tags []string `yaml:"tags"` +} + +// migrateTagGroups migrates x-tagGroups extension to parent field relationships +func migrateTagGroups(doc *OpenAPI) error { + if doc.Extensions == nil { + return nil + } + + // Check if x-tagGroups extension exists first + _, exists := doc.Extensions.Get("x-tagGroups") + if !exists { + return nil // No x-tagGroups extension found + } + + // Parse x-tagGroups extension + tagGroups, err := extensions.GetExtensionValue[[]TagGroup](doc.Extensions, "x-tagGroups") + if err != nil { + return fmt.Errorf("failed to parse x-tagGroups extension: %w", err) + } + + // Always remove the extension, even if empty or invalid + defer doc.Extensions.Delete("x-tagGroups") + + if tagGroups == nil || len(*tagGroups) == 0 { + return nil // Nothing to migrate + } + + // Initialize tags slice if it doesn't exist + if doc.Tags == nil { + doc.Tags = []*Tag{} + } + + // Create a map for quick tag lookup + tagMap := make(map[string]*Tag) + for _, tag := range doc.Tags { + if tag != nil { + tagMap[tag.Name] = tag + } + } + + // Process each tag group + for _, group := range *tagGroups { + if group.Name == "" { + continue // Skip groups without names + } + + // Ensure parent tag exists for this group + parentTag := ensureParentTagExists(doc, tagMap, group.Name) + if parentTag == nil { + return fmt.Errorf("failed to create parent tag for group: %s", group.Name) + } + + // Set parent relationships for all child tags in this group + for _, childTagName := range group.Tags { + if childTagName == "" { + continue // Skip empty tag names + } + + if err := setTagParent(doc, tagMap, childTagName, group.Name); err != nil { + return fmt.Errorf("failed to set parent for tag %s in group %s: %w", childTagName, group.Name, err) + } + } + } + + return nil +} + +// ensureParentTagExists creates a parent tag if it doesn't already exist +func ensureParentTagExists(doc *OpenAPI, tagMap map[string]*Tag, groupName string) *Tag { + // Check if parent tag already exists + if existingTag, exists := tagMap[groupName]; exists { + // Set kind to "nav" if not already set (common pattern for navigation groups) + if existingTag.Kind == nil { + kind := "nav" + existingTag.Kind = &kind + } + return existingTag + } + + // Create new parent tag + kind := "nav" + parentTag := &Tag{ + Name: groupName, + Summary: &groupName, // Use group name as summary for display + Kind: &kind, + } + + // Add to document and map + doc.Tags = append(doc.Tags, parentTag) + tagMap[groupName] = parentTag + + return parentTag +} + +// setTagParent sets the parent field for a child tag, creating the child tag if it doesn't exist +func setTagParent(doc *OpenAPI, tagMap map[string]*Tag, childTagName, parentTagName string) error { + // Prevent self-referencing (tag can't be its own parent) + if childTagName == parentTagName { + return fmt.Errorf("tag cannot be its own parent: %s", childTagName) + } + + // Check if child tag exists + childTag, exists := tagMap[childTagName] + if !exists { + // Create child tag if it doesn't exist + childTag = &Tag{ + Name: childTagName, + } + doc.Tags = append(doc.Tags, childTag) + tagMap[childTagName] = childTag + } + + // Check if child tag already has a different parent + if childTag.Parent != nil && *childTag.Parent != parentTagName { + return fmt.Errorf("tag %s already has parent %s, cannot assign new parent %s", childTagName, *childTag.Parent, parentTagName) + } + + // Set the parent relationship + childTag.Parent = &parentTagName + + return nil +} + func upgradeSchema30to31(js *oas3.JSONSchema[oas3.Referenceable]) { if js == nil || js.IsReference() || js.IsRight() { return diff --git a/openapi/upgrade_test.go b/openapi/upgrade_test.go index 8609572..10df246 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -7,10 +7,12 @@ import ( "strings" "testing" + "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/jsonschema/oas3" "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestUpgrade_Success(t *testing.T) { @@ -391,3 +393,354 @@ func TestUpgradeAdditionalOperations(t *testing.T) { assert.NotNil(t, purgeOp, "purge operation should not be nil") assert.Equal(t, "Purge operation", *purgeOp.Summary, "purge operation summary should be preserved") } + +func TestUpgradeTagGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupDoc func() *openapi.OpenAPI + validate func(t *testing.T, doc *openapi.OpenAPI) + wantErr bool + errContains string + }{ + { + name: "basic_x_tagGroups_migration", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + // Add existing child tags + doc.Tags = []*openapi.Tag{ + {Name: "books"}, + {Name: "cds"}, + {Name: "giftcards"}, + } + + // Add x-tagGroups extension + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "Products", + "tags": []interface{}{"books", "cds", "giftcards"}, + }, + }) + + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Should have 4 tags total (3 existing + 1 new parent) + assert.Len(t, doc.Tags, 4, "should have 4 tags after migration") + + // Find parent tag + var parentTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == "Products" { + parentTag = tag + break + } + } + require.NotNil(t, parentTag, "parent tag should exist") + assert.Equal(t, "Products", *parentTag.Summary, "parent summary should be set") + assert.Equal(t, "nav", *parentTag.Kind, "parent kind should be nav") + + // Verify child tag parent assignments + childNames := []string{"books", "cds", "giftcards"} + for _, childName := range childNames { + var childTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == childName { + childTag = tag + break + } + } + require.NotNil(t, childTag, "child tag %s should exist", childName) + require.NotNil(t, childTag.Parent, "child tag %s should have parent", childName) + assert.Equal(t, "Products", *childTag.Parent, "child tag %s should have correct parent", childName) + } + + // x-tagGroups extension should be removed + _, exists := doc.Extensions.Get("x-tagGroups") + assert.False(t, exists, "x-tagGroups extension should be removed") + }, + }, + { + name: "existing_parent_tag", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + // Add existing parent tag with different kind + existingKind := "category" + doc.Tags = []*openapi.Tag{ + {Name: "Products", Kind: &existingKind}, + {Name: "books"}, + } + + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "Products", + "tags": []interface{}{"books"}, + }, + }) + + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Find parent tag + var parentTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == "Products" { + parentTag = tag + break + } + } + require.NotNil(t, parentTag, "parent tag should exist") + // Kind should remain unchanged when parent already exists + assert.Equal(t, "category", *parentTag.Kind, "existing parent kind should be preserved") + + // Child should have parent set + var childTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == "books" { + childTag = tag + break + } + } + require.NotNil(t, childTag, "child tag should exist") + require.NotNil(t, childTag.Parent, "child tag should have parent") + assert.Equal(t, "Products", *childTag.Parent, "child should have correct parent") + }, + }, + { + name: "missing_child_tags_created", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + // No existing tags + doc.Tags = []*openapi.Tag{} + + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "Products", + "tags": []interface{}{"books", "electronics"}, + }, + }) + + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Should have 3 tags (1 parent + 2 children) + assert.Len(t, doc.Tags, 3, "should have 3 tags after migration") + + // All tags should exist + tagNames := []string{"Products", "books", "electronics"} + for _, name := range tagNames { + found := false + for _, tag := range doc.Tags { + if tag.Name == name { + found = true + break + } + } + assert.True(t, found, "tag %s should exist", name) + } + }, + }, + { + name: "multiple_groups", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + doc.Tags = []*openapi.Tag{} + + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "Products", + "tags": []interface{}{"books", "cds"}, + }, + { + "name": "Support", + "tags": []interface{}{"help", "contact"}, + }, + }) + + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Should have 6 tags (2 parents + 4 children) + assert.Len(t, doc.Tags, 6, "should have 6 tags after migration") + + // Verify both parent tags exist + parentNames := []string{"Products", "Support"} + for _, parentName := range parentNames { + var parentTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == parentName { + parentTag = tag + break + } + } + require.NotNil(t, parentTag, "parent tag %s should exist", parentName) + assert.Equal(t, "nav", *parentTag.Kind, "parent %s should have nav kind", parentName) + } + + // Verify child relationships + childParentMap := map[string]string{ + "books": "Products", + "cds": "Products", + "help": "Support", + "contact": "Support", + } + + for childName, expectedParent := range childParentMap { + var childTag *openapi.Tag + for _, tag := range doc.Tags { + if tag.Name == childName { + childTag = tag + break + } + } + require.NotNil(t, childTag, "child tag %s should exist", childName) + require.NotNil(t, childTag.Parent, "child tag %s should have parent", childName) + assert.Equal(t, expectedParent, *childTag.Parent, "child %s should have correct parent", childName) + } + }, + }, + { + name: "no_x_tagGroups_extension", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + doc.Tags = []*openapi.Tag{ + {Name: "existing"}, + } + // No x-tagGroups extension + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Should remain unchanged + assert.Len(t, doc.Tags, 1, "should have 1 tag") + assert.Equal(t, "existing", doc.Tags[0].Name, "existing tag should remain") + assert.Nil(t, doc.Tags[0].Parent, "existing tag should have no parent") + }, + }, + { + name: "empty_x_tagGroups", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{}) + return doc + }, + validate: func(t *testing.T, doc *openapi.OpenAPI) { + // Should remove empty extension + _, exists := doc.Extensions.Get("x-tagGroups") + assert.False(t, exists, "empty x-tagGroups should be removed") + }, + }, + { + name: "conflicting_parent_assignment", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + // Add tag with existing parent + existingParent := "ExistingParent" + doc.Tags = []*openapi.Tag{ + {Name: "ExistingParent"}, + {Name: "books", Parent: &existingParent}, + } + + // Try to assign different parent + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "Products", + "tags": []interface{}{"books"}, + }, + }) + + return doc + }, + wantErr: true, + errContains: "already has parent", + }, + { + name: "self_referencing_prevention", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + doc.Extensions = createXTagGroupsExtension([]map[string]interface{}{ + { + "name": "SelfRef", + "tags": []interface{}{"SelfRef"}, + }, + }) + + return doc + }, + wantErr: true, + errContains: "cannot be its own parent", + }, + { + name: "invalid_x_tagGroups_format", + setupDoc: func() *openapi.OpenAPI { + doc := createTestDocWithVersion("3.1.0") + + // Create malformed extension + doc.Extensions = extensions.New() + // This will create an invalid structure that can't be parsed as []TagGroup + doc.Extensions.Set("x-tagGroups", createYAMLNode("invalid string")) + + return doc + }, + wantErr: true, + errContains: "failed to parse x-tagGroups extension", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc := tt.setupDoc() + + // Perform upgrade to 3.2.0 + _, err := openapi.Upgrade(ctx, doc, openapi.WithUpgradeTargetVersion("3.2.0")) + + if tt.wantErr { + require.Error(t, err, "should have error") + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains, "error should contain expected text") + } + return + } + + require.NoError(t, err, "upgrade should not fail") + if tt.validate != nil { + tt.validate(t, doc) + } + }) + } +} + +// Helper functions for test setup + +func createTestDocWithVersion(version string) *openapi.OpenAPI { + return &openapi.OpenAPI{ + OpenAPI: version, + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Paths: openapi.NewPaths(), + } +} + +func createXTagGroupsExtension(groups []map[string]interface{}) *extensions.Extensions { + exts := extensions.New() + exts.Set("x-tagGroups", createYAMLNode(groups)) + return exts +} + +func createYAMLNode(value interface{}) *yaml.Node { + var node yaml.Node + if err := node.Encode(value); err != nil { + panic(err) + } + return &node +} From 9581e4a70c976264e9f48a16b5e5bbe485fc3503 Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Fri, 3 Oct 2025 14:15:38 +1000 Subject: [PATCH 10/26] Added support for querystring operation parameters --- openapi/parameter.go | 33 ++++++++++-- openapi/parameter_validate_test.go | 86 +++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/openapi/parameter.go b/openapi/parameter.go index 96aafd8..428138d 100644 --- a/openapi/parameter.go +++ b/openapi/parameter.go @@ -28,6 +28,8 @@ func (p ParameterIn) String() string { const ( // ParameterInQuery represents the location of a parameter that is passed in the query string. ParameterInQuery ParameterIn = "query" + // ParameterInQueryString represents the location of a parameter that is passed as the entire query string. + ParameterInQueryString ParameterIn = "querystring" // ParameterInHeader represents the location of a parameter that is passed in the header. ParameterInHeader ParameterIn = "header" // ParameterInPath represents the location of a parameter that is passed in the path. @@ -42,7 +44,7 @@ type Parameter struct { // Name is the case sensitive name of the parameter. Name string - // In is the location of the parameter. One of "query", "header", "path" or "cookie". + // In is the location of the parameter. One of "query", "querystring", "header", "path" or "cookie". In ParameterIn // Description is a brief description of the parameter. May contain CommonMark syntax. Description *string @@ -127,6 +129,7 @@ func (p *Parameter) GetAllowEmptyValue() bool { // - ParameterInHeader: SerializationStyleSimple // - ParameterInPath: SerializationStyleSimple // - ParameterInCookie: SerializationStyleForm +// - ParameterInQueryString: Incompatible with style field func (p *Parameter) GetStyle() SerializationStyle { if p == nil || p.Style == nil { switch p.In { @@ -138,6 +141,10 @@ func (p *Parameter) GetStyle() SerializationStyle { return SerializationStyleSimple case ParameterInCookie: return SerializationStyleForm + case ParameterInQueryString: + return "" // No style allowed for querystring parameters + default: + return "" // Unknown type } } return *p.Style @@ -212,9 +219,9 @@ func (p *Parameter) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, validation.NewValueError(validation.NewMissingValueError("parameter field in is required"), core, core.In)) } else { switch p.In { - case ParameterInQuery, ParameterInHeader, ParameterInPath, ParameterInCookie: + case ParameterInQuery, ParameterInQueryString, ParameterInHeader, ParameterInPath, ParameterInCookie: default: - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field in must be one of [%s]", strings.Join([]string{string(ParameterInQuery), string(ParameterInHeader), string(ParameterInPath), string(ParameterInCookie)}, ", ")), core, core.In)) + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field in must be one of [%s]", strings.Join([]string{string(ParameterInQuery), string(ParameterInQueryString), string(ParameterInHeader), string(ParameterInPath), string(ParameterInCookie)}, ", ")), core, core.In)) } } @@ -228,6 +235,9 @@ func (p *Parameter) Validate(ctx context.Context, opts ...validation.Option) []e if core.Style.Present { switch p.In { + case ParameterInQueryString: + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field style is not allowed for in=querystring"), core, core.Style)) + case ParameterInPath: allowedStyles := []string{string(SerializationStyleSimple), string(SerializationStyleLabel), string(SerializationStyleMatrix)} if !slices.Contains(allowedStyles, string(*p.Style)) { @@ -252,7 +262,22 @@ func (p *Parameter) Validate(ctx context.Context, opts ...validation.Option) []e } if core.Schema.Present { - errs = append(errs, p.Schema.Validate(ctx, opts...)...) + switch p.In { + case ParameterInQueryString: + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field schema is not allowed for in=querystring"), core, core.Schema)) + default: + errs = append(errs, p.Schema.Validate(ctx, opts...)...) + } + } + + if !core.Content.Present || p.Content == nil { + // Querystring parameters must use content instead of schema + if p.In == ParameterInQueryString { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field content is required for in=querystring"), core, core.Content)) + } + } else if p.Content.Len() != 1 { + // If present, content must have exactly one entry + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field content must have exactly one entry"), core, core.Content)) } for _, obj := range p.Content.All() { diff --git a/openapi/parameter_validate_test.go b/openapi/parameter_validate_test.go index ebe23ff..4d0208f 100644 --- a/openapi/parameter_validate_test.go +++ b/openapi/parameter_validate_test.go @@ -112,6 +112,40 @@ deprecated: true schema: type: string description: This parameter is deprecated +`, + }, + { + name: "valid querystring parameter", + yml: ` +name: filter +in: querystring +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + type: string + category: + type: string +description: Filter parameters as query string +`, + }, + { + name: "querystring parameter with JSON content", + yml: ` +name: data +in: querystring +content: + application/json: + schema: + type: object + properties: + query: + type: string + limit: + type: integer +description: JSON data as query string `, }, } @@ -187,7 +221,7 @@ in: invalid schema: type: string `, - wantErrs: []string{"[3:5] parameter field in must be one of [query, header, path, cookie]"}, + wantErrs: []string{"[3:5] parameter field in must be one of [query, querystring, header, path, cookie]"}, }, { name: "multiple validation errors", @@ -201,6 +235,56 @@ required: false "[4:11] parameter field in=path requires required=true", }, }, + { + name: "querystring parameter with schema instead of content", + yml: ` +name: filter +in: querystring +schema: + type: object +`, + wantErrs: []string{ + "parameter field schema is not allowed for in=querystring", + "parameter field content is required for in=querystring", + }, + }, + { + name: "querystring parameter with style", + yml: ` +name: filter +in: querystring +style: form +content: + application/x-www-form-urlencoded: + schema: + type: object +`, + wantErrs: []string{"parameter field style is not allowed for in=querystring"}, + }, + { + name: "querystring parameter missing content", + yml: ` +name: filter +in: querystring +description: Missing content field +`, + wantErrs: []string{"parameter field content is required for in=querystring"}, + }, + { + name: "parameter with multiple content entries", + yml: ` +name: data +in: query +content: + application/json: + schema: + type: object + application/xml: + schema: + type: object +`, + wantErrs: []string{"parameter field content must have exactly one entry"}, + }, } for _, tt := range tests { From 320d1fe8725c442bec416a4129ed820465f641c0 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 08:32:30 +1000 Subject: [PATCH 11/26] fix: address pr feedback --- .github/workflows/ci.yaml | 2 + internal/version/version.go | 2 + openapi/bootstrap.go | 13 -- openapi/bundle_test.go | 74 ---------- openapi/inline_test.go | 121 ---------------- openapi/paths.go | 12 +- openapi/tag.go | 19 ++- openapi/tag_kind_registry.go | 4 + openapi/tag_validate_test.go | 49 ++++++- openapi/testdata/bootstrap_expected.yaml | 9 -- .../inline/bundled_counter_expected.yaml | 122 +++++++++++++++- openapi/testdata/inline/bundled_expected.yaml | 122 +++++++++++++++- openapi/testdata/inline/inline_expected.yaml | 130 +++++++++++++++++- openapi/testdata/inline/inline_input.yaml | 18 ++- 14 files changed, 456 insertions(+), 241 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5744b85..a930d37 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,8 @@ jobs: - name: Install mise uses: jdx/mise-action@v3 + with: + experimental: true - name: Setup Go with caching uses: actions/setup-go@v6 diff --git a/internal/version/version.go b/internal/version/version.go index 4c83a26..80b6f90 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -12,6 +12,8 @@ type Version struct { Patch int } +var _ fmt.Stringer = (*Version)(nil) + func New(major, minor, patch int) *Version { return &Version{ Major: major, diff --git a/openapi/bootstrap.go b/openapi/bootstrap.go index 0c37aee..1b4019c 100644 --- a/openapi/bootstrap.go +++ b/openapi/bootstrap.go @@ -73,19 +73,6 @@ func createBootstrapTags() []*Tag { URL: "https://docs.example.com/users", }, }, - { - Name: "admin", - Summary: pointer.From("Admin"), - Description: pointer.From("Administrative operations"), - Parent: pointer.From("users"), - Kind: pointer.From("nav"), - }, - { - Name: "beta-features", - Summary: pointer.From("Beta"), - Description: pointer.From("Experimental features"), - Kind: pointer.From("badge"), - }, } } diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index 5524ce5..ed428e0 100644 --- a/openapi/bundle_test.go +++ b/openapi/bundle_test.go @@ -213,77 +213,3 @@ func TestBundle_Issue50_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") } - -func TestBundle_AdditionalOperations_Success(t *testing.T) { - t.Parallel() - - ctx := t.Context() - - // Load the input document with additionalOperations - inputFile, err := os.Open("testdata/inline/additionaloperations_input.yaml") - require.NoError(t, err) - defer inputFile.Close() - - inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) - require.NoError(t, err) - require.Empty(t, validationErrs, "Input document should be valid") - - // Configure bundling options - opts := openapi.BundleOptions{ - ResolveOptions: openapi.ResolveOptions{ - RootDocument: inputDoc, - TargetLocation: "testdata/inline/additionaloperations_input.yaml", - }, - NamingStrategy: openapi.BundleNamingFilePath, - } - - // Bundle all external references - err = openapi.Bundle(ctx, inputDoc, opts) - require.NoError(t, err) - - // Marshal the bundled document to YAML - var buf bytes.Buffer - err = openapi.Marshal(ctx, inputDoc, &buf) - require.NoError(t, err) - actualYAML := buf.String() - - // Verify that external references in additionalOperations were bundled - assert.Contains(t, actualYAML, "components:", "Components section should be created") - assert.Contains(t, actualYAML, "additionalOperations:", "additionalOperations should be preserved") - - // Verify external schemas were bundled into components - assert.Contains(t, actualYAML, "ResourceMetadata:", "External ResourceMetadata schema should be bundled") - assert.Contains(t, actualYAML, "SyncConfig:", "External SyncConfig schema should be bundled") - assert.Contains(t, actualYAML, "BatchConfig:", "External BatchConfig schema should be bundled") - - // Verify external parameters were bundled - assert.Contains(t, actualYAML, "DestinationParam:", "External DestinationParam should be bundled") - assert.Contains(t, actualYAML, "ConfirmationParam:", "External ConfirmationParam should be bundled") - - // Verify external responses were bundled - assert.Contains(t, actualYAML, "CopyResponse:", "External CopyResponse should be bundled") - assert.Contains(t, actualYAML, "ValidationErrorResponse:", "External ValidationErrorResponse should be bundled") - - // Verify external request bodies were bundled - assert.Contains(t, actualYAML, "CopyRequest:", "External CopyRequest should be bundled") - - // Verify references in additionalOperations now point to components - assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/DestinationParam\"", "COPY operation should reference bundled parameter") - assert.Contains(t, actualYAML, "$ref: \"#/components/requestBodies/CopyRequest\"", "COPY operation should reference bundled request body") - assert.Contains(t, actualYAML, "$ref: \"#/components/responses/CopyResponse\"", "COPY operation should reference bundled response") - - // Verify references in PURGE operation - assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/ConfirmationParam\"", "PURGE operation should reference bundled parameter") - assert.Contains(t, actualYAML, "$ref: \"#/components/responses/ValidationErrorResponse\"", "PURGE operation should reference bundled response") - - // Verify references in SYNC operation - assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncConfig\"", "SYNC operation should reference bundled schema") - assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncResult\"", "SYNC operation should reference bundled schema") - - // Verify references in BATCH operation - assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchConfig\"", "BATCH operation should reference bundled schema") - assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchResult\"", "BATCH operation should reference bundled schema") - - // Verify no external file references remain in additionalOperations - assert.NotContains(t, actualYAML, "external_custom_operations.yaml#/", "No external file references should remain") -} diff --git a/openapi/inline_test.go b/openapi/inline_test.go index 7e2b773..5f6bdaf 100644 --- a/openapi/inline_test.go +++ b/openapi/inline_test.go @@ -3,7 +3,6 @@ package openapi_test import ( "bytes" "os" - "strings" "testing" "github.com/speakeasy-api/openapi/openapi" @@ -121,123 +120,3 @@ func TestInline_SiblingDirectories_Success(t *testing.T) { // Compare the actual output with expected output assert.Equal(t, string(expectedBytes), string(actualYAML), "Inlined document should match expected output") } - -func TestInline_AdditionalOperations_Success(t *testing.T) { - t.Parallel() - - ctx := t.Context() - - // Load the input document with additionalOperations - inputFile, err := os.Open("testdata/inline/additionaloperations_input.yaml") - require.NoError(t, err) - defer inputFile.Close() - - inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) - require.NoError(t, err) - require.Empty(t, validationErrs, "Input document should be valid") - - // Configure inlining options - opts := openapi.InlineOptions{ - ResolveOptions: openapi.ResolveOptions{ - RootDocument: inputDoc, - TargetLocation: "testdata/inline/additionaloperations_input.yaml", - }, - RemoveUnusedComponents: true, - } - - // Inline all references - err = openapi.Inline(ctx, inputDoc, opts) - require.NoError(t, err) - - // Marshal the inlined document to YAML - var buf bytes.Buffer - err = openapi.Marshal(ctx, inputDoc, &buf) - require.NoError(t, err) - actualYAML := buf.String() - - // Verify that additionalOperations are preserved - assert.Contains(t, actualYAML, "additionalOperations:", "additionalOperations should be preserved") - - // Verify that external references in additionalOperations were inlined - assert.NotContains(t, actualYAML, "$ref:", "No references should remain after inlining") - assert.NotContains(t, actualYAML, "external_custom_operations.yaml", "No external file references should remain") - - // Verify that the COPY operation has inlined content - assert.Contains(t, actualYAML, "COPY:", "COPY operation should be present") - assert.Contains(t, actualYAML, "operationId: copyResource", "COPY operation content should be inlined") - - // Verify that external parameter was inlined in COPY operation - copyOperationSection := extractAdditionalOperationSection(actualYAML, "COPY") - assert.Contains(t, copyOperationSection, "name: destination", "DestinationParam should be inlined") - assert.Contains(t, copyOperationSection, "in: header", "DestinationParam should be inlined") - - // Verify that external request body was inlined in COPY operation - assert.Contains(t, copyOperationSection, "source_path:", "CopyRequest schema should be inlined") - assert.Contains(t, copyOperationSection, "destination_path:", "CopyRequest schema should be inlined") - - // Verify that the PURGE operation has inlined content - assert.Contains(t, actualYAML, "PURGE:", "PURGE operation should be present") - assert.Contains(t, actualYAML, "operationId: purgeResource", "PURGE operation content should be inlined") - - // Verify that external parameter was inlined in PURGE operation - purgeOperationSection := extractAdditionalOperationSection(actualYAML, "PURGE") - assert.Contains(t, purgeOperationSection, "name: X-Confirm-Purge", "ConfirmationParam should be inlined") - assert.Contains(t, purgeOperationSection, "pattern: ^CONFIRM-[A-Z0-9]{8}$", "ConfirmationParam schema should be inlined") - - // Verify that the SYNC operation has inlined content - assert.Contains(t, actualYAML, "SYNC:", "SYNC operation should be present") - assert.Contains(t, actualYAML, "operationId: syncResource", "SYNC operation content should be inlined") - - // Verify that external schemas were inlined in SYNC operation - syncOperationSection := extractAdditionalOperationSection(actualYAML, "SYNC") - assert.Contains(t, syncOperationSection, "source:", "SyncConfig schema should be inlined") - assert.Contains(t, syncOperationSection, "destination:", "SyncConfig schema should be inlined") - assert.Contains(t, syncOperationSection, "sync_id:", "SyncResult schema should be inlined") - assert.Contains(t, syncOperationSection, "files_synced:", "SyncResult schema should be inlined") - - // Verify that the BATCH operation has inlined content - assert.Contains(t, actualYAML, "BATCH:", "BATCH operation should be present") - assert.Contains(t, actualYAML, "operationId: batchProcess", "BATCH operation content should be inlined") - - // Verify that nested external schemas were properly inlined - batchOperationSection := extractAdditionalOperationSection(actualYAML, "BATCH") - assert.Contains(t, batchOperationSection, "parallel_execution:", "BatchConfig schema should be inlined") - assert.Contains(t, batchOperationSection, "batch_id:", "BatchResult schema should be inlined") - assert.Contains(t, batchOperationSection, "max_attempts:", "RetryPolicy schema should be inlined") - - // Verify components section was removed (since RemoveUnusedComponents is true) - // Note: Some components might remain if they're still referenced from the main document - if !assert.NotContains(t, actualYAML, "components:", "Components section should be removed after inlining") { - // If components section exists, ensure it doesn't contain the external schemas - assert.NotContains(t, actualYAML, "ResourceMetadata:", "External ResourceMetadata should not be in components after inlining") - assert.NotContains(t, actualYAML, "SyncConfig:", "External SyncConfig should not be in components after inlining") - } -} - -// Helper function to extract a specific additionalOperation section from YAML -func extractAdditionalOperationSection(yamlContent, operationName string) string { - lines := strings.Split(yamlContent, "\n") - var sectionLines []string - inTargetOperation := false - indentLevel := -1 - - for _, line := range lines { - if strings.Contains(line, operationName+":") && strings.Contains(line, "additionalOperations") == false { - inTargetOperation = true - indentLevel = len(line) - len(strings.TrimLeft(line, " ")) - sectionLines = append(sectionLines, line) - continue - } - - if inTargetOperation { - currentIndent := len(line) - len(strings.TrimLeft(line, " ")) - // If we hit a line at the same or lower indent level, we've left the operation - if strings.TrimSpace(line) != "" && currentIndent <= indentLevel { - break - } - sectionLines = append(sectionLines, line) - } - } - - return strings.Join(sectionLines, "\n") -} diff --git a/openapi/paths.go b/openapi/paths.go index fcef5e1..f756884 100644 --- a/openapi/paths.go +++ b/openapi/paths.go @@ -287,9 +287,11 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er o := validation.NewOptions(opts...) - openapi := validation.GetContextObject[OpenAPI](o) - if openapi == nil { - panic("OpenAPI is required") + oa := validation.GetContextObject[OpenAPI](o) + // If OpenAPI object is not provided, assume the latest version + openapiVersion := Version + if oa != nil { + openapiVersion = oa.OpenAPI } for _, op := range p.All() { @@ -304,7 +306,7 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, parameter.Validate(ctx, opts...)...) } - supportsAdditionalOperations, err := version.IsVersionGreaterOrEqual(openapi.OpenAPI, "3.2.0") + supportsAdditionalOperations, err := version.IsVersionGreaterOrEqual(openapiVersion, Version) switch { case err != nil: errs = append(errs, err) @@ -327,7 +329,7 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er case !supportsAdditionalOperations: if core.AdditionalOperations.Present { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("additionalOperations is not supported in OpenAPI version %s", openapi.OpenAPI), core, core.AdditionalOperations)) + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("additionalOperations is not supported in OpenAPI version %s", openapiVersion), core, core.AdditionalOperations)) } } diff --git a/openapi/tag.go b/openapi/tag.go index ad77b30..bba656f 100644 --- a/openapi/tag.go +++ b/openapi/tag.go @@ -103,17 +103,14 @@ func (t *Tag) Validate(ctx context.Context, opts ...validation.Option) []error { errs = append(errs, t.ExternalDocs.Validate(ctx, opts...)...) } - t.Valid = len(errs) == 0 && core.GetValid() - - return errs -} + // Get OpenAPI object from validation options to check parent relationships + o := validation.NewOptions(opts...) + openapi := validation.GetContextObject[OpenAPI](o) -// ValidateWithTags validates the Tag object in the context of all tags to check for parent relationships. -// This should be called during document-level validation where all tags are available. -func (t *Tag) ValidateWithTags(ctx context.Context, allTags []*Tag, opts ...validation.Option) []error { - errs := t.Validate(ctx, opts...) + // If we have an OpenAPI object with tags, validate parent relationships + if openapi != nil && openapi.Tags != nil && t.Parent != nil && *t.Parent != "" { + allTags := openapi.Tags - if t.Parent != nil && *t.Parent != "" { // Check if parent tag exists parentExists := false for _, tag := range allTags { @@ -124,7 +121,6 @@ func (t *Tag) ValidateWithTags(ctx context.Context, allTags []*Tag, opts ...vali } if !parentExists { - core := t.GetCore() errs = append(errs, validation.NewValueError( validation.NewMissingValueError("parent tag '%s' does not exist", *t.Parent), core, core.Parent)) @@ -132,13 +128,14 @@ func (t *Tag) ValidateWithTags(ctx context.Context, allTags []*Tag, opts ...vali // Check for circular references if t.hasCircularParentReference(allTags, make(map[string]bool)) { - core := t.GetCore() errs = append(errs, validation.NewValueError( validation.NewValueValidationError("circular parent reference detected for tag '%s'", t.Name), core, core.Parent)) } } + t.Valid = len(errs) == 0 && core.GetValid() + return errs } diff --git a/openapi/tag_kind_registry.go b/openapi/tag_kind_registry.go index cf7c2f2..74a88db 100644 --- a/openapi/tag_kind_registry.go +++ b/openapi/tag_kind_registry.go @@ -1,10 +1,14 @@ package openapi +import "fmt" + // TagKind represents commonly used values for the Tag.Kind field. // These values are registered in the OpenAPI Initiative's Tag Kind Registry // at https://spec.openapis.org/registry/tag-kind/ type TagKind string +var _ fmt.Stringer = (*TagKind)(nil) + // Officially registered Tag Kind values from the OpenAPI Initiative registry const ( // TagKindNav represents tags used for navigation purposes diff --git a/openapi/tag_validate_test.go b/openapi/tag_validate_test.go index 3cc5b99..95a5a22 100644 --- a/openapi/tag_validate_test.go +++ b/openapi/tag_validate_test.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/validation" "github.com/stretchr/testify/require" ) @@ -208,8 +209,18 @@ func TestTag_ValidateWithTags_ParentRelationships_Success(t *testing.T) { allTags := []*openapi.Tag{catalogTag, productsTag, booksTag, standaloneTag} + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + for _, tag := range allTags { - errs := tag.ValidateWithTags(t.Context(), allTags) + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) require.Empty(t, errs, "expected no validation errors for tag %s", tag.Name) } } @@ -221,7 +232,17 @@ func TestTag_ValidateWithTags_ParentNotFound_Error(t *testing.T) { tag := &openapi.Tag{Name: "orphan", Parent: &[]string{"nonexistent"}[0]} allTags := []*openapi.Tag{tag} - errs := tag.ValidateWithTags(t.Context(), allTags) + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) require.NotEmpty(t, errs, "expected validation errors") found := false @@ -272,10 +293,20 @@ func TestTag_ValidateWithTags_CircularReference_Error(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: tt.tags, + } + // Check each tag that has a parent for _, tag := range tt.tags { if tag.Parent != nil { - errs := tag.ValidateWithTags(t.Context(), tt.tags) + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) require.NotEmpty(t, errs, "expected validation errors for %s", tt.desc) found := false @@ -312,8 +343,18 @@ func TestTag_ValidateWithTags_ComplexHierarchy_Success(t *testing.T) { allTags := []*openapi.Tag{catalogTag, productsTag, booksTag, cdsTag, servicesTag, deliveryTag} + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + for _, tag := range allTags { - errs := tag.ValidateWithTags(t.Context(), allTags) + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) require.Empty(t, errs, "expected no validation errors for tag %s", tag.Name) } } diff --git a/openapi/testdata/bootstrap_expected.yaml b/openapi/testdata/bootstrap_expected.yaml index 95d9eef..bfc5946 100644 --- a/openapi/testdata/bootstrap_expected.yaml +++ b/openapi/testdata/bootstrap_expected.yaml @@ -19,15 +19,6 @@ tags: description: "User API documentation" url: "https://docs.example.com/users" kind: "nav" - - name: "admin" - summary: "Admin" - description: "Administrative operations" - parent: "users" - kind: "nav" - - name: "beta-features" - summary: "Beta" - description: "Experimental features" - kind: "badge" servers: - url: "https://api.example.com/v1" description: "Production server" diff --git a/openapi/testdata/inline/bundled_counter_expected.yaml b/openapi/testdata/inline/bundled_counter_expected.yaml index b9074c4..cf9b92a 100644 --- a/openapi/testdata/inline/bundled_counter_expected.yaml +++ b/openapi/testdata/inline/bundled_counter_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -100,6 +100,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "#/components/parameters/DestinationParam" + requestBody: + $ref: "#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts @@ -380,6 +396,15 @@ components: schema: type: string format: uuid + DestinationParam: + name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" ComplexFilterParam: name: filter in: query @@ -572,6 +597,44 @@ components: required: - userId - username + CompressionConfig: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + ResourceMetadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true Organization: type: object properties: @@ -887,6 +950,34 @@ components: format: email profile: $ref: "#/components/schemas/UserProfile" + CopyRequest: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + $ref: '#/components/schemas/CompressionConfig' + required: + - source_path + - destination_path + required: true responses: UserResponse: description: Single user response @@ -923,6 +1014,35 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + CopyResponse: + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + $ref: '#/components/schemas/ResourceMetadata' + created_at: + type: string + format: date-time + required: + - copy_id + - status examples: UserExample: summary: Example user diff --git a/openapi/testdata/inline/bundled_expected.yaml b/openapi/testdata/inline/bundled_expected.yaml index 1a285a3..1924fa6 100644 --- a/openapi/testdata/inline/bundled_expected.yaml +++ b/openapi/testdata/inline/bundled_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -100,6 +100,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "#/components/parameters/DestinationParam" + requestBody: + $ref: "#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts @@ -380,6 +396,15 @@ components: schema: type: string format: uuid + DestinationParam: + name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" ComplexFilterParam: name: filter in: query @@ -572,6 +597,44 @@ components: required: - userId - username + CompressionConfig: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + ResourceMetadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true Organization: type: object properties: @@ -887,6 +950,34 @@ components: format: email profile: $ref: "#/components/schemas/UserProfile" + CopyRequest: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + $ref: '#/components/schemas/CompressionConfig' + required: + - source_path + - destination_path + required: true responses: UserResponse: description: Single user response @@ -923,6 +1014,35 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + CopyResponse: + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + $ref: '#/components/schemas/ResourceMetadata' + created_at: + type: string + format: date-time + required: + - copy_id + - status examples: UserExample: summary: Example user diff --git a/openapi/testdata/inline/inline_expected.yaml b/openapi/testdata/inline/inline_expected.yaml index f541b85..cc15dc2 100644 --- a/openapi/testdata/inline/inline_expected.yaml +++ b/openapi/testdata/inline/inline_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -386,6 +386,134 @@ paths: tags: ["introduction", "first-post"] created_at: "2023-01-01T12:00:00Z" updated_at: "2023-01-01T12:00:00Z" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" + requestBody: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + required: + - source_path + - destination_path + required: true + responses: + "201": + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + required: + - copy_id + - status + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message post: tags: - posts diff --git a/openapi/testdata/inline/inline_input.yaml b/openapi/testdata/inline/inline_input.yaml index 42cf0bb..6873423 100644 --- a/openapi/testdata/inline/inline_input.yaml +++ b/openapi/testdata/inline/inline_input.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -106,6 +106,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "external_custom_operations.yaml#/components/parameters/DestinationParam" + requestBody: + $ref: "external_custom_operations.yaml#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "external_custom_operations.yaml#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts From 982d09abeea48eb9ea2f68073d7559b72bf6ea1d Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 09:25:07 +1000 Subject: [PATCH 12/26] feat: add support for itemSchema to media type --- openapi/bundle.go | 18 +++++-- openapi/core/mediatype.go | 1 + openapi/mediatype.go | 16 +++++- openapi/mediatype_validate_test.go | 36 ++++++++++++++ openapi/testdata/walk.openapi.yaml | 7 +++ openapi/walk.go | 5 ++ openapi/walk_test.go | 78 +++++++++++++++++++++--------- 7 files changed, 132 insertions(+), 29 deletions(-) diff --git a/openapi/bundle.go b/openapi/bundle.go index 7ad818c..959f036 100644 --- a/openapi/bundle.go +++ b/openapi/bundle.go @@ -450,8 +450,13 @@ func walkAndUpdateRefsInResponse(_ context.Context, response *ReferencedResponse // Walk through the response's content schemas if response.Object.Content != nil { for _, mediaType := range response.Object.Content.All() { - if mediaType != nil && mediaType.Schema != nil && mediaType.Schema.IsReference() { - updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + if mediaType != nil { + if mediaType.Schema != nil && mediaType.Schema.IsReference() { + updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + } + if mediaType.ItemSchema != nil && mediaType.ItemSchema.IsReference() { + updateSchemaRefWithSource(mediaType.ItemSchema, componentStorage, sourceLocation) + } } } } @@ -491,8 +496,13 @@ func walkAndUpdateRefsInRequestBody(_ context.Context, body *ReferencedRequestBo // Walk through the request body's content schemas if body.Object.Content != nil { for _, mediaType := range body.Object.Content.All() { - if mediaType != nil && mediaType.Schema != nil && mediaType.Schema.IsReference() { - updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + if mediaType != nil { + if mediaType.Schema != nil && mediaType.Schema.IsReference() { + updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + } + if mediaType.ItemSchema != nil && mediaType.ItemSchema.IsReference() { + updateSchemaRefWithSource(mediaType.ItemSchema, componentStorage, sourceLocation) + } } } } diff --git a/openapi/core/mediatype.go b/openapi/core/mediatype.go index dcec036..8b0826a 100644 --- a/openapi/core/mediatype.go +++ b/openapi/core/mediatype.go @@ -12,6 +12,7 @@ type MediaType struct { marshaller.CoreModel `model:"mediaType"` Schema marshaller.Node[oascore.JSONSchema] `key:"schema"` + ItemSchema marshaller.Node[oascore.JSONSchema] `key:"itemSchema"` Encoding marshaller.Node[*sequencedmap.Map[string, *Encoding]] `key:"encoding"` Example marshaller.Node[values.Value] `key:"example"` Examples marshaller.Node[*sequencedmap.Map[string, *Reference[*Example]]] `key:"examples"` diff --git a/openapi/mediatype.go b/openapi/mediatype.go index ecf1668..32eee96 100644 --- a/openapi/mediatype.go +++ b/openapi/mediatype.go @@ -16,8 +16,10 @@ import ( type MediaType struct { marshaller.Model[core.MediaType] - // Schema is the schema defining the type used for the parameter. + // Schema is the schema defining the type used for the media type. Schema *oas3.JSONSchema[oas3.Referenceable] + // ItemSchema is a schema describing each item within a sequential media type like text/event-stream. + ItemSchema *oas3.JSONSchema[oas3.Referenceable] // Encoding is a map allowing for more complex encoding scenarios. Encoding *sequencedmap.Map[string, *Encoding] // Example is an example of the media type's value. @@ -37,6 +39,14 @@ func (m *MediaType) GetSchema() *oas3.JSONSchema[oas3.Referenceable] { return m.Schema } +// GetItemSchema returns the value of the ItemSchema field. Returns nil if not set. +func (m *MediaType) GetItemSchema() *oas3.JSONSchema[oas3.Referenceable] { + if m == nil { + return nil + } + return m.ItemSchema +} + // GetEncoding returns the value of the Encoding field. Returns nil if not set. func (m *MediaType) GetEncoding() *sequencedmap.Map[string, *Encoding] { if m == nil { @@ -70,6 +80,10 @@ func (m *MediaType) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, m.Schema.Validate(ctx, opts...)...) } + if core.ItemSchema.Present { + errs = append(errs, m.ItemSchema.Validate(ctx, opts...)...) + } + for _, obj := range m.Examples.All() { errs = append(errs, obj.Validate(ctx, opts...)...) } diff --git a/openapi/mediatype_validate_test.go b/openapi/mediatype_validate_test.go index 27b8c02..113afe4 100644 --- a/openapi/mediatype_validate_test.go +++ b/openapi/mediatype_validate_test.go @@ -99,6 +99,42 @@ schema: type: string x-test: some-value x-custom: custom-data +`, + }, + { + name: "valid media type with itemSchema only", + yml: ` +itemSchema: + type: object + properties: + id: + type: integer + name: + type: string +`, + }, + { + name: "valid media type with itemSchema and example", + yml: ` +itemSchema: + type: string +example: "hello world" +`, + }, + { + name: "valid media type with itemSchema and examples", + yml: ` +itemSchema: + $ref: "#/components/schemas/User" +examples: + user1: + value: + id: 1 + name: John + user2: + value: + id: 2 + name: Jane `, }, } diff --git a/openapi/testdata/walk.openapi.yaml b/openapi/testdata/walk.openapi.yaml index 7d1a2fc..67f2bfa 100644 --- a/openapi/testdata/walk.openapi.yaml +++ b/openapi/testdata/walk.openapi.yaml @@ -324,6 +324,13 @@ paths: application/json: schema: $ref: "#/components/schemas/User" + "202": + description: Batch user creation accepted + content: + application/json: + itemSchema: + $ref: "#/components/schemas/User" + x-custom: batch-create-response-content-extension "400": description: Invalid user data x-custom: create-user-operation-extension diff --git a/openapi/walk.go b/openapi/walk.go index f19428c..b6c5acc 100644 --- a/openapi/walk.go +++ b/openapi/walk.go @@ -549,6 +549,11 @@ func walkMediaType(ctx context.Context, mediaType *MediaType, loc []LocationCont return false } + // Walk through item schema + if !walkSchema(ctx, mediaType.ItemSchema, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "itemSchema"}), openAPI, yield) { + return false + } + // Walk through encoding if !walkEncodings(ctx, mediaType.Encoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "encoding"}), openAPI, yield) { return false diff --git a/openapi/walk_test.go b/openapi/walk_test.go index a97f8aa..9bf3a88 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -607,38 +607,68 @@ func TestWalkSchema_Success(t *testing.T) { func TestWalkMediaType_Success(t *testing.T) { t.Parallel() - openAPIDoc, err := loadOpenAPIDocument(t.Context()) - require.NoError(t, err) + tests := []struct { + name string + location string + assertMediaType func(t *testing.T, mt *openapi.MediaType) + }{ + { + name: "media type with schema", + location: "/paths/~1users~1{id}/get/requestBody/content/application~1json", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + assert.NotNil(t, mt.Schema, "Schema should not be nil") + assert.True(t, mt.Schema.IsLeft() || mt.Schema.IsRight(), "Schema should be Left or Right") + assert.Nil(t, mt.ItemSchema, "ItemSchema should be nil when Schema is present") + }, + }, + { + name: "media type with itemSchema", + location: "/paths/~1users/post/responses/202/content/application~1json", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + assert.Nil(t, mt.Schema, "Schema should be nil when ItemSchema is present") + assert.NotNil(t, mt.ItemSchema, "ItemSchema should not be nil") + assert.True(t, mt.ItemSchema.IsLeft() || mt.ItemSchema.IsRight(), "ItemSchema should be Left or Right") + }, + }, + } - matchedLocations := []string{} - expectedLoc := "/paths/~1users~1{id}/get/requestBody/content/application~1json" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() - for item := range openapi.Walk(t.Context(), openAPIDoc) { - err := item.Match(openapi.Matcher{ - MediaType: func(mt *openapi.MediaType) error { - mediaTypeLoc := string(item.Location.ToJSONPointer()) - matchedLocations = append(matchedLocations, mediaTypeLoc) + openAPIDoc, err := loadOpenAPIDocument(ctx) + require.NoError(t, err) - if mediaTypeLoc == expectedLoc { - assert.NotNil(t, mt.Schema) - // Schema could be either Left (direct schema) or Right (reference) - // Just verify it exists - assert.True(t, mt.Schema.IsLeft() || mt.Schema.IsRight()) + matchedLocations := []string{} + found := false - return walk.ErrTerminate + for item := range openapi.Walk(ctx, openAPIDoc) { + err := item.Match(openapi.Matcher{ + MediaType: func(mt *openapi.MediaType) error { + mediaTypeLoc := string(item.Location.ToJSONPointer()) + matchedLocations = append(matchedLocations, mediaTypeLoc) + + if mediaTypeLoc == tt.location { + tt.assertMediaType(t, mt) + found = true + return walk.ErrTerminate + } + + return nil + }, + }) + + if errors.Is(err, walk.ErrTerminate) { + break } + require.NoError(t, err) + } - return nil - }, + assert.True(t, found, "Should find MediaType at location: %s", tt.location) + assert.Contains(t, matchedLocations, tt.location, "Should visit MediaType at location: %s", tt.location) }) - - if errors.Is(err, walk.ErrTerminate) { - break - } - require.NoError(t, err) } - - assert.Contains(t, matchedLocations, expectedLoc) } func TestWalkComponents_Success(t *testing.T) { From 9fd73f857e58b90aed30d870a8796955b4f5d8e2 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 09:35:43 +1000 Subject: [PATCH 13/26] fix --- openapi/walk_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openapi/walk_test.go b/openapi/walk_test.go index 9bf3a88..0075af1 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -616,6 +616,7 @@ func TestWalkMediaType_Success(t *testing.T) { name: "media type with schema", location: "/paths/~1users~1{id}/get/requestBody/content/application~1json", assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() assert.NotNil(t, mt.Schema, "Schema should not be nil") assert.True(t, mt.Schema.IsLeft() || mt.Schema.IsRight(), "Schema should be Left or Right") assert.Nil(t, mt.ItemSchema, "ItemSchema should be nil when Schema is present") @@ -625,6 +626,7 @@ func TestWalkMediaType_Success(t *testing.T) { name: "media type with itemSchema", location: "/paths/~1users/post/responses/202/content/application~1json", assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() assert.Nil(t, mt.Schema, "Schema should be nil when ItemSchema is present") assert.NotNil(t, mt.ItemSchema, "ItemSchema should not be nil") assert.True(t, mt.ItemSchema.IsLeft() || mt.ItemSchema.IsRight(), "ItemSchema should be Left or Right") From 6c07e23894bfda74ca38770d41984b080b863a33 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 10:16:21 +1000 Subject: [PATCH 14/26] feat: add prefixEncoding and itemEncoding support for OpenAPI 3.2 multipart media types --- openapi/core/mediatype.go | 14 +- openapi/header.go | 7 +- openapi/mediatype.go | 83 ++++++++ openapi/mediatype_multipart_validate_test.go | 187 +++++++++++++++++++ openapi/mediatype_validate_test.go | 76 ++++++++ openapi/parameter.go | 7 +- openapi/requests.go | 7 +- openapi/responses.go | 7 +- openapi/testdata/walk.openapi.yaml | 47 +++-- openapi/walk.go | 30 +++ openapi/walk_test.go | 22 +++ 11 files changed, 461 insertions(+), 26 deletions(-) create mode 100644 openapi/mediatype_multipart_validate_test.go diff --git a/openapi/core/mediatype.go b/openapi/core/mediatype.go index 8b0826a..d6c3a0d 100644 --- a/openapi/core/mediatype.go +++ b/openapi/core/mediatype.go @@ -11,10 +11,12 @@ import ( type MediaType struct { marshaller.CoreModel `model:"mediaType"` - Schema marshaller.Node[oascore.JSONSchema] `key:"schema"` - ItemSchema marshaller.Node[oascore.JSONSchema] `key:"itemSchema"` - Encoding marshaller.Node[*sequencedmap.Map[string, *Encoding]] `key:"encoding"` - Example marshaller.Node[values.Value] `key:"example"` - Examples marshaller.Node[*sequencedmap.Map[string, *Reference[*Example]]] `key:"examples"` - Extensions core.Extensions `key:"extensions"` + Schema marshaller.Node[oascore.JSONSchema] `key:"schema"` + ItemSchema marshaller.Node[oascore.JSONSchema] `key:"itemSchema"` + Encoding marshaller.Node[*sequencedmap.Map[string, *Encoding]] `key:"encoding"` + PrefixEncoding marshaller.Node[[]*Encoding] `key:"prefixEncoding"` + ItemEncoding marshaller.Node[*Encoding] `key:"itemEncoding"` + Example marshaller.Node[values.Value] `key:"example"` + Examples marshaller.Node[*sequencedmap.Map[string, *Reference[*Example]]] `key:"examples"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/header.go b/openapi/header.go index e797459..36591af 100644 --- a/openapi/header.go +++ b/openapi/header.go @@ -139,8 +139,11 @@ func (h *Header) Validate(ctx context.Context, opts ...validation.Option) []erro errs = append(errs, h.Schema.Validate(ctx, opts...)...) } - for _, obj := range h.Content.All() { - errs = append(errs, obj.Validate(ctx, opts...)...) + for mediaType, obj := range h.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, obj.Validate(ctx, contentOpts...)...) } for _, obj := range h.Examples.All() { diff --git a/openapi/mediatype.go b/openapi/mediatype.go index 32eee96..6cc46c9 100644 --- a/openapi/mediatype.go +++ b/openapi/mediatype.go @@ -2,6 +2,7 @@ package openapi import ( "context" + "strings" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/jsonschema/oas3" @@ -12,6 +13,11 @@ import ( "github.com/speakeasy-api/openapi/values" ) +// MediaTypeContext holds the media type string for validation purposes +type MediaTypeContext struct { + MediaType string +} + // MediaType provides a schema and examples for the associated media type. type MediaType struct { marshaller.Model[core.MediaType] @@ -22,6 +28,12 @@ type MediaType struct { ItemSchema *oas3.JSONSchema[oas3.Referenceable] // Encoding is a map allowing for more complex encoding scenarios. Encoding *sequencedmap.Map[string, *Encoding] + // PrefixEncoding provides positional encoding information for multipart content (OpenAPI 3.2+). + // This field SHALL only apply when the media type is multipart. This field MUST NOT be present if encoding is present. + PrefixEncoding []*Encoding + // ItemEncoding provides encoding information for array items in multipart content (OpenAPI 3.2+). + // This field SHALL only apply when the media type is multipart. This field MUST NOT be present if encoding is present. + ItemEncoding *Encoding // Example is an example of the media type's value. Example values.Value // Examples is a map of examples of the media type's value. @@ -55,6 +67,22 @@ func (m *MediaType) GetEncoding() *sequencedmap.Map[string, *Encoding] { return m.Encoding } +// GetPrefixEncoding returns the value of the PrefixEncoding field. Returns nil if not set. +func (m *MediaType) GetPrefixEncoding() []*Encoding { + if m == nil { + return nil + } + return m.PrefixEncoding +} + +// GetItemEncoding returns the value of the ItemEncoding field. Returns nil if not set. +func (m *MediaType) GetItemEncoding() *Encoding { + if m == nil { + return nil + } + return m.ItemEncoding +} + // GetExamples returns the value of the Examples field. Returns nil if not set. func (m *MediaType) GetExamples() *sequencedmap.Map[string, *ReferencedExample] { if m == nil { @@ -92,6 +120,61 @@ func (m *MediaType) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, obj.Validate(ctx, opts...)...) } + // Validate prefixEncoding field if present + if core.PrefixEncoding.Present { + for _, enc := range m.PrefixEncoding { + if enc != nil { + errs = append(errs, enc.Validate(ctx, opts...)...) + } + } + } + + // Validate itemEncoding field if present + if core.ItemEncoding.Present { + errs = append(errs, m.ItemEncoding.Validate(ctx, opts...)...) + } + + // Validate mutual exclusivity: encoding MUST NOT be present with prefixEncoding or itemEncoding + if core.Encoding.Present && (core.PrefixEncoding.Present || core.ItemEncoding.Present) { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("encoding field MUST NOT be present when prefixEncoding or itemEncoding is present"), + core, + core.Encoding, + )) + } + + // Validate multipart-only constraint for encoding, prefixEncoding, and itemEncoding + o := validation.NewOptions(opts...) + mtCtx := validation.GetContextObject[MediaTypeContext](o) + if mtCtx != nil && mtCtx.MediaType != "" { + isMultipart := strings.HasPrefix(strings.ToLower(mtCtx.MediaType), "multipart/") + isFormURLEncoded := strings.ToLower(mtCtx.MediaType) == "application/x-www-form-urlencoded" + + if core.PrefixEncoding.Present && !isMultipart { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("prefixEncoding field SHALL only apply when the media type is multipart"), + core, + core.PrefixEncoding, + )) + } + + if core.ItemEncoding.Present && !isMultipart { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("itemEncoding field SHALL only apply when the media type is multipart"), + core, + core.ItemEncoding, + )) + } + + if core.Encoding.Present && !isMultipart && !isFormURLEncoded { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("encoding field SHALL only apply when the media type is multipart or application/x-www-form-urlencoded"), + core, + core.Encoding, + )) + } + } + m.Valid = len(errs) == 0 && core.GetValid() return errs diff --git a/openapi/mediatype_multipart_validate_test.go b/openapi/mediatype_multipart_validate_test.go new file mode 100644 index 0000000..03a66e3 --- /dev/null +++ b/openapi/mediatype_multipart_validate_test.go @@ -0,0 +1,187 @@ +package openapi_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMediaType_MultipartValidation_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + }{ + { + name: "prefixEncoding with multipart/mixed", + yml: ` +description: Test response +content: + multipart/mixed: + schema: + type: array + prefixItems: + - type: object + - type: string + prefixEncoding: + - contentType: application/json + - contentType: text/plain +`, + }, + { + name: "itemEncoding with multipart/form-data", + yml: ` +description: Test response +content: + multipart/form-data: + itemSchema: + type: object + itemEncoding: + contentType: application/json +`, + }, + { + name: "encoding with multipart/form-data", + yml: ` +description: Test response +content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + encoding: + file: + contentType: image/png +`, + }, + { + name: "encoding with application/x-www-form-urlencoded", + yml: ` +description: Test response +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + type: string + encoding: + name: + contentType: text/plain +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var response openapi.Response + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &response) + require.NoError(t, err, "unmarshal should succeed") + require.Empty(t, validationErrs, "unmarshal validation should succeed") + + errs := response.Validate(t.Context()) + require.Empty(t, errs, "validation should succeed") + require.True(t, response.Valid, "response should be valid") + }) + } +} + +func TestMediaType_MultipartValidation_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + wantErrs []string + }{ + { + name: "prefixEncoding with non-multipart media type", + yml: ` +description: Test response +content: + application/json: + schema: + type: array + prefixEncoding: + - contentType: application/json +`, + wantErrs: []string{ + "prefixEncoding field SHALL only apply when the media type is multipart", + }, + }, + { + name: "itemEncoding with non-multipart media type", + yml: ` +description: Test response +content: + application/json: + itemSchema: + type: object + itemEncoding: + contentType: application/json +`, + wantErrs: []string{ + "itemEncoding field SHALL only apply when the media type is multipart", + }, + }, + { + name: "encoding with non-multipart non-form-urlencoded media type", + yml: ` +description: Test response +content: + application/json: + schema: + type: object + properties: + file: + type: string + encoding: + file: + contentType: image/png +`, + wantErrs: []string{ + "encoding field SHALL only apply when the media type is multipart or application/x-www-form-urlencoded", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var response openapi.Response + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &response) + require.NoError(t, err, "unmarshal should succeed") + require.Empty(t, validationErrs, "unmarshal validation should succeed") + + errs := response.Validate(t.Context()) + require.NotEmpty(t, errs, "validation should fail") + require.False(t, response.Valid, "response should be invalid") + + var errMessages []string + for _, err := range errs { + errMessages = append(errMessages, err.Error()) + } + + for _, wantErr := range tt.wantErrs { + found := false + for _, msg := range errMessages { + if assert.Contains(t, msg, wantErr) { + found = true + break + } + } + assert.True(t, found, "expected error message not found: %s", wantErr) + } + }) + } +} diff --git a/openapi/mediatype_validate_test.go b/openapi/mediatype_validate_test.go index 113afe4..eace23f 100644 --- a/openapi/mediatype_validate_test.go +++ b/openapi/mediatype_validate_test.go @@ -135,6 +135,46 @@ examples: value: id: 2 name: Jane +`, + }, + { + name: "valid media type with prefixEncoding", + yml: ` +schema: + type: array + prefixItems: + - type: object + - type: string +prefixEncoding: + - contentType: application/json + - contentType: text/plain +`, + }, + { + name: "valid media type with itemEncoding", + yml: ` +itemSchema: + type: object + properties: + id: + type: integer +itemEncoding: + contentType: application/json +`, + }, + { + name: "valid media type with both prefixEncoding and itemEncoding", + yml: ` +schema: + type: array + prefixItems: + - type: object + items: + type: string +prefixEncoding: + - contentType: application/json +itemEncoding: + contentType: text/plain `, }, } @@ -184,6 +224,42 @@ encoding: "[13:17] schema.type expected array, got string", }, }, + { + name: "encoding and prefixEncoding cannot coexist", + yml: ` +schema: + type: object + properties: + file: + type: string +encoding: + file: + contentType: image/png +prefixEncoding: + - contentType: application/json +`, + wantErrs: []string{ + "[8:3] encoding field MUST NOT be present when prefixEncoding or itemEncoding is present", + }, + }, + { + name: "encoding and itemEncoding cannot coexist", + yml: ` +schema: + type: object + properties: + file: + type: string +encoding: + file: + contentType: image/png +itemEncoding: + contentType: application/json +`, + wantErrs: []string{ + "[8:3] encoding field MUST NOT be present when prefixEncoding or itemEncoding is present", + }, + }, } for _, tt := range tests { diff --git a/openapi/parameter.go b/openapi/parameter.go index 85896c1..68304dc 100644 --- a/openapi/parameter.go +++ b/openapi/parameter.go @@ -280,8 +280,11 @@ func (p *Parameter) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, validation.NewValueError(validation.NewValueValidationError("parameter field content must have exactly one entry"), core, core.Content)) } - for _, obj := range p.Content.All() { - errs = append(errs, obj.Validate(ctx, opts...)...) + for mediaType, obj := range p.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, obj.Validate(ctx, contentOpts...)...) } for _, obj := range p.Examples.All() { diff --git a/openapi/requests.go b/openapi/requests.go index 2927d0f..0756000 100644 --- a/openapi/requests.go +++ b/openapi/requests.go @@ -60,8 +60,11 @@ func (r *RequestBody) Validate(ctx context.Context, opts ...validation.Option) [ errs = append(errs, validation.NewValueError(validation.NewMissingValueError("requestBody.content is required"), core, core.Content)) } - for _, content := range r.Content.All() { - errs = append(errs, content.Validate(ctx, opts...)...) + for mediaType, content := range r.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, content.Validate(ctx, contentOpts...)...) } r.Valid = len(errs) == 0 && core.GetValid() diff --git a/openapi/responses.go b/openapi/responses.go index 65d8b03..6b6cf05 100644 --- a/openapi/responses.go +++ b/openapi/responses.go @@ -198,8 +198,11 @@ func (r *Response) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, header.Validate(ctx, opts...)...) } - for _, content := range r.GetContent().All() { - errs = append(errs, content.Validate(ctx, opts...)...) + for mediaType, content := range r.GetContent().All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, content.Validate(ctx, contentOpts...)...) } for _, link := range r.GetLinks().All() { diff --git a/openapi/testdata/walk.openapi.yaml b/openapi/testdata/walk.openapi.yaml index 67f2bfa..a6b5b38 100644 --- a/openapi/testdata/walk.openapi.yaml +++ b/openapi/testdata/walk.openapi.yaml @@ -105,20 +105,14 @@ paths: in: query description: Fields to include required: false - schema: - type: string - description: Comma-separated list of fields content: - application/json: + multipart/form-data: schema: - type: string - description: JSON string parameter - examples: - include-example: - summary: Include example - description: Example include parameter - value: "name,email" - x-custom: operation-parameter-example-extension + type: object + properties: + profileImage: + type: string + format: binary encoding: profileImage: contentType: image/png @@ -130,6 +124,13 @@ paths: description: Rate limit value x-custom: encoding-header-extension x-custom: encoding-extension + examples: + include-example: + summary: Include example + description: Example include parameter + value: + profileImage: + x-custom: operation-parameter-example-extension x-custom: operation-parameter-content-extension x-custom: operation-parameter-extension requestBody: @@ -331,6 +332,28 @@ paths: itemSchema: $ref: "#/components/schemas/User" x-custom: batch-create-response-content-extension + "203": + description: Multipart response with prefix encoding + content: + multipart/mixed: + schema: + type: array + prefixItems: + - type: object + - type: string + prefixEncoding: + - contentType: application/json + - contentType: text/plain + x-custom: prefix-encoding-response-content-extension + "204": + description: Multipart streaming response with item encoding + content: + multipart/mixed: + itemSchema: + type: object + itemEncoding: + contentType: application/json + x-custom: item-encoding-response-content-extension "400": description: Invalid user data x-custom: create-user-operation-extension diff --git a/openapi/walk.go b/openapi/walk.go index b6c5acc..8c6b4c0 100644 --- a/openapi/walk.go +++ b/openapi/walk.go @@ -559,6 +559,16 @@ func walkMediaType(ctx context.Context, mediaType *MediaType, loc []LocationCont return false } + // Walk through prefixEncoding + if !walkPrefixEncodings(ctx, mediaType.PrefixEncoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "prefixEncoding"}), openAPI, yield) { + return false + } + + // Walk through itemEncoding + if !walkEncoding(ctx, mediaType.ItemEncoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "itemEncoding"}), openAPI, yield) { + return false + } + // Walk through examples if !walkReferencedExamples(ctx, mediaType.Examples, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "examples"}), openAPI, yield) { return false @@ -588,6 +598,26 @@ func walkEncodings(ctx context.Context, encodings *sequencedmap.Map[string, *Enc return true } +// walkPrefixEncodings walks through prefix encodings (array) +func walkPrefixEncodings(ctx context.Context, encodings []*Encoding, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { + if len(encodings) == 0 { + return true + } + + // Get the last loc so we can set the parent index + parentLoc := loc[len(loc)-1] + loc = loc[:len(loc)-1] + + for i, encoding := range encodings { + parentLoc.ParentIndex = pointer.From(i) + + if !walkEncoding(ctx, encoding, append(loc, parentLoc), openAPI, yield) { + return false + } + } + return true +} + // walkEncoding walks through an encoding func walkEncoding(ctx context.Context, encoding *Encoding, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { if encoding == nil { diff --git a/openapi/walk_test.go b/openapi/walk_test.go index 0075af1..b1ce223 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -632,6 +632,28 @@ func TestWalkMediaType_Success(t *testing.T) { assert.True(t, mt.ItemSchema.IsLeft() || mt.ItemSchema.IsRight(), "ItemSchema should be Left or Right") }, }, + { + name: "media type with prefixEncoding", + location: "/paths/~1users/post/responses/203/content/multipart~1mixed", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.NotNil(t, mt.PrefixEncoding, "PrefixEncoding should not be nil") + assert.Len(t, mt.PrefixEncoding, 2, "PrefixEncoding should have 2 items") + assert.Equal(t, "application/json", mt.PrefixEncoding[0].GetContentTypeValue(), "First prefix encoding should be application/json") + assert.Equal(t, "text/plain", mt.PrefixEncoding[1].GetContentTypeValue(), "Second prefix encoding should be text/plain") + assert.Nil(t, mt.Encoding, "Encoding should be nil when PrefixEncoding is present") + }, + }, + { + name: "media type with itemEncoding", + location: "/paths/~1users/post/responses/204/content/multipart~1mixed", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.NotNil(t, mt.ItemEncoding, "ItemEncoding should not be nil") + assert.Equal(t, "application/json", mt.ItemEncoding.GetContentTypeValue(), "ItemEncoding should be application/json") + assert.Nil(t, mt.Encoding, "Encoding should be nil when ItemEncoding is present") + }, + }, } for _, tt := range tests { From 5244755210d86a9cf48b2cf7d2801e0a111f09e6 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 10:17:32 +1000 Subject: [PATCH 15/26] docs: add git commit conventions to AGENTS.md --- AGENTS.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 60dd922..3e961ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,54 @@ mise test -count=1 ./... - **Race Detection**: Automatically enables race detection to catch concurrency issues - **Submodule Awareness**: Checks for and warns about uninitialized test submodules +## Git Commit Conventions + +**Always use single-line conventional commits.** Do not create multi-line commit messages. + +### Commit Message Format + +``` +: +``` + +### Common Types + +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Adding or updating tests +- `chore:` - Maintenance tasks +- `perf:` - Performance improvements + +### Examples + +#### โœ… Good: Single-line conventional commits + +```bash +git commit -m "feat: add prefixEncoding and itemEncoding support for OpenAPI 3.2 multipart media types" +git commit -m "fix: correct validation logic for encoding field mutual exclusivity" +git commit -m "test: add comprehensive tests for multipart encoding validation" +git commit -m "refactor: simplify media type context passing in validation" +``` + +#### โŒ Bad: Multi-line commits + +```bash +git commit -m "feat: implement prefixEncoding and itemEncoding for OpenAPI 3.2 + +- Add PrefixEncoding and ItemEncoding fields to MediaType +- Implement validation for mutual exclusivity +- Add comprehensive tests" +``` + +### Why Single-Line Commits? + +1. **Simplicity**: Easy to read in git log and GitHub UI +2. **Consistency**: All commits follow the same pattern +3. **Searchability**: Easier to search and filter commits +4. **Tool Compatibility**: Works better with automated tools and scripts + ## Testing Follow these testing conventions when writing Go tests in this project. Run newly added or modified test immediately after changes to make sure they work as expected before continuing with more work. From c6523effdc2d6b645313da8cdac92a91c29d8f77 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 11:34:32 +1000 Subject: [PATCH 16/26] feat: add OpenAPI 3.2 security enhancements including OAuth2 device authorization flow, deprecated field, oauth2MetadataUrl, and URI-based security scheme references --- openapi/core/security.go | 42 ++++--- openapi/security.go | 93 ++++++++++++++- openapi/security_unmarshal_test.go | 51 ++++++++ openapi/security_validate_test.go | 186 ++++++++++++++++++++++++++++- 4 files changed, 345 insertions(+), 27 deletions(-) diff --git a/openapi/core/security.go b/openapi/core/security.go index 44bb75e..ec20490 100644 --- a/openapi/core/security.go +++ b/openapi/core/security.go @@ -10,15 +10,17 @@ import ( type SecurityScheme struct { marshaller.CoreModel `model:"securityScheme"` - Type marshaller.Node[string] `key:"type"` - Description marshaller.Node[*string] `key:"description"` - Name marshaller.Node[*string] `key:"name"` - In marshaller.Node[*string] `key:"in"` - Scheme marshaller.Node[*string] `key:"scheme"` - BearerFormat marshaller.Node[*string] `key:"bearerFormat"` - Flows marshaller.Node[*OAuthFlows] `key:"flows"` - OpenIdConnectUrl marshaller.Node[*string] `key:"openIdConnectUrl"` - Extensions core.Extensions `key:"extensions"` + Type marshaller.Node[string] `key:"type"` + Description marshaller.Node[*string] `key:"description"` + Name marshaller.Node[*string] `key:"name"` + In marshaller.Node[*string] `key:"in"` + Scheme marshaller.Node[*string] `key:"scheme"` + BearerFormat marshaller.Node[*string] `key:"bearerFormat"` + Flows marshaller.Node[*OAuthFlows] `key:"flows"` + OpenIdConnectUrl marshaller.Node[*string] `key:"openIdConnectUrl"` + OAuth2MetadataUrl marshaller.Node[*string] `key:"oauth2MetadataUrl"` + Deprecated marshaller.Node[*bool] `key:"deprecated"` + Extensions core.Extensions `key:"extensions"` } type SecurityRequirement struct { @@ -61,19 +63,21 @@ func (s *SecurityRequirement) GetMapKeyNodeOrRootLine(key string, rootNode *yaml type OAuthFlows struct { marshaller.CoreModel `model:"oAuthFlows"` - Implicit marshaller.Node[*OAuthFlow] `key:"implicit"` - Password marshaller.Node[*OAuthFlow] `key:"password"` - ClientCredentials marshaller.Node[*OAuthFlow] `key:"clientCredentials"` - AuthorizationCode marshaller.Node[*OAuthFlow] `key:"authorizationCode"` - Extensions core.Extensions `key:"extensions"` + Implicit marshaller.Node[*OAuthFlow] `key:"implicit"` + Password marshaller.Node[*OAuthFlow] `key:"password"` + ClientCredentials marshaller.Node[*OAuthFlow] `key:"clientCredentials"` + AuthorizationCode marshaller.Node[*OAuthFlow] `key:"authorizationCode"` + DeviceAuthorization marshaller.Node[*OAuthFlow] `key:"deviceAuthorization"` + Extensions core.Extensions `key:"extensions"` } type OAuthFlow struct { marshaller.CoreModel `model:"oAuthFlow"` - AuthorizationURL marshaller.Node[*string] `key:"authorizationUrl"` - TokenURL marshaller.Node[*string] `key:"tokenUrl"` - RefreshURL marshaller.Node[*string] `key:"refreshUrl"` - Scopes marshaller.Node[*sequencedmap.Map[string, string]] `key:"scopes"` - Extensions core.Extensions `key:"extensions"` + AuthorizationURL marshaller.Node[*string] `key:"authorizationUrl"` + DeviceAuthorizationURL marshaller.Node[*string] `key:"deviceAuthorizationUrl"` + TokenURL marshaller.Node[*string] `key:"tokenUrl"` + RefreshURL marshaller.Node[*string] `key:"refreshUrl"` + Scopes marshaller.Node[*sequencedmap.Map[string, string]] `key:"scopes"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/security.go b/openapi/security.go index 9370535..82d7715 100644 --- a/openapi/security.go +++ b/openapi/security.go @@ -64,6 +64,10 @@ type SecurityScheme struct { Flows *OAuthFlows // OpenIdConnectUrl is a URL to discover OAuth2 configuration values. OpenIdConnectUrl *string + // OAuth2MetadataUrl is a URL to the OAuth2 authorization server metadata (RFC8414). + OAuth2MetadataUrl *string + // Deprecated declares this security scheme to be deprecated. + Deprecated *bool // Extensions provides a list of extensions to the SecurityScheme object. Extensions *extensions.Extensions } @@ -134,6 +138,22 @@ func (s *SecurityScheme) GetOpenIdConnectUrl() string { return *s.OpenIdConnectUrl } +// GetOAuth2MetadataUrl returns the value of the OAuth2MetadataUrl field. Returns empty string if not set. +func (s *SecurityScheme) GetOAuth2MetadataUrl() string { + if s == nil || s.OAuth2MetadataUrl == nil { + return "" + } + return *s.OAuth2MetadataUrl +} + +// GetDeprecated returns the value of the Deprecated field. Returns false if not set. +func (s *SecurityScheme) GetDeprecated() bool { + if s == nil || s.Deprecated == nil { + return false + } + return *s.Deprecated +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (s *SecurityScheme) GetExtensions() *extensions.Extensions { if s == nil || s.Extensions == nil { @@ -178,6 +198,12 @@ func (s *SecurityScheme) Validate(ctx context.Context, opts ...validation.Option } else { errs = append(errs, s.Flows.Validate(ctx, opts...)...) } + // Validate oauth2MetadataUrl if present + if core.OAuth2MetadataUrl.Present && s.OAuth2MetadataUrl != nil && *s.OAuth2MetadataUrl != "" { + if _, err := url.Parse(*s.OAuth2MetadataUrl); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("securityScheme.oauth2MetadataUrl is not a valid uri: %s", err), core, core.OAuth2MetadataUrl)) + } + } case SecuritySchemeTypeOpenIDConnect: if !core.OpenIdConnectUrl.Present || *s.OpenIdConnectUrl == "" { errs = append(errs, validation.NewValueError(validation.NewMissingValueError("securityScheme.openIdConnectUrl is required for type=openIdConnect"), core, core.OpenIdConnectUrl)) @@ -258,9 +284,25 @@ func (s *SecurityRequirement) Validate(ctx context.Context, opts ...validation.O } for securityScheme := range s.Keys() { - if openapi.Components == nil || !openapi.Components.SecuritySchemes.Has(securityScheme) { - errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("securityRequirement scheme %s is not defined in components.securitySchemes", securityScheme), core, core, securityScheme)) + // Per OpenAPI 3.2 spec: property names that are identical to a component name + // MUST be treated as a component name (takes precedence over URI resolution) + if openapi.Components != nil && openapi.Components.SecuritySchemes.Has(securityScheme) { + continue + } + + // If not found as component name, check if it's a valid URI reference + // to a Security Scheme Object (OpenAPI 3.2 feature) + if _, err := url.Parse(securityScheme); err == nil { + // It's a valid URI - in a full implementation, we would try to resolve it + // For now, we accept it as valid if it parses as a URI + // TODO A complete implementation would need to: + // 1. Resolve the URI to a Security Scheme Object + // 2. Validate that the resolved object is indeed a Security Scheme + continue } + + // Not found as component name and not a valid URI + errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("securityRequirement scheme %s is not defined in components.securitySchemes and is not a valid URI reference", securityScheme), core, core, securityScheme)) } s.Valid = len(errs) == 0 && core.GetValid() @@ -280,6 +322,8 @@ type OAuthFlows struct { ClientCredentials *OAuthFlow // AuthorizationCode represents configuration fields for the OAuth2 Authorization Code flow. AuthorizationCode *OAuthFlow + // DeviceAuthorization represents configuration fields for the OAuth2 Device Authorization flow (RFC8628). + DeviceAuthorization *OAuthFlow // Extensions provides a list of extensions to the OAuthFlows object. Extensions *extensions.Extensions @@ -290,10 +334,11 @@ var _ interfaces.Model[core.OAuthFlows] = (*OAuthFlows)(nil) type OAuthFlowType string const ( - OAuthFlowTypeImplicit OAuthFlowType = "implicit" - OAuthFlowTypePassword OAuthFlowType = "password" - OAuthFlowTypeClientCredentials OAuthFlowType = "clientCredentials" - OAuthFlowTypeAuthorizationCode OAuthFlowType = "authorizationCode" + OAuthFlowTypeImplicit OAuthFlowType = "implicit" + OAuthFlowTypePassword OAuthFlowType = "password" + OAuthFlowTypeClientCredentials OAuthFlowType = "clientCredentials" + OAuthFlowTypeAuthorizationCode OAuthFlowType = "authorizationCode" + OAuthFlowTypeDeviceAuthorization OAuthFlowType = "deviceAuthorization" ) // GetImplicit returns the value of the Implicit field. Returns nil if not set. @@ -328,6 +373,14 @@ func (o *OAuthFlows) GetAuthorizationCode() *OAuthFlow { return o.AuthorizationCode } +// GetDeviceAuthorization returns the value of the DeviceAuthorization field. Returns nil if not set. +func (o *OAuthFlows) GetDeviceAuthorization() *OAuthFlow { + if o == nil { + return nil + } + return o.DeviceAuthorization +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (o *OAuthFlows) GetExtensions() *extensions.Extensions { if o == nil || o.Extensions == nil { @@ -353,6 +406,9 @@ func (o *OAuthFlows) Validate(ctx context.Context, opts ...validation.Option) [] if o.AuthorizationCode != nil { errs = append(errs, o.AuthorizationCode.Validate(ctx, append(opts, validation.WithContextObject(pointer.From(OAuthFlowTypeAuthorizationCode)))...)...) } + if o.DeviceAuthorization != nil { + errs = append(errs, o.DeviceAuthorization.Validate(ctx, append(opts, validation.WithContextObject(pointer.From(OAuthFlowTypeDeviceAuthorization)))...)...) + } o.Valid = len(errs) == 0 && core.GetValid() @@ -365,6 +421,8 @@ type OAuthFlow struct { // AuthorizationUrl is a URL to be used for obtaining authorization. AuthorizationURL *string + // DeviceAuthorizationUrl is a URL to be used for obtaining device authorization (RFC8628). + DeviceAuthorizationURL *string // TokenUrl is a URL to be used for obtaining access tokens. TokenURL *string // RefreshUrl is a URL to be used for refreshing access tokens. @@ -385,6 +443,14 @@ func (o *OAuthFlow) GetAuthorizationURL() string { return *o.AuthorizationURL } +// GetDeviceAuthorizationURL returns the value of the DeviceAuthorizationURL field. Returns empty string if not set. +func (o *OAuthFlow) GetDeviceAuthorizationURL() string { + if o == nil || o.DeviceAuthorizationURL == nil { + return "" + } + return *o.DeviceAuthorizationURL +} + // GetTokenURL returns the value of the TokenURL field. Returns empty string if not set. func (o *OAuthFlow) GetTokenURL() string { if o == nil || o.TokenURL == nil { @@ -469,6 +535,21 @@ func (o *OAuthFlow) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.tokenUrl is not a valid uri: %s", err), core, core.TokenURL)) } } + case OAuthFlowTypeDeviceAuthorization: + if !core.DeviceAuthorizationURL.Present || *o.DeviceAuthorizationURL == "" { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("oAuthFlow.deviceAuthorizationUrl is required for type=deviceAuthorization"), core, core.DeviceAuthorizationURL)) + } else { + if _, err := url.Parse(*o.DeviceAuthorizationURL); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.deviceAuthorizationUrl is not a valid uri: %s", err), core, core.DeviceAuthorizationURL)) + } + } + if !core.TokenURL.Present || *o.TokenURL == "" { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("oAuthFlow.tokenUrl is required for type=deviceAuthorization"), core, core.TokenURL)) + } else { + if _, err := url.Parse(*o.TokenURL); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.tokenUrl is not a valid uri: %s", err), core, core.TokenURL)) + } + } } if core.RefreshURL.Present { diff --git a/openapi/security_unmarshal_test.go b/openapi/security_unmarshal_test.go index 683171b..a6855f4 100644 --- a/openapi/security_unmarshal_test.go +++ b/openapi/security_unmarshal_test.go @@ -91,6 +91,39 @@ type: http scheme: bearer x-custom: value x-another: 123 +`, + }, + { + name: "oauth2_with_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server +`, + }, + { + name: "deprecated_scheme", + yml: ` +type: http +scheme: bearer +deprecated: true +`, + }, + { + name: "oauth2_device_authorization", + yml: ` +type: oauth2 +flows: + deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -186,6 +219,11 @@ authorizationCode: scopes: read: Read access write: Write access +deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + device: Device access x-custom: value ` @@ -209,6 +247,10 @@ x-custom: value require.Equal(t, "https://example.com/oauth/token", oauthFlows.GetAuthorizationCode().GetTokenURL()) require.Equal(t, "https://example.com/oauth/refresh", oauthFlows.GetAuthorizationCode().GetRefreshURL()) + require.NotNil(t, oauthFlows.GetDeviceAuthorization()) + require.Equal(t, "https://example.com/oauth/device_authorization", oauthFlows.GetDeviceAuthorization().GetDeviceAuthorizationURL()) + require.Equal(t, "https://example.com/oauth/token", oauthFlows.GetDeviceAuthorization().GetTokenURL()) + ext, ok := oauthFlows.GetExtensions().Get("x-custom") require.True(t, ok) require.Equal(t, "value", ext.Value) @@ -271,6 +313,15 @@ tokenUrl: https://example.com/oauth/token scopes: read: Read access x-custom: value +`, + }, + { + name: "device_authorization_flow", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access `, }, } diff --git a/openapi/security_validate_test.go b/openapi/security_validate_test.go index b548597..1e460b3 100644 --- a/openapi/security_validate_test.go +++ b/openapi/security_validate_test.go @@ -94,6 +94,39 @@ type: http scheme: bearer x-custom: value x-another: 123 +`, + }, + { + name: "valid_oauth2_with_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server +`, + }, + { + name: "valid_deprecated_scheme", + yml: ` +type: http +scheme: bearer +deprecated: true +`, + }, + { + name: "valid_oauth2_device_authorization", + yml: ` +type: oauth2 +flows: + deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -182,6 +215,20 @@ type: openIdConnect `, wantErrs: []string{"openIdConnectUrl is required for type=openIdConnect"}, }, + { + name: "oauth2_invalid_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: ://invalid-url +`, + wantErrs: []string{"oauth2MetadataUrl is not a valid uri"}, + }, } for _, tt := range tests { @@ -292,9 +339,9 @@ func TestSecurityRequirement_Validate_Error(t *testing.T) { { name: "undefined_security_scheme", yml: ` -undefined_scheme: [] +"://invalid uri": [] `, - expectedErr: "securityRequirement scheme undefined_scheme is not defined in components.securitySchemes", + expectedErr: "securityRequirement scheme ://invalid uri is not defined in components.securitySchemes and is not a valid URI reference", }, } @@ -322,6 +369,101 @@ undefined_scheme: [] } } +func TestSecurityRequirement_Validate_URIReferences_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + }{ + { + name: "uri_reference_absolute", + yml: ` +https://example.com/security/schemes/oauth2: [] +`, + }, + { + name: "uri_reference_relative", + yml: ` +./security/oauth2: [] +`, + }, + { + name: "uri_reference_with_fragment", + yml: ` +https://example.com/api#/components/securitySchemes/oauth2: [] +`, + }, + { + name: "mixed_component_and_uri", + yml: ` +api_key: [] +https://example.com/security/oauth2: + - read + - write +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var securityRequirement openapi.SecurityRequirement + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &securityRequirement) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Create a mock OpenAPI document with one known component + openAPIDoc := &openapi.OpenAPI{ + Components: &openapi.Components{ + SecuritySchemes: sequencedmap.New( + sequencedmap.NewElem("api_key", &openapi.ReferencedSecurityScheme{ + Object: &openapi.SecurityScheme{Type: openapi.SecuritySchemeTypeAPIKey}, + }), + ), + }, + } + + errs := securityRequirement.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) + require.Empty(t, errs, "Expected no validation errors for valid URI references") + }) + } +} + +func TestSecurityRequirement_Validate_ComponentNamePrecedence_Success(t *testing.T) { + t.Parallel() + + // Test that component names take precedence over URI interpretation + // per spec: "Property names that are identical to a component name under + // the Components Object MUST be treated as a component name" + yml := ` +foo: [] +` + + var securityRequirement openapi.SecurityRequirement + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &securityRequirement) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Create a mock OpenAPI document where "foo" is both a valid URI segment + // and a component name - component name should take precedence + openAPIDoc := &openapi.OpenAPI{ + Components: &openapi.Components{ + SecuritySchemes: sequencedmap.New( + sequencedmap.NewElem("foo", &openapi.ReferencedSecurityScheme{ + Object: &openapi.SecurityScheme{Type: openapi.SecuritySchemeTypeHTTP}, + }), + ), + }, + } + + errs := securityRequirement.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) + require.Empty(t, errs, "Component name 'foo' should take precedence over URI interpretation") +} + func TestOAuthFlows_Validate_Success(t *testing.T) { t.Parallel() @@ -392,6 +534,16 @@ implicit: scopes: read: Read access x-custom: value +`, + }, + { + name: "valid_device_authorization_flow", + yml: ` +deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -478,6 +630,16 @@ x-custom: value `, flowType: openapi.OAuthFlowTypePassword, }, + { + name: "valid_device_authorization_flow", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + }, } for _, tt := range tests { @@ -558,6 +720,26 @@ tokenUrl: https://example.com/oauth/token flowType: openapi.OAuthFlowTypePassword, expectedErr: "scopes is required (empty map is allowed)", }, + { + name: "device_authorization_missing_device_authorization_url", + yml: ` +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + expectedErr: "deviceAuthorizationUrl is required for type=deviceAuthorization", + }, + { + name: "device_authorization_missing_token_url", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + expectedErr: "tokenUrl is required for type=deviceAuthorization", + }, } for _, tt := range tests { From a9538df1462b9d0636c59308dbb79c537c6c5ae4 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 12:52:12 +1000 Subject: [PATCH 17/26] feat: add OpenAPI 3.2 discriminator defaultMapping support --- jsonschema/oas3/core/discriminator.go | 7 +- jsonschema/oas3/discriminator.go | 35 +++ jsonschema/oas3/discriminator_32_test.go | 133 ++++++++++++ .../oas3/discriminator_validate_test.go | 8 +- jsonschema/oas3/discriminator_version_test.go | 204 ++++++++++++++++++ jsonschema/oas3/schema32.base.json | 90 ++++++++ jsonschema/oas3/schema32.json | 25 +++ jsonschema/oas3/validation.go | 34 ++- 8 files changed, 526 insertions(+), 10 deletions(-) create mode 100644 jsonschema/oas3/discriminator_32_test.go create mode 100644 jsonschema/oas3/discriminator_version_test.go create mode 100644 jsonschema/oas3/schema32.base.json create mode 100644 jsonschema/oas3/schema32.json diff --git a/jsonschema/oas3/core/discriminator.go b/jsonschema/oas3/core/discriminator.go index 56a5168..c63bcb9 100644 --- a/jsonschema/oas3/core/discriminator.go +++ b/jsonschema/oas3/core/discriminator.go @@ -9,7 +9,8 @@ import ( type Discriminator struct { marshaller.CoreModel `model:"discriminator"` - PropertyName marshaller.Node[string] `key:"propertyName"` - Mapping marshaller.Node[*sequencedmap.Map[string, marshaller.Node[string]]] `key:"mapping"` - Extensions core.Extensions `key:"extensions"` + PropertyName marshaller.Node[string] `key:"propertyName"` + Mapping marshaller.Node[*sequencedmap.Map[string, marshaller.Node[string]]] `key:"mapping"` + DefaultMapping marshaller.Node[*string] `key:"defaultMapping"` + Extensions core.Extensions `key:"extensions"` } diff --git a/jsonschema/oas3/discriminator.go b/jsonschema/oas3/discriminator.go index aebb553..f10f00b 100644 --- a/jsonschema/oas3/discriminator.go +++ b/jsonschema/oas3/discriminator.go @@ -16,9 +16,18 @@ type Discriminator struct { marshaller.Model[core.Discriminator] // PropertyName is the name of the property in the payload that will hold the discriminator value. + // This field is REQUIRED in all OpenAPI versions. + // In OpenAPI 3.2+, the property that this references MAY be optional in the schema, + // but when it is optional, DefaultMapping MUST be provided. PropertyName string // Mapping is an object to hold mappings between payload values and schema names or references. Mapping *sequencedmap.Map[string, string] + // DefaultMapping is the schema name or URI reference to a schema that is expected to validate + // the structure of the model when the discriminating property is not present in the payload + // or contains a value for which there is no explicit or implicit mapping. + // This field is part of OpenAPI 3.2+ and is required when the property referenced by + // PropertyName is optional in the schema. + DefaultMapping *string // Extensions provides a list of extensions to the Discriminator object. Extensions *extensions.Extensions } @@ -41,6 +50,14 @@ func (d *Discriminator) GetMapping() *sequencedmap.Map[string, string] { return d.Mapping } +// GetDefaultMapping returns the value of the DefaultMapping field. Returns empty string if not set. +func (d *Discriminator) GetDefaultMapping() string { + if d == nil || d.DefaultMapping == nil { + return "" + } + return *d.DefaultMapping +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (d *Discriminator) GetExtensions() *extensions.Extensions { if d == nil || d.Extensions == nil { @@ -54,10 +71,18 @@ func (d *Discriminator) Validate(ctx context.Context, opts ...validation.Option) core := d.GetCore() errs := []error{} + // propertyName is REQUIRED in all OpenAPI versions if core.PropertyName.Present { if core.PropertyName.Value == "" { errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.propertyName is required"), core, core.PropertyName)) } + } else { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.propertyName is required"), core, core.PropertyName)) + } + + // defaultMapping validation - must not be empty if present + if core.DefaultMapping.Present && (core.DefaultMapping.Value == nil || *core.DefaultMapping.Value == "") { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.defaultMapping cannot be empty"), core, core.DefaultMapping)) } d.Valid = len(errs) == 0 && core.GetValid() @@ -79,6 +104,16 @@ func (d *Discriminator) IsEqual(other *Discriminator) bool { return false } + // Compare DefaultMapping (both pointers) + switch { + case d.DefaultMapping == nil && other.DefaultMapping == nil: + // Both nil, continue + case d.DefaultMapping == nil || other.DefaultMapping == nil: + return false + case *d.DefaultMapping != *other.DefaultMapping: + return false + } + // Compare Mapping using sequencedmap's IsEqual method switch { case d.Mapping == nil && other.Mapping == nil: diff --git a/jsonschema/oas3/discriminator_32_test.go b/jsonschema/oas3/discriminator_32_test.go new file mode 100644 index 0000000..7582731 --- /dev/null +++ b/jsonschema/oas3/discriminator_32_test.go @@ -0,0 +1,133 @@ +package oas3_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/pointer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscriminator_OpenAPI32_DefaultMapping_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + expectedDefaultMap string + }{ + { + name: "discriminator with propertyName and defaultMapping", + yml: ` +propertyName: petType +defaultMapping: "#/components/schemas/OtherPet" +mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" +`, + expectedDefaultMap: "#/components/schemas/OtherPet", + }, + { + name: "discriminator with defaultMapping using schema name", + yml: ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + expectedDefaultMap: "OtherPet", + }, + { + name: "discriminator with propertyName and defaultMapping but no mapping", + yml: ` +propertyName: petType +defaultMapping: DefaultPet +`, + expectedDefaultMap: "DefaultPet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + assert.Equal(t, tt.expectedDefaultMap, discriminator.GetDefaultMapping()) + }) + } +} + +func TestDiscriminator_OpenAPI32_IsEqual(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disc1 *oas3.Discriminator + disc2 *oas3.Discriminator + expected bool + }{ + { + name: "equal discriminators with defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + expected: true, + }, + { + name: "different defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("DefaultPet"), + }, + expected: false, + }, + { + name: "one with defaultMapping, one without", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + }, + expected: false, + }, + { + name: "both with same propertyName and defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expected, tt.disc1.IsEqual(tt.disc2)) + }) + } +} diff --git a/jsonschema/oas3/discriminator_validate_test.go b/jsonschema/oas3/discriminator_validate_test.go index 681264a..878f3fa 100644 --- a/jsonschema/oas3/discriminator_validate_test.go +++ b/jsonschema/oas3/discriminator_validate_test.go @@ -87,11 +87,13 @@ func TestDiscriminator_Validate_Error(t *testing.T) { }{ { name: "missing property name", - yml: ` -mapping: + yml: `mapping: dog: "#/components/schemas/Dog" `, - wantErrs: []string{"[2:1] discriminator.propertyName is missing"}, + wantErrs: []string{ + "[1:1] discriminator.propertyName is missing", + "[1:1] discriminator.propertyName is required", + }, }, { name: "empty property name", diff --git a/jsonschema/oas3/discriminator_version_test.go b/jsonschema/oas3/discriminator_version_test.go new file mode 100644 index 0000000..3a160ab --- /dev/null +++ b/jsonschema/oas3/discriminator_version_test.go @@ -0,0 +1,204 @@ +package oas3_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/pointer" + "github.com/speakeasy-api/openapi/validation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscriminator_VersionAwareValidation_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + version string + isValid bool + }{ + { + name: "OAS 3.1 - propertyName required", + yml: ` +propertyName: petType +mapping: + cat: Cat + dog: Dog +`, + version: "3.1.0", + isValid: true, + }, + { + name: "OAS 3.2 - propertyName with defaultMapping", + yml: ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: true, + }, + { + name: "OAS 3.2 - propertyName without defaultMapping (property is required in schema)", + yml: ` +propertyName: petType +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: true, + }, + { + name: "OAS 3.1 - missing propertyName should fail", + yml: `mapping: + cat: Cat + dog: Dog +`, + version: "3.1.0", + isValid: false, + }, + { + name: "OAS 3.2 - missing propertyName should also fail", + yml: `defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var discriminator oas3.Discriminator + + // Collect all errors from unmarshalling + var allErrors []error + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &discriminator) + require.NoError(t, err) + allErrors = append(allErrors, validationErrs...) + + // Create version context + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: &tt.version, + }), + } + + // Collect validation errors + validateErrs := discriminator.Validate(t.Context(), opts...) + allErrors = append(allErrors, validateErrs...) + + if tt.isValid { + assert.Empty(t, allErrors, "expected no validation errors for version %s", tt.version) + assert.True(t, discriminator.Valid, "expected discriminator to be valid for version %s", tt.version) + } else { + assert.NotEmpty(t, allErrors, "expected validation errors for version %s", tt.version) + assert.False(t, discriminator.Valid, "expected discriminator to be invalid for version %s", tt.version) + } + }) + } +} + +func TestDiscriminator_OpenAPI32_CompleteExample_Success(t *testing.T) { + t.Parallel() + + // Full example from the spec showing optional propertyName with defaultMapping + yml := ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog + lizard: Lizard +x-custom-extension: value +` + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Validate with OpenAPI 3.2 + version := "3.2.0" + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: &version, + }), + } + + errs := discriminator.Validate(t.Context(), opts...) + require.Empty(t, errs) + require.True(t, discriminator.Valid) + + // Verify all fields + assert.Equal(t, "petType", discriminator.GetPropertyName()) + assert.Equal(t, "OtherPet", discriminator.GetDefaultMapping()) + + mapping := discriminator.GetMapping() + require.NotNil(t, mapping) + assert.Equal(t, 3, mapping.Len()) + + cat, ok := mapping.Get("cat") + assert.True(t, ok) + assert.Equal(t, "Cat", cat) + + dog, ok := mapping.Get("dog") + assert.True(t, ok) + assert.Equal(t, "Dog", dog) + + lizard, ok := mapping.Get("lizard") + assert.True(t, ok) + assert.Equal(t, "Lizard", lizard) + + extensions := discriminator.GetExtensions() + require.NotNil(t, extensions) + + ext, ok := extensions.Get("x-custom-extension") + require.True(t, ok) + assert.Equal(t, "value", ext.Value) +} + +func TestDiscriminator_BackwardCompatibility_Success(t *testing.T) { + t.Parallel() + + // Verify that existing OAS 3.1 discriminators still work + yml := ` +propertyName: petType +mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" +` + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Test with both 3.1 and 3.2 versions + for _, version := range []string{"3.1.0", "3.2.0"} { + t.Run("version_"+version, func(t *testing.T) { + t.Parallel() + + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: pointer.From(version), + }), + } + + errs := discriminator.Validate(t.Context(), opts...) + assert.Empty(t, errs, "valid OAS 3.1 discriminator should work in version %s", version) + assert.True(t, discriminator.Valid) + }) + } +} diff --git a/jsonschema/oas3/schema32.base.json b/jsonschema/oas3/schema32.base.json new file mode 100644 index 0000000..63c48be --- /dev/null +++ b/jsonschema/oas3/schema32.base.json @@ -0,0 +1,90 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/meta/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OAS Base vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect for OpenAPI 3.2+", + + "$vocabulary": { + "https://spec.openapis.org/oas/3.2/vocab/base": true + }, + + "$dynamicAnchor": "meta", + + "type": ["object", "boolean"], + "properties": { + "example": true, + "discriminator": { "$ref": "#/$defs/discriminator" }, + "externalDocs": { "$ref": "#/$defs/external-docs" }, + "xml": { "$ref": "#/$defs/xml" } + }, + + "$defs": { + "extensible": { + "patternProperties": { + "^x-": true + } + }, + + "discriminator": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "defaultMapping": { + "type": "string" + } + }, + "required": ["propertyName"], + "unevaluatedProperties": false + }, + + "external-docs": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "required": ["url"], + "unevaluatedProperties": false + }, + + "xml": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean" + }, + "wrapped": { + "type": "boolean" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/jsonschema/oas3/schema32.json b/jsonschema/oas3/schema32.json new file mode 100644 index 0000000..ff73eb4 --- /dev/null +++ b/jsonschema/oas3/schema32.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/dialect/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OpenAPI 3.2 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI 3.2+ documents", + + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://spec.openapis.org/oas/3.2/vocab/base": false + }, + + "$dynamicAnchor": "meta", + + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://spec.openapis.org/oas/3.2/meta/base" } + ] +} diff --git a/jsonschema/oas3/validation.go b/jsonschema/oas3/validation.go index 475563c..bb0a2e4 100644 --- a/jsonschema/oas3/validation.go +++ b/jsonschema/oas3/validation.go @@ -31,12 +31,21 @@ var schema31BaseJSON string //go:embed schema30.json var schema30JSON string -var oasSchemaValidator = make(map[string]*jsValidator.Schema) -var defaultPrinter = message.NewPrinter(language.English) +//go:embed schema32.json +var schema32JSON string + +//go:embed schema32.base.json +var schema32BaseJSON string + +var ( + oasSchemaValidator = make(map[string]*jsValidator.Schema) + defaultPrinter = message.NewPrinter(language.English) +) const ( JSONSchema31SchemaID = "https://spec.openapis.org/oas/3.1/dialect/base" JSONSchema30SchemaID = "https://spec.openapis.org/oas/3.0/dialect/2024-10-18" + JSONSchema32SchemaID = "https://spec.openapis.org/oas/3.2/dialect/base" ) type ParentDocumentVersion struct { @@ -76,6 +85,8 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err switch { case dv.OpenAPI != nil: switch { + case strings.HasPrefix(*dv.OpenAPI, "3.2"): + schema = JSONSchema32SchemaID case strings.HasPrefix(*dv.OpenAPI, "3.1"): schema = JSONSchema31SchemaID case strings.HasPrefix(*dv.OpenAPI, "3.0"): @@ -196,8 +207,10 @@ func getRootCauses(err *jsValidator.ValidationError, js core.Schema) []error { return errs } -var validationInitialized = make(map[string]bool) -var initMutex sync.Mutex +var ( + validationInitialized = make(map[string]bool) + initMutex sync.Mutex +) func initValidation(schema string) *jsValidator.Schema { initMutex.Lock() @@ -211,6 +224,19 @@ func initValidation(schema string) *jsValidator.Schema { c := jsValidator.NewCompiler() switch schema { + case JSONSchema32SchemaID: + oasSchemaBase, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32BaseJSON)) + if err != nil { + panic(err) + } + if err := c.AddResource("https://spec.openapis.org/oas/3.2/meta/base", oasSchemaBase); err != nil { + panic(err) + } + + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32JSON)) + if err != nil { + panic(err) + } case JSONSchema31SchemaID: oasSchemaBase, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31BaseJSON)) if err != nil { From 3588c15f06fd13dc24be0806344c725fbcdc933d Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 13:49:39 +1000 Subject: [PATCH 18/26] fix --- .../{schema30.json => schema30.dialect.json} | 0 jsonschema/oas3/schema31.base.json | 87 ------------- jsonschema/oas3/schema31.dialect.json | 25 ++++ jsonschema/oas3/schema31.json | 25 ---- ...{schema32.base.json => schema31.meta.json} | 87 ++++++------- .../{schema32.json => schema32.dialect.json} | 28 ++--- jsonschema/oas3/schema32.meta.json | 114 ++++++++++++++++++ .../oas3/schema_exclusive_validation_test.go | 2 +- jsonschema/oas3/validation.go | 56 +++++---- 9 files changed, 232 insertions(+), 192 deletions(-) rename jsonschema/oas3/{schema30.json => schema30.dialect.json} (100%) delete mode 100644 jsonschema/oas3/schema31.base.json create mode 100644 jsonschema/oas3/schema31.dialect.json delete mode 100644 jsonschema/oas3/schema31.json rename jsonschema/oas3/{schema32.base.json => schema31.meta.json} (67%) rename jsonschema/oas3/{schema32.json => schema32.dialect.json} (75%) create mode 100644 jsonschema/oas3/schema32.meta.json diff --git a/jsonschema/oas3/schema30.json b/jsonschema/oas3/schema30.dialect.json similarity index 100% rename from jsonschema/oas3/schema30.json rename to jsonschema/oas3/schema30.dialect.json diff --git a/jsonschema/oas3/schema31.base.json b/jsonschema/oas3/schema31.base.json deleted file mode 100644 index a7a59f1..0000000 --- a/jsonschema/oas3/schema31.base.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$id": "https://spec.openapis.org/oas/3.1/meta/base", - "$schema": "https://json-schema.org/draft/2020-12/schema", - - "title": "OAS Base vocabulary", - "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", - - "$vocabulary": { - "https://spec.openapis.org/oas/3.1/vocab/base": true - }, - - "$dynamicAnchor": "meta", - - "type": ["object", "boolean"], - "properties": { - "example": true, - "discriminator": { "$ref": "#/$defs/discriminator" }, - "externalDocs": { "$ref": "#/$defs/external-docs" }, - "xml": { "$ref": "#/$defs/xml" } - }, - - "$defs": { - "extensible": { - "patternProperties": { - "^x-": true - } - }, - - "discriminator": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "propertyName": { - "type": "string" - }, - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["propertyName"], - "unevaluatedProperties": false - }, - - "external-docs": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri-reference" - }, - "description": { - "type": "string" - } - }, - "required": ["url"], - "unevaluatedProperties": false - }, - - "xml": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "namespace": { - "type": "string", - "format": "uri" - }, - "prefix": { - "type": "string" - }, - "attribute": { - "type": "boolean" - }, - "wrapped": { - "type": "boolean" - } - }, - "unevaluatedProperties": false - } - } -} diff --git a/jsonschema/oas3/schema31.dialect.json b/jsonschema/oas3/schema31.dialect.json new file mode 100644 index 0000000..68ec971 --- /dev/null +++ b/jsonschema/oas3/schema31.dialect.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/dialect/2024-11-10", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenAPI 3.1 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI v3.1 Descriptions", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://spec.openapis.org/oas/3.1/vocab/base": false + }, + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "https://spec.openapis.org/oas/3.1/meta/2024-11-10" + } + ] +} \ No newline at end of file diff --git a/jsonschema/oas3/schema31.json b/jsonschema/oas3/schema31.json deleted file mode 100644 index eae8386..0000000 --- a/jsonschema/oas3/schema31.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$id": "https://spec.openapis.org/oas/3.1/dialect/base", - "$schema": "https://json-schema.org/draft/2020-12/schema", - - "title": "OpenAPI 3.1 Schema Object Dialect", - "description": "A JSON Schema dialect describing schemas found in OpenAPI documents", - - "$vocabulary": { - "https://json-schema.org/draft/2020-12/vocab/core": true, - "https://json-schema.org/draft/2020-12/vocab/applicator": true, - "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, - "https://json-schema.org/draft/2020-12/vocab/validation": true, - "https://json-schema.org/draft/2020-12/vocab/meta-data": true, - "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, - "https://json-schema.org/draft/2020-12/vocab/content": true, - "https://spec.openapis.org/oas/3.1/vocab/base": false - }, - - "$dynamicAnchor": "meta", - - "allOf": [ - { "$ref": "https://json-schema.org/draft/2020-12/schema" }, - { "$ref": "https://spec.openapis.org/oas/3.1/meta/base" } - ] -} diff --git a/jsonschema/oas3/schema32.base.json b/jsonschema/oas3/schema31.meta.json similarity index 67% rename from jsonschema/oas3/schema32.base.json rename to jsonschema/oas3/schema31.meta.json index 63c48be..663ef65 100644 --- a/jsonschema/oas3/schema32.base.json +++ b/jsonschema/oas3/schema31.meta.json @@ -1,90 +1,93 @@ { - "$id": "https://spec.openapis.org/oas/3.2/meta/base", + "$id": "https://spec.openapis.org/oas/3.1/meta/2024-11-10", "$schema": "https://json-schema.org/draft/2020-12/schema", - - "title": "OAS Base vocabulary", - "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect for OpenAPI 3.2+", - + "title": "OAS Base Vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", + "$dynamicAnchor": "meta", "$vocabulary": { - "https://spec.openapis.org/oas/3.2/vocab/base": true + "https://spec.openapis.org/oas/3.1/vocab/base": true }, - - "$dynamicAnchor": "meta", - - "type": ["object", "boolean"], + "type": [ + "object", + "boolean" + ], "properties": { + "discriminator": { + "$ref": "#/$defs/discriminator" + }, "example": true, - "discriminator": { "$ref": "#/$defs/discriminator" }, - "externalDocs": { "$ref": "#/$defs/external-docs" }, - "xml": { "$ref": "#/$defs/xml" } + "externalDocs": { + "$ref": "#/$defs/external-docs" + }, + "xml": { + "$ref": "#/$defs/xml" + } }, - "$defs": { - "extensible": { - "patternProperties": { - "^x-": true - } - }, - "discriminator": { "$ref": "#/$defs/extensible", - "type": "object", "properties": { - "propertyName": { - "type": "string" - }, "mapping": { - "type": "object", "additionalProperties": { "type": "string" - } + }, + "type": "object" }, - "defaultMapping": { + "propertyName": { "type": "string" } }, - "required": ["propertyName"], + "required": [ + "propertyName" + ], + "type": "object", "unevaluatedProperties": false }, - + "extensible": { + "patternProperties": { + "^x-": true + } + }, "external-docs": { "$ref": "#/$defs/extensible", - "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri-reference" - }, "description": { "type": "string" + }, + "url": { + "format": "uri-reference", + "type": "string" } }, - "required": ["url"], + "required": [ + "url" + ], + "type": "object", "unevaluatedProperties": false }, - "xml": { "$ref": "#/$defs/extensible", - "type": "object", "properties": { + "attribute": { + "type": "boolean" + }, "name": { "type": "string" }, "namespace": { - "type": "string", - "format": "uri" + "format": "uri", + "type": "string" }, "prefix": { "type": "string" }, - "attribute": { - "type": "boolean" - }, "wrapped": { "type": "boolean" } }, + "type": "object", "unevaluatedProperties": false } } } + diff --git a/jsonschema/oas3/schema32.json b/jsonschema/oas3/schema32.dialect.json similarity index 75% rename from jsonschema/oas3/schema32.json rename to jsonschema/oas3/schema32.dialect.json index ff73eb4..f6bfb68 100644 --- a/jsonschema/oas3/schema32.json +++ b/jsonschema/oas3/schema32.dialect.json @@ -1,25 +1,25 @@ { - "$id": "https://spec.openapis.org/oas/3.2/dialect/base", + "$id": "https://spec.openapis.org/oas/3.2/dialect/2025-09-17", "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "OpenAPI 3.2 Schema Object Dialect", - "description": "A JSON Schema dialect describing schemas found in OpenAPI 3.2+ documents", - + "description": "A JSON Schema dialect describing schemas found in OpenAPI v3.2.x Descriptions", + "$dynamicAnchor": "meta", "$vocabulary": { - "https://json-schema.org/draft/2020-12/vocab/core": true, "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, "https://json-schema.org/draft/2020-12/vocab/validation": true, - "https://json-schema.org/draft/2020-12/vocab/meta-data": true, - "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, - "https://json-schema.org/draft/2020-12/vocab/content": true, "https://spec.openapis.org/oas/3.2/vocab/base": false }, - - "$dynamicAnchor": "meta", - "allOf": [ - { "$ref": "https://json-schema.org/draft/2020-12/schema" }, - { "$ref": "https://spec.openapis.org/oas/3.2/meta/base" } + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "https://spec.openapis.org/oas/3.2/meta/2025-09-17" + } ] -} +} \ No newline at end of file diff --git a/jsonschema/oas3/schema32.meta.json b/jsonschema/oas3/schema32.meta.json new file mode 100644 index 0000000..556b682 --- /dev/null +++ b/jsonschema/oas3/schema32.meta.json @@ -0,0 +1,114 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/meta/2025-09-17", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OAS Base Vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI JSON Schema Dialect", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://spec.openapis.org/oas/3.2/vocab/base": true + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "discriminator": { + "$ref": "#/$defs/discriminator" + }, + "example": { + "deprecated": true + }, + "externalDocs": { + "$ref": "#/$defs/external-docs" + }, + "xml": { + "$ref": "#/$defs/xml" + } + }, + "$defs": { + "discriminator": { + "$ref": "#/$defs/extensible", + "properties": { + "mapping": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "defaultMapping": { + "type": "string" + }, + "propertyName": { + "type": "string" + } + }, + "type": "object", + "unevaluatedProperties": false + }, + "extensible": { + "patternProperties": { + "^x-": true + } + }, + "external-docs": { + "$ref": "#/$defs/extensible", + "properties": { + "description": { + "type": "string" + }, + "url": { + "format": "uri-reference", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object", + "unevaluatedProperties": false + }, + "xml": { + "$ref": "#/$defs/extensible", + "properties": { + "nodeType": { + "type": "string", + "enum": [ + "element", + "attribute", + "text", + "cdata", + "none" + ] + }, + "name": { + "type": "string" + }, + "namespace": { + "format": "iri", + "type": "string" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "deprecated": true + }, + "wrapped": { + "type": "boolean", + "deprecated": true + } + }, + "type": "object", + "dependentSchemas": { + "nodeType": { + "properties": { + "attribute": false, + "wrapped": false + } + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/jsonschema/oas3/schema_exclusive_validation_test.go b/jsonschema/oas3/schema_exclusive_validation_test.go index 3ad7bef..b2b16c9 100644 --- a/jsonschema/oas3/schema_exclusive_validation_test.go +++ b/jsonschema/oas3/schema_exclusive_validation_test.go @@ -232,7 +232,7 @@ exclusiveMaximum: false { name: "boolean exclusiveMinimum with 3.1 $schema should fail", yaml: ` -$schema: "https://spec.openapis.org/oas/3.1/dialect/base" +$schema: "https://spec.openapis.org/oas/3.1/dialect/2024-11-10" type: number minimum: 0 maximum: 100 diff --git a/jsonschema/oas3/validation.go b/jsonschema/oas3/validation.go index bb0a2e4..b80c350 100644 --- a/jsonschema/oas3/validation.go +++ b/jsonschema/oas3/validation.go @@ -22,20 +22,30 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed schema31.json -var schema31JSON string - -//go:embed schema31.base.json -var schema31BaseJSON string - -//go:embed schema30.json -var schema30JSON string - -//go:embed schema32.json -var schema32JSON string - -//go:embed schema32.base.json -var schema32BaseJSON string +// custom file to cover for missing openapi 3.0 json schema +// +//go:embed schema30.dialect.json +var schema30DialectJSON string + +// sourced from https://spec.openapis.org/oas/3.1/dialect/2024-11-10.html +// +//go:embed schema31.dialect.json +var schema31DialectJSON string + +// source from https://spec.openapis.org/oas/3.1/meta/2024-11-10.html +// +//go:embed schema31.meta.json +var schema31MetaJSON string + +// sourced from https://spec.openapis.org/oas/3.2/dialect/2025-09-17.html +// +//go:embed schema32.dialect.json +var schema32DialectJSON string + +// source from https://spec.openapis.org/oas/3.2/meta/2025-09-17.html +// +//go:embed schema32.meta.json +var schema32MetaJSON string var ( oasSchemaValidator = make(map[string]*jsValidator.Schema) @@ -43,9 +53,9 @@ var ( ) const ( - JSONSchema31SchemaID = "https://spec.openapis.org/oas/3.1/dialect/base" JSONSchema30SchemaID = "https://spec.openapis.org/oas/3.0/dialect/2024-10-18" - JSONSchema32SchemaID = "https://spec.openapis.org/oas/3.2/dialect/base" + JSONSchema31SchemaID = "https://spec.openapis.org/oas/3.1/meta/2024-11-10" + JSONSchema32SchemaID = "https://spec.openapis.org/oas/3.2/meta/2025-09-17" ) type ParentDocumentVersion struct { @@ -225,34 +235,34 @@ func initValidation(schema string) *jsValidator.Schema { switch schema { case JSONSchema32SchemaID: - oasSchemaBase, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32BaseJSON)) + oasSchemaMeta, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32MetaJSON)) if err != nil { panic(err) } - if err := c.AddResource("https://spec.openapis.org/oas/3.2/meta/base", oasSchemaBase); err != nil { + if err := c.AddResource(JSONSchema32SchemaID, oasSchemaMeta); err != nil { panic(err) } - schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32JSON)) + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32DialectJSON)) if err != nil { panic(err) } case JSONSchema31SchemaID: - oasSchemaBase, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31BaseJSON)) + oasSchemaMeta, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31MetaJSON)) if err != nil { panic(err) } - if err := c.AddResource("https://spec.openapis.org/oas/3.1/meta/base", oasSchemaBase); err != nil { + if err := c.AddResource(JSONSchema31SchemaID, oasSchemaMeta); err != nil { panic(err) } - schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31JSON)) + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31DialectJSON)) if err != nil { panic(err) } case JSONSchema30SchemaID: var err error - schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema30JSON)) + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema30DialectJSON)) if err != nil { panic(err) } From 0d3754eb25f7847ee8e33c332b27b13619402fed Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 14:18:44 +1000 Subject: [PATCH 19/26] feat: add dataValue and serializedValue support to Example Object for OpenAPI 3.2 --- openapi/core/example.go | 12 ++-- openapi/examples.go | 41 ++++++++++- openapi/examples_unmarshal_test.go | 108 +++++++++++++++++++++++++---- openapi/examples_validate_test.go | 81 ++++++++++++++++++++++ 4 files changed, 221 insertions(+), 21 deletions(-) diff --git a/openapi/core/example.go b/openapi/core/example.go index 65e7aca..aac485e 100644 --- a/openapi/core/example.go +++ b/openapi/core/example.go @@ -9,9 +9,11 @@ import ( type Example struct { marshaller.CoreModel `model:"example"` - Summary marshaller.Node[*string] `key:"summary"` - Description marshaller.Node[*string] `key:"description"` - Value marshaller.Node[values.Value] `key:"value"` - ExternalValue marshaller.Node[*string] `key:"externalValue"` - Extensions core.Extensions `key:"extensions"` + Summary marshaller.Node[*string] `key:"summary"` + Description marshaller.Node[*string] `key:"description"` + Value marshaller.Node[values.Value] `key:"value"` + ExternalValue marshaller.Node[*string] `key:"externalValue"` + DataValue marshaller.Node[values.Value] `key:"dataValue"` + SerializedValue marshaller.Node[*string] `key:"serializedValue"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/examples.go b/openapi/examples.go index e336181..f2bc01b 100644 --- a/openapi/examples.go +++ b/openapi/examples.go @@ -20,10 +20,15 @@ type Example struct { Summary *string // Description is a description of the example. Description *string - // Value is the example value. Mutually exclusive with ExternalValue. + // Value is the example value. Mutually exclusive with ExternalValue, DataValue, and SerializedValue. + // Deprecated for non-JSON serialization targets: Use DataValue and/or SerializedValue instead. Value values.Value - // ExternalValue is a URI to the location of the example value. May be relative to the location of the document. Mutually exclusive with Value. + // ExternalValue is a URI to the location of the example value. May be relative to the location of the document. Mutually exclusive with Value and SerializedValue. ExternalValue *string + // DataValue is an example of the data structure that MUST be valid according to the relevant Schema Object. If this field is present, Value MUST be absent. + DataValue values.Value + // SerializedValue is an example of the serialized form of the value, including encoding and escaping. If this field is present, Value and ExternalValue MUST be absent. + SerializedValue *string // Extensions provides a list of extensions to the Example object. Extensions *extensions.Extensions } @@ -62,6 +67,22 @@ func (e *Example) GetExternalValue() string { return *e.ExternalValue } +// GetDataValue returns the value of the DataValue field. Returns nil if not set. +func (e *Example) GetDataValue() values.Value { + if e == nil { + return nil + } + return e.DataValue +} + +// GetSerializedValue returns the value of the SerializedValue field. Returns empty string if not set. +func (e *Example) GetSerializedValue() string { + if e == nil || e.SerializedValue == nil { + return "" + } + return *e.SerializedValue +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (e *Example) GetExtensions() *extensions.Extensions { if e == nil || e.Extensions == nil { @@ -81,10 +102,26 @@ func (e *Example) Validate(ctx context.Context, opts ...validation.Option) []err core := e.GetCore() errs := []error{} + // Check mutual exclusivity: value and externalValue if core.Value.Present && core.ExternalValue.Present { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.value and externalValue are mutually exclusive"), core, core.Value)) } + // Check mutual exclusivity: dataValue and value + if core.DataValue.Present && core.Value.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.dataValue and value are mutually exclusive"), core, core.DataValue)) + } + + // Check mutual exclusivity: serializedValue and value + if core.SerializedValue.Present && core.Value.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.serializedValue and value are mutually exclusive"), core, core.SerializedValue)) + } + + // Check mutual exclusivity: serializedValue and externalValue + if core.SerializedValue.Present && core.ExternalValue.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.serializedValue and externalValue are mutually exclusive"), core, core.SerializedValue)) + } + if core.ExternalValue.Present { if _, err := url.Parse(*e.ExternalValue); err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError(fmt.Sprintf("example.externalValue is not a valid uri: %s", err)), core, core.ExternalValue)) diff --git a/openapi/examples_unmarshal_test.go b/openapi/examples_unmarshal_test.go index 180192c..89793f4 100644 --- a/openapi/examples_unmarshal_test.go +++ b/openapi/examples_unmarshal_test.go @@ -12,7 +12,14 @@ import ( func TestExample_Unmarshal_Success(t *testing.T) { t.Parallel() - yml := ` + tests := []struct { + name string + yml string + test func(t *testing.T, example *openapi.Example) + }{ + { + name: "all legacy fields", + yml: ` summary: Example of a pet description: A pet object example value: @@ -21,22 +28,95 @@ value: status: available externalValue: https://example.com/examples/pet.json x-test: some-value -` +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Example of a pet", example.GetSummary()) + require.Equal(t, "A pet object example", example.GetDescription()) + require.Equal(t, "https://example.com/examples/pet.json", example.GetExternalValue()) - var example openapi.Example + value := example.GetValue() + require.NotNil(t, value) - validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &example) - require.NoError(t, err) - require.Empty(t, validationErrs) + ext, ok := example.GetExtensions().Get("x-test") + require.True(t, ok) + require.Equal(t, "some-value", ext.Value) + }, + }, + { + name: "dataValue field", + yml: ` +summary: Data value example +description: Example using dataValue +dataValue: + author: A. Writer + title: The Newest Book +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Data value example", example.GetSummary()) + require.Equal(t, "Example using dataValue", example.GetDescription()) - require.Equal(t, "Example of a pet", example.GetSummary()) - require.Equal(t, "A pet object example", example.GetDescription()) - require.Equal(t, "https://example.com/examples/pet.json", example.GetExternalValue()) + dataValue := example.GetDataValue() + require.NotNil(t, dataValue) + }, + }, + { + name: "serializedValue field", + yml: ` +summary: Serialized value example +serializedValue: "flag=true" +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Serialized value example", example.GetSummary()) + require.Equal(t, "flag=true", example.GetSerializedValue()) + }, + }, + { + name: "dataValue and serializedValue together", + yml: ` +summary: Combined example +dataValue: + author: A. Writer + title: An Older Book + rating: 4.5 +serializedValue: '{"author":"A. Writer","title":"An Older Book","rating":4.5}' +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Combined example", example.GetSummary()) - value := example.GetValue() - require.NotNil(t, value) + dataValue := example.GetDataValue() + require.NotNil(t, dataValue) - ext, ok := example.GetExtensions().Get("x-test") - require.True(t, ok) - require.Equal(t, "some-value", ext.Value) + serializedValue := example.GetSerializedValue() + require.Equal(t, `{"author":"A. Writer","title":"An Older Book","rating":4.5}`, serializedValue) + }, + }, + { + name: "serializedValue with JSON content", + yml: ` +serializedValue: '{"name":"Fluffy","petType":"Cat","color":"White"}' +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, `{"name":"Fluffy","petType":"Cat","color":"White"}`, example.GetSerializedValue()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var example openapi.Example + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &example) + require.NoError(t, err) + require.Empty(t, validationErrs) + + tt.test(t, &example) + }) + } } diff --git a/openapi/examples_validate_test.go b/openapi/examples_validate_test.go index f1ec2ae..dd1ac9a 100644 --- a/openapi/examples_validate_test.go +++ b/openapi/examples_validate_test.go @@ -87,6 +87,42 @@ value: 42 yml: ` summary: Boolean example value: true +`, + }, + { + name: "valid example with dataValue", + yml: ` +summary: Data value example +dataValue: + author: A. Writer + title: The Newest Book +`, + }, + { + name: "valid example with serializedValue", + yml: ` +summary: Serialized value example +serializedValue: "flag=true" +`, + }, + { + name: "valid example with dataValue and serializedValue", + yml: ` +summary: Combined example +dataValue: + author: A. Writer + title: An Older Book + rating: 4.5 +serializedValue: '{"author":"A. Writer","title":"An Older Book","rating":4.5}' +`, + }, + { + name: "valid example with dataValue and externalValue", + yml: ` +dataValue: + id: 123 + name: test +externalValue: https://example.com/examples/user.json `, }, } @@ -150,6 +186,51 @@ externalValue: ":invalid" "[3:16] example.externalValue is not a valid uri: parse \":invalid\": missing protocol scheme", }, }, + { + name: "dataValue and value are mutually exclusive", + yml: ` +summary: Invalid example +dataValue: + id: 123 +value: "test" +`, + wantErrs: []string{"example.dataValue and value are mutually exclusive"}, + }, + { + name: "serializedValue and value are mutually exclusive", + yml: ` +summary: Invalid example +serializedValue: "test=123" +value: "test" +`, + wantErrs: []string{"example.serializedValue and value are mutually exclusive"}, + }, + { + name: "serializedValue and externalValue are mutually exclusive", + yml: ` +summary: Invalid example +serializedValue: "test=123" +externalValue: https://example.com/test.json +`, + wantErrs: []string{"example.serializedValue and externalValue are mutually exclusive"}, + }, + { + name: "multiple mutual exclusivity violations", + yml: ` +summary: Invalid example +dataValue: + id: 123 +value: "test" +serializedValue: "test=123" +externalValue: https://example.com/test.json +`, + wantErrs: []string{ + "example.value and externalValue are mutually exclusive", + "example.dataValue and value are mutually exclusive", + "example.serializedValue and value are mutually exclusive", + "example.serializedValue and externalValue are mutually exclusive", + }, + }, } for _, tt := range tests { From 565859f9f371be8e2ff434b61ff0c942cf25f682 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 14:38:04 +1000 Subject: [PATCH 20/26] feat: add name field to Server Object for OpenAPI 3.2 --- openapi/README.md | 1 + openapi/core/server.go | 1 + openapi/openapi_examples_test.go | 2 ++ openapi/server.go | 10 ++++++++++ openapi/server_unmarshal_test.go | 20 ++++++++++++++++++++ 5 files changed, 34 insertions(+) diff --git a/openapi/README.md b/openapi/README.md index 73c9a40..4031ef9 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -540,6 +540,7 @@ doc := &openapi.OpenAPI{ { URL: "https://api.example.com/v1", Description: pointer.From("Production server"), + Name: pointer.From("prod"), }, }, Paths: paths, diff --git a/openapi/core/server.go b/openapi/core/server.go index ab1e91b..fc059a0 100644 --- a/openapi/core/server.go +++ b/openapi/core/server.go @@ -11,6 +11,7 @@ type Server struct { URL marshaller.Node[string] `key:"url"` Description marshaller.Node[*string] `key:"description"` + Name marshaller.Node[*string] `key:"name"` Variables marshaller.Node[*sequencedmap.Map[string, *ServerVariable]] `key:"variables"` Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index 544440f..11d3f3c 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -629,6 +629,7 @@ func Example_creating() { { URL: "https://api.example.com/v1", Description: pointer.From("Production server"), + Name: pointer.From("prod"), }, }, Paths: paths, @@ -650,6 +651,7 @@ func Example_creating() { // servers: // - url: https://api.example.com/v1 // description: Production server + // name: prod // paths: // /users: // get: diff --git a/openapi/server.go b/openapi/server.go index 7f8f1cb..83789bd 100644 --- a/openapi/server.go +++ b/openapi/server.go @@ -28,6 +28,8 @@ type Server struct { URL string // A description of the server. May contain CommonMark syntax. Description *string + // An optional unique string to refer to the host designated by the URL. + Name *string // A map of variables available to be templated into the URL. Variables *sequencedmap.Map[string, *ServerVariable] @@ -53,6 +55,14 @@ func (s *Server) GetDescription() string { return *s.Description } +// GetName returns the value of the Name field. Returns empty string if not set. +func (s *Server) GetName() string { + if s == nil || s.Name == nil { + return "" + } + return *s.Name +} + // GetVariables returns the value of the Variables field. Returns nil if not set. func (s *Server) GetVariables() *sequencedmap.Map[string, *ServerVariable] { if s == nil { diff --git a/openapi/server_unmarshal_test.go b/openapi/server_unmarshal_test.go index d1a785f..b34c6dd 100644 --- a/openapi/server_unmarshal_test.go +++ b/openapi/server_unmarshal_test.go @@ -56,6 +56,26 @@ x-test: some-value require.Equal(t, "some-value", ext.Value) } +func TestServer_Unmarshal_WithName_Success(t *testing.T) { + t.Parallel() + + yml := ` +url: https://api.example.com/v1 +description: Production server +name: prod +` + + var server openapi.Server + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &server) + require.NoError(t, err) + require.Empty(t, validationErrs) + + require.Equal(t, "https://api.example.com/v1", server.GetURL()) + require.Equal(t, "Production server", server.GetDescription()) + require.Equal(t, "prod", server.GetName()) +} + func TestServerVariable_Unmarshal_Success(t *testing.T) { t.Parallel() From 456661fabc6d96c75967a784c7606f02377faa8b Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 15:22:42 +1000 Subject: [PATCH 21/26] feat: add field support for OpenAPI 3.2 with reference resolution --- openapi/core/openapi.go | 1 + openapi/openapi.go | 17 +++++ openapi/openapi_validate_test.go | 34 +++++++++ openapi/reference.go | 12 +++- openapi/reference_resolve_test.go | 75 ++++++++++++++++++++ openapi/testdata/resolve_test/shared.yaml | 14 ++++ openapi/testdata/resolve_test/with_self.yaml | 18 +++++ 7 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 openapi/testdata/resolve_test/shared.yaml create mode 100644 openapi/testdata/resolve_test/with_self.yaml diff --git a/openapi/core/openapi.go b/openapi/core/openapi.go index c3362a9..03ea224 100644 --- a/openapi/core/openapi.go +++ b/openapi/core/openapi.go @@ -11,6 +11,7 @@ type OpenAPI struct { marshaller.CoreModel `model:"openapi"` OpenAPI marshaller.Node[string] `key:"openapi"` + Self marshaller.Node[*string] `key:"$self"` Info marshaller.Node[Info] `key:"info"` ExternalDocs marshaller.Node[*oas3core.ExternalDocumentation] `key:"externalDocs"` Tags marshaller.Node[[]*Tag] `key:"tags"` diff --git a/openapi/openapi.go b/openapi/openapi.go index bf33193..4c29277 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -49,6 +49,9 @@ type OpenAPI struct { // OpenAPI is the version of the OpenAPI Specification that this document conforms to. OpenAPI string + // Self provides the self-assigned URI of this document, which also serves as its base URI for resolving relative references. + // It MUST be in the form of a URI reference as defined by RFC3986. + Self *string // Info provides various information about the API and document. Info Info // ExternalDocs provides additional external documentation for this API. @@ -91,6 +94,14 @@ func (o *OpenAPI) GetOpenAPI() string { return o.OpenAPI } +// GetSelf returns the value of the Self field. Returns empty string if not set. +func (o *OpenAPI) GetSelf() string { + if o == nil || o.Self == nil { + return "" + } + return *o.Self +} + // GetInfo returns the value of the Info field. Returns nil if not set. func (o *OpenAPI) GetInfo() *Info { if o == nil { @@ -223,6 +234,12 @@ func (o *OpenAPI) Validate(ctx context.Context, opts ...validation.Option) []err errs = append(errs, o.Components.Validate(ctx, opts...)...) } + if core.Self.Present && o.Self != nil { + if _, err := url.Parse(*o.Self); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.$self is not a valid uri reference: %s", err), core, core.Self)) + } + } + if core.JSONSchemaDialect.Present && o.JSONSchemaDialect != nil { if _, err := url.Parse(*o.JSONSchemaDialect); err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.jsonSchemaDialect is not a valid uri: %s", err), core, core.JSONSchemaDialect)) diff --git a/openapi/openapi_validate_test.go b/openapi/openapi_validate_test.go index 5b1f957..f7bd670 100644 --- a/openapi/openapi_validate_test.go +++ b/openapi/openapi_validate_test.go @@ -101,6 +101,28 @@ info: paths: {} x-custom: value x-api-version: 2.0 +`, + }, + { + name: "valid_with_self_absolute_uri", + yml: ` +openapi: 3.2.0 +$self: https://example.com/api/openapi.yaml +info: + title: Test API + version: 1.0.0 +paths: {} +`, + }, + { + name: "valid_with_self_relative_uri", + yml: ` +openapi: 3.2.0 +$self: /api/openapi.yaml +info: + title: Test API + version: 1.0.0 +paths: {} `, }, { @@ -246,6 +268,18 @@ paths: {} `, wantErrs: []string{"[7:3] externalDocumentation.url is missing"}, }, + { + name: "invalid_self_not_uri", + yml: ` +openapi: 3.2.0 +$self: "ht!tp://invalid-scheme" +info: + title: Test API + version: 1.0.0 +paths: {} +`, + wantErrs: []string{"openapi.$self is not a valid uri reference"}, + }, } for _, tt := range tests { diff --git a/openapi/reference.go b/openapi/reference.go index da75067..060327a 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -525,7 +525,17 @@ func (r *Reference[T, V, C]) resolve(ctx context.Context, opts references.Resolv if !ok { return nil, nil, nil, fmt.Errorf("root document must be *OpenAPI, got %T", opts.RootDocument) } - result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), opts) + + // Use $self as base URI if present in the target document (OpenAPI 3.2+) + // The $self field provides the self-assigned URI of the document per RFC3986 Section 5.1.1 + resolveOpts := opts + if targetDoc, ok := opts.TargetDocument.(*OpenAPI); ok && targetDoc != nil { + if self := targetDoc.GetSelf(); self != "" { + resolveOpts.TargetLocation = self + } + } + + result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), resolveOpts) if err != nil { return nil, nil, validationErrs, err } diff --git a/openapi/reference_resolve_test.go b/openapi/reference_resolve_test.go index 3924e21..53089ee 100644 --- a/openapi/reference_resolve_test.go +++ b/openapi/reference_resolve_test.go @@ -1065,3 +1065,78 @@ func TestReference_ParentLinks(t *testing.T) { }, "SetTopLevelParent on nil reference should not panic") }) } + +func TestResolveObject_WithSelf_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create mock filesystem to simulate the virtual location implied by $self + mockFS := NewMockVirtualFS() + + // Read the shared file that should be resolved relative to $self + sharedPath := filepath.Join("testdata", "resolve_test", "shared.yaml") + sharedContent, err := os.ReadFile(sharedPath) + require.NoError(t, err) + + // The $self is /test/api/openapi.yaml (absolute file path) + // So a relative reference ./shared.yaml should resolve to /test/api/shared.yaml + mockFS.AddFile("/test/api/shared.yaml", sharedContent) + + // Load the document with $self field + testDataPath := filepath.Join("testdata", "resolve_test", "with_self.yaml") + file, err := os.Open(testDataPath) + require.NoError(t, err) + defer file.Close() + + doc, validationErrs, err := Unmarshal(ctx, file) + require.NoError(t, err) + assert.Empty(t, validationErrs) + + // Verify $self is set correctly + require.NotNil(t, doc.Self) + assert.Equal(t, "/test/api/openapi.yaml", *doc.Self) + assert.Equal(t, "/test/api/openapi.yaml", doc.GetSelf()) + + // Setup resolve options - use a filesystem path as TargetLocation + // but $self should override it for external reference resolution + absPath, err := filepath.Abs(testDataPath) + require.NoError(t, err) + + opts := ResolveOptions{ + TargetLocation: absPath, // Filesystem location (different from $self) + RootDocument: doc, + VirtualFS: mockFS, // Use mock FS to intercept file access + } + + // Test resolving the external reference to shared.yaml + // The reference is './shared.yaml#/components/parameters/SharedParam' + // With $self = /test/api/openapi.yaml + // It should resolve to /test/api/shared.yaml#/components/parameters/SharedParam + require.NotNil(t, doc.Components) + require.NotNil(t, doc.Components.Parameters) + + externalParam, exists := doc.Components.Parameters.Get("ExternalParam") + require.True(t, exists, "ExternalParam should exist in components") + require.True(t, externalParam.IsReference(), "ExternalParam should be a reference") + + // Resolve the reference - it should use $self as base URI + validationErrs, err = externalParam.Resolve(ctx, opts) + require.NoError(t, err) + assert.Empty(t, validationErrs) + + resolved := externalParam.GetObject() + require.NotNil(t, resolved, "Resolved parameter should not be nil") + + // Verify the resolved parameter is the SharedParam from shared.yaml + assert.Equal(t, "sharedParam", resolved.GetName()) + assert.Equal(t, ParameterInQuery, resolved.GetIn()) + assert.True(t, resolved.GetRequired()) + assert.Equal(t, "A parameter defined in the shared file", resolved.GetDescription()) + + // Verify that the mock FS was accessed with the path resolved from $self + // The relative reference './shared.yaml' combined with $self '/test/api/openapi.yaml' + // should result in accessing '/test/api/shared.yaml' + assert.Equal(t, 1, mockFS.GetAccessCount("/test/api/shared.yaml"), + "shared.yaml should be accessed via path resolved from $self, not from TargetLocation") +} diff --git a/openapi/testdata/resolve_test/shared.yaml b/openapi/testdata/resolve_test/shared.yaml new file mode 100644 index 0000000..6503214 --- /dev/null +++ b/openapi/testdata/resolve_test/shared.yaml @@ -0,0 +1,14 @@ +openapi: 3.2.0 +info: + title: Shared Components + version: 1.0.0 +paths: {} +components: + parameters: + SharedParam: + name: sharedParam + in: query + required: true + schema: + type: string + description: A parameter defined in the shared file diff --git a/openapi/testdata/resolve_test/with_self.yaml b/openapi/testdata/resolve_test/with_self.yaml new file mode 100644 index 0000000..0ae2862 --- /dev/null +++ b/openapi/testdata/resolve_test/with_self.yaml @@ -0,0 +1,18 @@ +openapi: 3.2.0 +$self: /test/api/openapi.yaml +info: + title: Test API with $self + version: 1.0.0 +paths: {} +components: + parameters: + # Reference to a relative external file - should resolve using $self as base + ExternalParam: + $ref: "./shared.yaml#/components/parameters/SharedParam" + responses: + TestResponse: + description: Test response + content: + application/json: + schema: + type: object From 627ba02ab969070ea86cc55e2d8f78ed67efaf38 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 15:26:23 +1000 Subject: [PATCH 22/26] fix --- openapi/upgrade.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 044cd38..764819c 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -31,7 +31,7 @@ func WithUpgradeTargetVersion(version string) Option[UpgradeOptions] { } } -// Upgrade upgrades any OpenAPI 3x document to OpenAPI 3.1.1 (the latest version currently supported). +// Upgrade upgrades any OpenAPI 3x document to OpenAPI 3.2.0 (the latest version currently supported). // It currently won't resolve any external references, so only this document itself will be upgraded. func Upgrade(ctx context.Context, doc *OpenAPI, opts ...Option[UpgradeOptions]) (bool, error) { if doc == nil { From 284590bb641dd51073ed325c498d7c39f109cedb Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 15:55:18 +1000 Subject: [PATCH 23/26] fix --- .mise.toml | 4 ++-- mise-tasks/examples-check | 2 +- mise-tasks/lint | 3 ++- openapi/operation.go | 1 + openapi/reference.go | 9 ++++++--- yml/config.go | 11 +++++++---- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.mise.toml b/.mise.toml index db5ff16..ae32f12 100644 --- a/.mise.toml +++ b/.mise.toml @@ -6,14 +6,14 @@ gotestsum = "latest" description = "Create VSCode symlinks for tools not automatically handled by mise-vscode" run = [ "mkdir -p .vscode/mise-tools", - "ln -sf $(mise exec golangci-lint@2.5.0 -- which golangci-lint) .vscode/mise-tools/golangci-lint", + "ln -sf $(mise exec golangci-lint@2.7.2 -- which golangci-lint) .vscode/mise-tools/golangci-lint", ] [hooks] postinstall = [ "ln -sf ./AGENTS.md ./CLAUDE.md", "git submodule update --init --recursive", - "mise exec go@1.24.3 -- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0", + "mise exec go@1.24.3 -- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2", "mise run setup-vscode-symlinks", "go install go.uber.org/nilaway/cmd/nilaway@8ad05f0", ] diff --git a/mise-tasks/examples-check b/mise-tasks/examples-check index 1860dc4..e68d0ad 100755 --- a/mise-tasks/examples-check +++ b/mise-tasks/examples-check @@ -19,7 +19,7 @@ if ! git diff --quiet openapi/README.md arazzo/README.md; then git diff openapi/README.md arazzo/README.md echo "" echo "๐Ÿ’ก To fix this, run: mise run update-examples" - echo " Then commit the updated README files." + echo " Then stage/commit the updated README files." exit 1 else echo "โœ… All examples in README files are up to date!" diff --git a/mise-tasks/lint b/mise-tasks/lint index 1184134..105c589 100755 --- a/mise-tasks/lint +++ b/mise-tasks/lint @@ -3,7 +3,8 @@ set -euo pipefail echo "๐Ÿ” Running linting checks..." -echo "๐Ÿงน Running golangci-lint (includes go vet)..." +GOLANGCI_VERSION=$(golangci-lint --version | grep -oP 'golangci-lint has version \K[0-9.]+' || echo "unknown") +echo "๐Ÿงน Running golangci-lint v${GOLANGCI_VERSION} (includes go vet)..." golangci-lint run echo "๐Ÿ›ก๏ธ Running nilaway..." diff --git a/openapi/operation.go b/openapi/operation.go index b3c19f9..da02ea9 100644 --- a/openapi/operation.go +++ b/openapi/operation.go @@ -151,6 +151,7 @@ func (o *Operation) GetExtensions() *extensions.Extensions { } // IsDeprecated is an alias for GetDeprecated for backward compatibility. +// // Deprecated: Use GetDeprecated instead for consistency with other models. func (o *Operation) IsDeprecated() bool { return o.GetDeprecated() diff --git a/openapi/reference.go b/openapi/reference.go index 060327a..f64e843 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "github.com/speakeasy-api/openapi/internal/interfaces" @@ -636,11 +637,13 @@ func joinReferenceChain(chain []string) string { return chain[0] } - result := chain[0] + var result strings.Builder + result.WriteString(chain[0]) for i := 1; i < len(chain); i++ { - result += " -> " + chain[i] + result.WriteString(" -> ") + result.WriteString(chain[i]) } - return result + return result.String() } func unmarshaler[T any, V interfaces.Validator[T], C marshaller.CoreModeler](_ *OpenAPI) func(context.Context, *yaml.Node, bool) (*Reference[T, V, C], []error, error) { diff --git a/yml/config.go b/yml/config.go index 556dc2d..0bb9aed 100644 --- a/yml/config.go +++ b/yml/config.go @@ -187,7 +187,7 @@ func inspectData(data []byte) (OutputFormat, int, IndentationStyle) { } func getGlobalStringStyle(doc *yaml.Node, cfg *Config) { - const minSamples = 3 + const minSamples = 5 keyStyles := make([]yaml.Style, 0, minSamples) valueStyles := make([]yaml.Style, 0, minSamples) @@ -253,7 +253,8 @@ func looksLikeNumber(s string) bool { return err == nil } -// mostCommonStyle returns the most frequently occurring style from the provided styles +// mostCommonStyle returns the most frequently occurring style from the provided styles. +// In case of a tie, it returns the style that appears first in the slice for deterministic behavior. func mostCommonStyle(styles []yaml.Style) yaml.Style { if len(styles) == 0 { return 0 @@ -264,10 +265,12 @@ func mostCommonStyle(styles []yaml.Style) yaml.Style { counts[style]++ } - // Find the style with the highest count + // Find the style with the highest count by iterating through the original slice. + // This ensures deterministic results when there are ties - the first occurrence wins. var maxCount int var mostCommon yaml.Style - for style, count := range counts { + for _, style := range styles { + count := counts[style] if count > maxCount { maxCount = count mostCommon = style From 948b2bd43f75b1d53d18e0a84e204f0cab5749db Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 16:11:35 +1000 Subject: [PATCH 24/26] docs: update documentation to reflect OpenAPI 3.2.0 support --- README.md | 4 ++-- cmd/openapi/commands/openapi/README.md | 7 ++++--- openapi/README.md | 11 ++++++----- openapi/upgrade_test.go | 4 ++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 24719ba..7e3dbf1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ OpenAPI Hub - OpenAPI Support + OpenAPI Support Arazzo Support Go Doc @@ -68,7 +68,7 @@ The `arazzo` package provides an API for working with Arazzo documents including ### [openapi](./openapi) -The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports both OpenAPI 3.0.x and 3.1.x specifications. +The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports OpenAPI 3.0.x, 3.1.x, and 3.2.x specifications. ### [swagger](./swagger) diff --git a/cmd/openapi/commands/openapi/README.md b/cmd/openapi/commands/openapi/README.md index 3fa2e95..625e2e8 100644 --- a/cmd/openapi/commands/openapi/README.md +++ b/cmd/openapi/commands/openapi/README.md @@ -50,7 +50,7 @@ This command checks for: ### `upgrade` -Upgrade an OpenAPI specification to the latest supported version (3.1.1). +Upgrade an OpenAPI specification to the latest supported version (3.2.0). ```bash # Upgrade to stdout @@ -64,11 +64,12 @@ openapi spec upgrade -w ./spec.yaml # Upgrade with specific target version openapi spec upgrade --version 3.1.0 ./spec.yaml +openapi spec upgrade --version 3.2.0 ./spec.yaml ``` Features: -- Converts OpenAPI 3.0.x specifications to 3.1.x +- Converts OpenAPI 3.0.x and 3.1.x specifications to 3.2.0 - Maintains backward compatibility where possible - Updates schema formats and structures - Preserves all custom extensions and vendor-specific content @@ -937,7 +938,7 @@ All commands work with both YAML and JSON input files and preserve the original openapi spec validate ./spec.yaml # Upgrade if needed -openapi spec upgrade ./spec.yaml ./spec-v3.1.yaml +openapi spec upgrade ./spec.yaml ./spec-v3.2.yaml # Bundle external references openapi spec bundle ./spec-v3.1.yaml ./spec-bundled.yaml diff --git a/openapi/README.md b/openapi/README.md index 8fca656..01c8f57 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -3,7 +3,7 @@ OpenAPI

OpenAPI Parser

-

An API for working with OpenAPI documents including: read, walk, create, mutate, validate, and upgrade +

An API for working with OpenAPI documents including: read, walk, create, mutate, validate, and upgrade

@@ -21,10 +21,10 @@ ## Features -- **Full OpenAPI 3.0.x and 3.1.x Support**: Parse and work with both OpenAPI 3.0.x and 3.1.x documents +- **Full OpenAPI 3.0.x, 3.1.x, and 3.2.x Support**: Parse and work with OpenAPI 3.0.x, 3.1.x, and 3.2.x documents - **Validation**: Built-in validation against the OpenAPI Specification - **Walking**: Traverse all elements in an OpenAPI document with a powerful iterator pattern -- **Upgrading**: Automatically upgrade OpenAPI 3.0.x documents to 3.1.1 +- **Upgrading**: Automatically upgrade OpenAPI 3.0.x and 3.1.x documents to 3.2.0 - **Mutation**: Modify OpenAPI documents programmatically - **JSON Schema Support**: Direct access to JSON Schema functionality - **Reference Resolution**: Resolve $ref references within documents @@ -35,9 +35,10 @@ ## Supported OpenAPI Versions - OpenAPI 3.0.0 through 3.0.4 -- OpenAPI 3.1.0 through 3.1.1 (latest) +- OpenAPI 3.1.0 through 3.1.1 +- OpenAPI 3.2.0 -The package can automatically upgrade documents from 3.0.x to 3.1.1, handling the differences in specification between versions. +The package can automatically upgrade documents from 3.0.x and 3.1.x to 3.2.0, handling the differences in specification between versions. diff --git a/openapi/upgrade_test.go b/openapi/upgrade_test.go index cfd4f74..fb85de1 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -267,7 +267,7 @@ components: // Upgrade (no options needed for 3.0.x documents) upgraded, err := openapi.Upgrade(ctx, doc1) require.NoError(t, err, "upgrade should not fail") - assert.Equal(t, openapi.Version, doc1.OpenAPI, "upgraded version should be 3.1.1") + assert.Equal(t, openapi.Version, doc1.OpenAPI, "upgraded version should be 3.2.0") assert.True(t, upgraded, "upgrade should have been performed") // Marshal back @@ -283,7 +283,7 @@ components: doc2, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(marshalledContent)) require.NoError(t, err, "second unmarshal should not fail") require.Empty(t, validationErrs, "second unmarshal should not have validation errors") - assert.Equal(t, openapi.Version, doc2.OpenAPI, "second doc version should be 3.1.1") + assert.Equal(t, openapi.Version, doc2.OpenAPI, "second doc version should be 3.2.0") // Marshal again var buf2 bytes.Buffer From 3c38b5f28d14f94cbb7e75af4bad0a5214888f8e Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 16:28:09 +1000 Subject: [PATCH 25/26] docs: update remaining version references from 3.1.x to 3.2.0 --- cmd/openapi/commands/openapi/upgrade.go | 23 ++++++++++++++--------- cmd/openapi/main.go | 2 +- openapi/README.md | 6 +++--- openapi/openapi_examples_test.go | 6 +++--- openapi/upgrade.go | 2 +- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/cmd/openapi/commands/openapi/upgrade.go b/cmd/openapi/commands/openapi/upgrade.go index 1193c21..b2899f9 100644 --- a/cmd/openapi/commands/openapi/upgrade.go +++ b/cmd/openapi/commands/openapi/upgrade.go @@ -13,12 +13,17 @@ import ( var upgradeCmd = &cobra.Command{ Use: "upgrade [output-file]", Short: "Upgrade an OpenAPI specification to the latest supported version", - Long: `Upgrade an OpenAPI specification document to the latest supported version (3.1.1). + Long: `Upgrade an OpenAPI specification document to the latest supported version (3.2.0). -This command will upgrade OpenAPI documents from: -- OpenAPI 3.0.x versions to 3.1.1 (always) -- OpenAPI 3.1.x versions to 3.1.1 (by default) -- Use --minor-only to only upgrade minor versions (3.0.x to 3.1.1, but skip 3.1.x versions) +By default, upgrades all versions including patch-level upgrades: +- 3.0.x โ†’ 3.2.0 +- 3.1.x โ†’ 3.2.0 +- 3.2.x (e.g., 3.2.0) โ†’ 3.2.0 (patch upgrade if newer patch exists) + +With --minor-only, only performs cross-minor version upgrades: +- 3.0.x โ†’ 3.2.0 (cross-minor upgrade) +- 3.1.x โ†’ 3.2.0 (cross-minor upgrade) +- 3.2.x โ†’ no change (same minor version, skip patch upgrades) The upgrade process includes: - Updating the OpenAPI version field @@ -40,7 +45,7 @@ var ( ) func init() { - upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade minor versions (3.0.x to 3.1.1, skip 3.1.x versions)") + upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade across minor versions, skip patch-level upgrades within same minor") upgradeCmd.Flags().BoolVarP(&writeInPlace, "write", "w", false, "write result in-place to input file") } @@ -81,11 +86,11 @@ func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, upgradeSam // Prepare upgrade options var opts []openapi.Option[openapi.UpgradeOptions] if upgradeSameMinorVersion { - // By default, upgrade all versions including patch versions (3.1.x to 3.1.1) + // By default, upgrade all versions including patch upgrades (e.g., 3.2.0 โ†’ 3.2.1) opts = append(opts, openapi.WithUpgradeSameMinorVersion()) } - // When skipPatchOnly is true, only 3.0.x versions will be upgraded to 3.1.1 - // 3.1.x versions will be skipped unless they need minor version upgrade + // When minorOnly is true, only cross-minor upgrades are performed + // Patch upgrades within the same minor version (e.g., 3.2.0 โ†’ 3.2.1) are skipped // Perform the upgrade originalVersion := doc.OpenAPI diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go index 9410bf6..7e87529 100644 --- a/cmd/openapi/main.go +++ b/cmd/openapi/main.go @@ -67,7 +67,7 @@ This CLI provides tools for: OpenAPI Specifications: - Validate OpenAPI specification documents for compliance -- Upgrade OpenAPI specs to the latest supported version (3.1.1) +- Upgrade OpenAPI specs to the latest supported version (3.2.0) - Inline all references to create self-contained documents - Bundle external references into components section while preserving structure diff --git a/openapi/README.md b/openapi/README.md index 01c8f57..fca42f2 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -35,7 +35,7 @@ ## Supported OpenAPI Versions - OpenAPI 3.0.0 through 3.0.4 -- OpenAPI 3.1.0 through 3.1.1 +- OpenAPI 3.1.0 through 3.1.2 - OpenAPI 3.2.0 The package can automatically upgrade documents from 3.0.x and 3.1.x to 3.2.0, handling the differences in specification between versions. @@ -727,7 +727,7 @@ if err := marshaller.Marshal(ctx, inlinedSchema, buf); err != nil { fmt.Printf("%s", buf.String()) ``` -## Upgrade an OpenAPI document from 3.0.x to 3.1.1 +## Upgrade an OpenAPI document from 3.0.x to 3.2.0 Shows the automatic conversion of nullable fields, examples, and other version differences. @@ -738,7 +738,7 @@ openAPIYAML := `openapi: 3.0.3 info: title: Legacy API version: 1.0.0 - description: An API that needs upgrading from 3.0.3 to 3.1.1 + description: An API that needs upgrading from 3.0.3 to 3.2.0 paths: /users: get: diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index 11d3f3c..6f9d122 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -947,7 +947,7 @@ func Example_inliningSchema() { // } } -// Example_upgrading demonstrates how to upgrade an OpenAPI document from 3.0.x to 3.1.1. +// Example_upgrading demonstrates how to upgrade an OpenAPI document from 3.0.x to 3.2.0. // Shows the automatic conversion of nullable fields, examples, and other version differences. func Example_upgrading() { ctx := context.Background() @@ -957,7 +957,7 @@ func Example_upgrading() { info: title: Legacy API version: 1.0.0 - description: An API that needs upgrading from 3.0.3 to 3.1.1 + description: An API that needs upgrading from 3.0.3 to 3.2.0 paths: /users: get: @@ -1019,7 +1019,7 @@ components: // info: // title: Legacy API // version: 1.0.0 - // description: An API that needs upgrading from 3.0.3 to 3.1.1 + // description: An API that needs upgrading from 3.0.3 to 3.2.0 // paths: // /users: // get: diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 764819c..dbee9ed 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -18,7 +18,7 @@ type UpgradeOptions struct { targetVersion string } -// WithUpgradeSameMinorVersion will upgrade the same minor version of the OpenAPI document. For example 3.1.0 to 3.1.1. +// WithUpgradeSameMinorVersion will upgrade the same minor version of the OpenAPI document. For example 3.2.0 to 3.2.1. func WithUpgradeSameMinorVersion() Option[UpgradeOptions] { return func(uo *UpgradeOptions) { uo.upgradeSameMinorVersion = true From 59a527f4d98b9b52dc9ba88569d6ed5841eda944 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Tue, 9 Dec 2025 23:20:31 +0000 Subject: [PATCH 26/26] fix: prevent file content disclosure in validation error messages --- arazzo/arazzo.go | 31 ++--- internal/version/version.go | 20 +++- internal/version/version_test.go | 4 +- jsonschema/oas3/jsonschema_validate_test.go | 2 +- marshaller/unmarshaller.go | 12 +- openapi/openapi.go | 31 ++--- openapi/openapi_examples_test.go | 6 +- openapi/openapi_unmarshal_test.go | 2 +- openapi/paths.go | 2 +- openapi/reference_resolve_test.go | 122 ++++++++++++++++++++ openapi/upgrade.go | 8 +- 11 files changed, 173 insertions(+), 67 deletions(-) diff --git a/arazzo/arazzo.go b/arazzo/arazzo.go index 82b8242..4e6d9ec 100644 --- a/arazzo/arazzo.go +++ b/arazzo/arazzo.go @@ -23,27 +23,10 @@ const ( Version = "1.0.1" ) -func MinimumSupportedVersion() version.Version { - v, err := version.ParseVersion("1.0.0") - if err != nil { - panic("failed to parse minimum supported Arazzo version: " + err.Error()) - } - if v == nil { - panic("minimum supported Arazzo version is nil") - } - return *v -} - -func MaximumSupportedVersion() version.Version { - v, err := version.ParseVersion(Version) - if err != nil { - panic("failed to parse maximum supported Arazzo version: " + err.Error()) - } - if v == nil { - panic("maximum supported Arazzo version is nil") - } - return *v -} +var ( + MinimumSupportedVersion = version.MustParse("1.0.0") + MaximumSupportedVersion = version.MustParse(Version) +) // Arazzo is the root object for an Arazzo document. type Arazzo struct { @@ -124,13 +107,13 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro core := a.GetCore() errs := []error{} - arazzoVersion, err := version.ParseVersion(a.Arazzo) + arazzoVersion, err := version.Parse(a.Arazzo) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo)) } if arazzoVersion != nil { - if arazzoVersion.GreaterThan(MaximumSupportedVersion()) { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion(), MaximumSupportedVersion()), core, core.Arazzo)) + if arazzoVersion.GreaterThan(*MaximumSupportedVersion) { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.Arazzo)) } } diff --git a/internal/version/version.go b/internal/version/version.go index 80b6f90..4005ee7 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -50,7 +50,7 @@ func (v Version) LessThan(other Version) bool { return !v.Equal(other) && !v.GreaterThan(other) } -func ParseVersion(version string) (*Version, error) { +func Parse(version string) (*Version, error) { parts := strings.Split(version, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid version %s", version) @@ -83,21 +83,29 @@ func ParseVersion(version string) (*Version, error) { return New(major, minor, patch), nil } -func IsVersionGreaterOrEqual(a, b string) (bool, error) { - versionA, err := ParseVersion(a) +func MustParse(version string) *Version { + v, err := Parse(version) + if err != nil { + panic(err) + } + return v +} + +func IsGreaterOrEqual(a, b string) (bool, error) { + versionA, err := Parse(a) if err != nil { return false, fmt.Errorf("invalid version %s: %w", a, err) } - versionB, err := ParseVersion(b) + versionB, err := Parse(b) if err != nil { return false, fmt.Errorf("invalid version %s: %w", b, err) } return versionA.Equal(*versionB) || versionA.GreaterThan(*versionB), nil } -func IsVersionLessThan(a, b string) (bool, error) { - greaterOrEqual, err := IsVersionGreaterOrEqual(a, b) +func IsLessThan(a, b string) (bool, error) { + greaterOrEqual, err := IsGreaterOrEqual(a, b) if err != nil { return false, err } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 8a41682..7e2a196 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -52,7 +52,7 @@ func Test_ParseVersion_Success(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - version, err := ParseVersion(tt.args.version) + version, err := Parse(tt.args.version) require.NoError(t, err) assert.Equal(t, tt.expectedMajor, version.Major) assert.Equal(t, tt.expectedMinor, version.Minor) @@ -131,7 +131,7 @@ func Test_ParseVersion_Error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - version, err := ParseVersion(tt.args.version) + version, err := Parse(tt.args.version) require.Error(t, err) assert.Nil(t, version) }) diff --git a/jsonschema/oas3/jsonschema_validate_test.go b/jsonschema/oas3/jsonschema_validate_test.go index f64a562..66e1bfe 100644 --- a/jsonschema/oas3/jsonschema_validate_test.go +++ b/jsonschema/oas3/jsonschema_validate_test.go @@ -23,7 +23,7 @@ func TestJSONSchema_Validate_Error(t *testing.T) { name: "schema fails direct validation", yml: ` "test"`, - wantErrs: []string{"[2:1] failed to validate either Schema [expected object, got `test`] or bool [line 2: cannot unmarshal !!str `test` into bool]"}, + wantErrs: []string{"[2:1] failed to validate either Schema [expected object, got `te...`] or bool [line 2: cannot unmarshal !!str `test` into bool]"}, }, { name: "child schema fails validation", diff --git a/marshaller/unmarshaller.go b/marshaller/unmarshaller.go index 2b6c958..1909012 100644 --- a/marshaller/unmarshaller.go +++ b/marshaller/unmarshaller.go @@ -675,7 +675,17 @@ func validateNodeKind(resolvedNode *yaml.Node, expectedKind yaml.Kind, parentNam actualKindStr := yml.NodeKindToString(resolvedNode.Kind) if actualKindStr == "scalar" { - actualKindStr = fmt.Sprintf("`%s`", resolvedNode.Value) + // Truncate value to prevent file content disclosure + // External references that fail to parse may contain sensitive file content + value := resolvedNode.Value + maxLen := 7 + if len(value) < maxLen { + maxLen = len(value) / 2 + } + if len(value) > maxLen { + value = value[:maxLen] + "..." + } + actualKindStr = fmt.Sprintf("`%s`", value) } return validation.NewValidationError(validation.NewTypeMismatchError(parentName, "expected %s, got %s", expectedType, actualKindStr), resolvedNode) diff --git a/openapi/openapi.go b/openapi/openapi.go index 4c29277..435df31 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -20,27 +20,10 @@ const ( Version = "3.2.0" ) -func MinimumSupportedVersion() version.Version { - v, err := version.ParseVersion("3.0.0") - if err != nil { - panic("failed to parse minimum supported OpenAPI version: " + err.Error()) - } - if v == nil { - panic("minimum supported OpenAPI version is nil") - } - return *v -} - -func MaximumSupportedVersion() version.Version { - v, err := version.ParseVersion(Version) - if err != nil { - panic("failed to parse maximum supported OpenAPI version: " + err.Error()) - } - if v == nil { - panic("maximum supported OpenAPI version is nil") - } - return *v -} +var ( + MinimumSupportedVersion = version.MustParse("3.0.0") + MaximumSupportedVersion = version.MustParse(Version) +) // OpenAPI represents an OpenAPI document compatible with the OpenAPI Specification 3.0.X and 3.1.X. // Where the specification differs between versions the @@ -194,13 +177,13 @@ func (o *OpenAPI) Validate(ctx context.Context, opts ...validation.Option) []err opts = append(opts, validation.WithContextObject(o)) opts = append(opts, validation.WithContextObject(&oas3.ParentDocumentVersion{OpenAPI: pointer.From(o.OpenAPI)})) - docVersion, err := version.ParseVersion(o.OpenAPI) + docVersion, err := version.Parse(o.OpenAPI) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.openapi invalid OpenAPI version %s: %s", o.OpenAPI, err.Error()), core, core.OpenAPI)) } if docVersion != nil { - if docVersion.LessThan(MinimumSupportedVersion()) || docVersion.GreaterThan(MaximumSupportedVersion()) { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.openapi only OpenAPI versions between %s and %s are supported", MinimumSupportedVersion(), MaximumSupportedVersion()), core, core.OpenAPI)) + if docVersion.LessThan(*MinimumSupportedVersion) || docVersion.GreaterThan(*MaximumSupportedVersion) { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.openapi only OpenAPI versions between %s and %s are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.OpenAPI)) } } diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index 6f9d122..01565b0 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -19,7 +19,7 @@ import ( "github.com/speakeasy-api/openapi/yml" ) -// The below examples should be copied into the README.md file if ever changed +// The below examples should be copied into the README.md file if ever changed using `mise run update-examples` // Example_reading demonstrates how to read and parse an OpenAPI document from a file. // This includes validation by default and shows how to access document properties. @@ -265,10 +265,10 @@ func Example_validating() { // [56:7] schema.examples expected array, got object // [59:15] schema.properties.name expected one of [boolean, object], got string // [59:15] schema.properties.name expected one of [boolean, object], got string - // [59:15] schema.properties.name failed to validate either Schema [schema.properties.name expected object, got `string`] or bool [schema.properties.name line 59: cannot unmarshal !!str `string` into bool] + // [59:15] schema.properties.name failed to validate either Schema [schema.properties.name expected object, got `str...`] or bool [schema.properties.name line 59: cannot unmarshal !!str `string` into bool] // [60:18] schema.properties.example expected one of [boolean, object], got string // [60:18] schema.properties.example expected one of [boolean, object], got string - // [60:18] schema.properties.example failed to validate either Schema [schema.properties.example expected object, got `John Doe`] or bool [schema.properties.example line 60: cannot unmarshal !!str `John Doe` into bool] + // [60:18] schema.properties.example failed to validate either Schema [schema.properties.example expected object, got `John Do...`] or bool [schema.properties.example line 60: cannot unmarshal !!str `John Doe` into bool] // [63:9] schema.examples expected sequence, got object // // Fixing validation errors... diff --git a/openapi/openapi_unmarshal_test.go b/openapi/openapi_unmarshal_test.go index 39e8565..b3ed5de 100644 --- a/openapi/openapi_unmarshal_test.go +++ b/openapi/openapi_unmarshal_test.go @@ -137,7 +137,7 @@ info: title: Test API version: 1.0.0 paths: {}`, - wantErrs: []string{fmt.Sprintf("[1:10] openapi.openapi only OpenAPI versions between %s and %s are supported", openapi.MinimumSupportedVersion(), openapi.MaximumSupportedVersion())}, + wantErrs: []string{fmt.Sprintf("[1:10] openapi.openapi only OpenAPI versions between %s and %s are supported", openapi.MinimumSupportedVersion, openapi.MaximumSupportedVersion)}, }, } diff --git a/openapi/paths.go b/openapi/paths.go index f756884..1b59778 100644 --- a/openapi/paths.go +++ b/openapi/paths.go @@ -306,7 +306,7 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, parameter.Validate(ctx, opts...)...) } - supportsAdditionalOperations, err := version.IsVersionGreaterOrEqual(openapiVersion, Version) + supportsAdditionalOperations, err := version.IsGreaterOrEqual(openapiVersion, Version) switch { case err != nil: errs = append(errs, err) diff --git a/openapi/reference_resolve_test.go b/openapi/reference_resolve_test.go index 53089ee..92f74f8 100644 --- a/openapi/reference_resolve_test.go +++ b/openapi/reference_resolve_test.go @@ -1,10 +1,12 @@ package openapi import ( + "bytes" "io" "io/fs" "os" "path/filepath" + "strings" "testing" "time" @@ -1140,3 +1142,123 @@ func TestResolveObject_WithSelf_Success(t *testing.T) { assert.Equal(t, 1, mockFS.GetAccessCount("/test/api/shared.yaml"), "shared.yaml should be accessed via path resolved from $self, not from TargetLocation") } + +func TestSecurityFileDisclosure_WithMockFS(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a mock file system with simulated sensitive content + mockFS := NewMockVirtualFS() + sensitiveContent := []byte(`root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin`) + + mockFS.AddFile("/etc/passwd", sensitiveContent) + + // Schema files can be in any directory (since $self is /etc/passwd - absolute ref is resolved) + schemasContent := []byte(` +User: + type: object + properties: + name: + type: string +`) + mockFS.AddFile("/test/api/shared/schemas.yaml", schemasContent) + + // Document with reference that directly points to /etc/passwd + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +components: + schemas: + User: + $ref: "/etc/passwd" +` + + doc, validationErrs, err := Unmarshal(ctx, bytes.NewBufferString(yml)) + require.NoError(t, err, "Document should parse") + if len(validationErrs) > 0 { + t.Logf("Validation errors during unmarshal: %v", validationErrs) + } + + // Try to resolve references using the mock FS + resolveOpts := ResolveOptions{ + RootDocument: doc, + TargetLocation: ".", + DisableExternalRefs: false, + VirtualFS: mockFS, + } + + // Try to resolve the schema reference + if doc.Components != nil && doc.Components.Schemas != nil { + for schemaName, schema := range doc.Components.Schemas.All() { + t.Logf("Checking schema: %s, IsReference: %v", schemaName, schema.IsReference()) + if schema.IsReference() { + t.Logf("Attempting to resolve reference: %s", schema.GetReference()) + resolveErrs, resolveErr := schema.Resolve(ctx, resolveOpts) + + // Should fail because /etc/passwd is not valid YAML + if resolveErr == nil && len(resolveErrs) == 0 { + t.Error("Expected resolution to fail for non-OpenAPI file") + } + + // Collect all error messages + var allErrors []string + if resolveErr != nil { + allErrors = append(allErrors, resolveErr.Error()) + t.Logf("Resolve error: %s", resolveErr.Error()) + } + for _, err := range resolveErrs { + if err != nil { + allErrors = append(allErrors, err.Error()) + t.Logf("Validation error: %s", err.Error()) + } + } + + combinedError := strings.Join(allErrors, "\n") + + // Check if our known sensitive content appears in the error + sensitivePatterns := []string{ + "root:x:0:0", + "daemon:x:1:1", + "/bin/bash", + "/usr/sbin/nologin", + } + + foundSensitiveContent := false + for _, pattern := range sensitivePatterns { + if len(combinedError) > 0 && containsString(combinedError, pattern) { + t.Errorf("SECURITY ISSUE: Sensitive content leaked in error message. Pattern found: %q", pattern) + foundSensitiveContent = true + break + } + } + + if !foundSensitiveContent { + t.Logf("PASS: No sensitive content found in error messages") + } + + // Verify file was accessed + if len(mockFS.GetAccessLog()) > 0 { + t.Logf("Files accessed: %v", mockFS.GetAccessLog()) + } + } + } + } +} + +// Helper function for case-sensitive string containment +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || indexString(s, substr) >= 0) +} + +func indexString(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/openapi/upgrade.go b/openapi/upgrade.go index dbee9ed..ec056ac 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -46,12 +46,12 @@ func Upgrade(ctx context.Context, doc *OpenAPI, opts ...Option[UpgradeOptions]) options.targetVersion = Version } - currentVersion, err := version.ParseVersion(doc.OpenAPI) + currentVersion, err := version.Parse(doc.OpenAPI) if err != nil { return false, err } - targetVersion, err := version.ParseVersion(options.targetVersion) + targetVersion, err := version.Parse(options.targetVersion) if err != nil { return false, err } @@ -106,7 +106,7 @@ func upgradeFrom310To312(_ context.Context, doc *OpenAPI, currentVersion *versio } // Currently no breaking changes between 3.1.0 and 3.1.2 that need to be handled - maxVersion, err := version.ParseVersion("3.1.2") + maxVersion, err := version.Parse("3.1.2") if err != nil { panic("failed to parse hardcoded version 3.1.2") } @@ -130,7 +130,7 @@ func upgradeFrom31To32(ctx context.Context, doc *OpenAPI, currentVersion *versio } // Currently no breaking changes between 3.1.x and 3.2.x that need to be handled - maxVersion, err := version.ParseVersion("3.2.0") + maxVersion, err := version.Parse("3.2.0") if err != nil { return err }