From 6c5141601d178130c4ccf0a81101ed292530cab6 Mon Sep 17 00:00:00 2001 From: Blake Preston Date: Thu, 25 Sep 2025 23:25:27 +0000 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 {