diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5744b85..a930d37 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,6 +17,8 @@ jobs: - name: Install mise uses: jdx/mise-action@v3 + with: + experimental: true - name: Setup Go with caching uses: actions/setup-go@v6 diff --git a/.mise.toml b/.mise.toml index 1dccfed..ae32f12 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,19 +1,19 @@ [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 -- which golangci-lint-v2) $(dirname $(mise exec -- which golangci-lint-v2))/golangci-lint || true", - "ln -sf $(mise exec -- which golangci-lint) .vscode/mise-tools/golangci-lint", + "ln -sf $(mise exec golangci-lint@2.7.2 -- which golangci-lint) .vscode/mise-tools/golangci-lint", ] [hooks] postinstall = [ "ln -sf ./AGENTS.md ./CLAUDE.md", + "git submodule update --init --recursive", + "mise exec go@1.24.3 -- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2", "mise run setup-vscode-symlinks", "go install go.uber.org/nilaway/cmd/nilaway@8ad05f0", ] diff --git a/AGENTS.md b/AGENTS.md index 60dd922..3e961ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,54 @@ mise test -count=1 ./... - **Race Detection**: Automatically enables race detection to catch concurrency issues - **Submodule Awareness**: Checks for and warns about uninitialized test submodules +## Git Commit Conventions + +**Always use single-line conventional commits.** Do not create multi-line commit messages. + +### Commit Message Format + +``` +: +``` + +### Common Types + +- `feat:` - New feature +- `fix:` - Bug fix +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `test:` - Adding or updating tests +- `chore:` - Maintenance tasks +- `perf:` - Performance improvements + +### Examples + +#### ✅ Good: Single-line conventional commits + +```bash +git commit -m "feat: add prefixEncoding and itemEncoding support for OpenAPI 3.2 multipart media types" +git commit -m "fix: correct validation logic for encoding field mutual exclusivity" +git commit -m "test: add comprehensive tests for multipart encoding validation" +git commit -m "refactor: simplify media type context passing in validation" +``` + +#### ❌ Bad: Multi-line commits + +```bash +git commit -m "feat: implement prefixEncoding and itemEncoding for OpenAPI 3.2 + +- Add PrefixEncoding and ItemEncoding fields to MediaType +- Implement validation for mutual exclusivity +- Add comprehensive tests" +``` + +### Why Single-Line Commits? + +1. **Simplicity**: Easy to read in git log and GitHub UI +2. **Consistency**: All commits follow the same pattern +3. **Searchability**: Easier to search and filter commits +4. **Tool Compatibility**: Works better with automated tools and scripts + ## Testing Follow these testing conventions when writing Go tests in this project. Run newly added or modified test immediately after changes to make sure they work as expected before continuing with more work. diff --git a/README.md b/README.md index 24719ba..7e3dbf1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ OpenAPI Hub - OpenAPI Support + OpenAPI Support Arazzo Support Go Doc @@ -68,7 +68,7 @@ The `arazzo` package provides an API for working with Arazzo documents including ### [openapi](./openapi) -The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports both OpenAPI 3.0.x and 3.1.x specifications. +The `openapi` package provides an API for working with OpenAPI documents including reading, creating, mutating, walking, validating and upgrading them. Supports OpenAPI 3.0.x, 3.1.x, and 3.2.x specifications. ### [swagger](./swagger) diff --git a/arazzo/arazzo.go b/arazzo/arazzo.go index c7e2543..4e6d9ec 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,10 +20,12 @@ 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" +) + +var ( + MinimumSupportedVersion = version.MustParse("1.0.0") + MaximumSupportedVersion = version.MustParse(Version) ) // Arazzo is the root object for an Arazzo document. @@ -105,13 +107,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.Parse(a.Arazzo) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo)) } - - if arazzoMajor != VersionMajor || arazzoMinor != VersionMinor || arazzoPatch > VersionPatch { - errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo.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.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 7937b57..8c567aa 100644 --- a/arazzo/arazzo_test.go +++ b/arazzo/arazzo_test.go @@ -301,7 +301,7 @@ sourceDescriptions: underlyingError error }{ {line: 1, column: 1, underlyingError: validation.NewMissingFieldError("arazzo.workflows is missing")}, - {line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo.version only 1.0.1 and below is supported")}, + {line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo.version only Arazzo versions between 1.0.0 and 1.0.1 are supported")}, {line: 4, column: 3, underlyingError: validation.NewMissingFieldError("info.version is missing")}, {line: 6, column: 5, underlyingError: validation.NewMissingFieldError("sourceDescription.url is missing")}, {line: 7, column: 11, underlyingError: validation.NewValueValidationError("sourceDescription.type must be one of [openapi, arazzo]")}, diff --git a/cmd/openapi/commands/openapi/README.md b/cmd/openapi/commands/openapi/README.md index 3fa2e95..625e2e8 100644 --- a/cmd/openapi/commands/openapi/README.md +++ b/cmd/openapi/commands/openapi/README.md @@ -50,7 +50,7 @@ This command checks for: ### `upgrade` -Upgrade an OpenAPI specification to the latest supported version (3.1.1). +Upgrade an OpenAPI specification to the latest supported version (3.2.0). ```bash # Upgrade to stdout @@ -64,11 +64,12 @@ openapi spec upgrade -w ./spec.yaml # Upgrade with specific target version openapi spec upgrade --version 3.1.0 ./spec.yaml +openapi spec upgrade --version 3.2.0 ./spec.yaml ``` Features: -- Converts OpenAPI 3.0.x specifications to 3.1.x +- Converts OpenAPI 3.0.x and 3.1.x specifications to 3.2.0 - Maintains backward compatibility where possible - Updates schema formats and structures - Preserves all custom extensions and vendor-specific content @@ -937,7 +938,7 @@ All commands work with both YAML and JSON input files and preserve the original openapi spec validate ./spec.yaml # Upgrade if needed -openapi spec upgrade ./spec.yaml ./spec-v3.1.yaml +openapi spec upgrade ./spec.yaml ./spec-v3.2.yaml # Bundle external references openapi spec bundle ./spec-v3.1.yaml ./spec-bundled.yaml diff --git a/cmd/openapi/commands/openapi/upgrade.go b/cmd/openapi/commands/openapi/upgrade.go index f6fa0cf..b2899f9 100644 --- a/cmd/openapi/commands/openapi/upgrade.go +++ b/cmd/openapi/commands/openapi/upgrade.go @@ -13,12 +13,17 @@ import ( var upgradeCmd = &cobra.Command{ Use: "upgrade [output-file]", Short: "Upgrade an OpenAPI specification to the latest supported version", - Long: `Upgrade an OpenAPI specification document to the latest supported version (3.1.1). + Long: `Upgrade an OpenAPI specification document to the latest supported version (3.2.0). -This command will upgrade OpenAPI documents from: -- OpenAPI 3.0.x versions to 3.1.1 (always) -- OpenAPI 3.1.x versions to 3.1.1 (by default) -- Use --minor-only to only upgrade minor versions (3.0.x to 3.1.1, but skip 3.1.x versions) +By default, upgrades all versions including patch-level upgrades: +- 3.0.x → 3.2.0 +- 3.1.x → 3.2.0 +- 3.2.x (e.g., 3.2.0) → 3.2.0 (patch upgrade if newer patch exists) + +With --minor-only, only performs cross-minor version upgrades: +- 3.0.x → 3.2.0 (cross-minor upgrade) +- 3.1.x → 3.2.0 (cross-minor upgrade) +- 3.2.x → no change (same minor version, skip patch upgrades) The upgrade process includes: - Updating the OpenAPI version field @@ -40,7 +45,7 @@ var ( ) func init() { - upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade minor versions (3.0.x to 3.1.1, skip 3.1.x versions)") + upgradeCmd.Flags().BoolVar(&minorOnly, "minor-only", false, "only upgrade across minor versions, skip patch-level upgrades within same minor") upgradeCmd.Flags().BoolVarP(&writeInPlace, "write", "w", false, "write result in-place to input file") } @@ -59,13 +64,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,12 +85,12 @@ func upgradeOpenAPI(ctx context.Context, processor *OpenAPIProcessor, minorOnly // Prepare upgrade options var opts []openapi.Option[openapi.UpgradeOptions] - if !minorOnly { - // By default, upgrade all versions including patch versions (3.1.x to 3.1.1) - opts = append(opts, openapi.WithUpgradeSamePatchVersion()) + if upgradeSameMinorVersion { + // By default, upgrade all versions including patch upgrades (e.g., 3.2.0 → 3.2.1) + opts = append(opts, openapi.WithUpgradeSameMinorVersion()) } - // When minorOnly is true, only 3.0.x versions will be upgraded to 3.1.1 - // 3.1.x versions will be skipped unless they need minor version upgrade + // When minorOnly is true, only cross-minor upgrades are performed + // Patch upgrades within the same minor version (e.g., 3.2.0 → 3.2.1) are skipped // Perform the upgrade originalVersion := doc.OpenAPI diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go index 9410bf6..7e87529 100644 --- a/cmd/openapi/main.go +++ b/cmd/openapi/main.go @@ -67,7 +67,7 @@ This CLI provides tools for: OpenAPI Specifications: - Validate OpenAPI specification documents for compliance -- Upgrade OpenAPI specs to the latest supported version (3.1.1) +- Upgrade OpenAPI specs to the latest supported version (3.2.0) - Inline all references to create self-contained documents - Bundle external references into components section while preserving structure diff --git a/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..4005ee7 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,113 @@ +package version + +import ( + "fmt" + "strconv" + "strings" +) + +type Version struct { + Major int + Minor int + Patch int +} + +var _ fmt.Stringer = (*Version)(nil) + +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 Parse(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 MustParse(version string) *Version { + v, err := Parse(version) + if err != nil { + panic(err) + } + return v +} + +func IsGreaterOrEqual(a, b string) (bool, error) { + versionA, err := Parse(a) + if err != nil { + return false, fmt.Errorf("invalid version %s: %w", a, err) + } + + versionB, err := Parse(b) + if err != nil { + return false, fmt.Errorf("invalid version %s: %w", b, err) + } + return versionA.Equal(*versionB) || versionA.GreaterThan(*versionB), nil +} + +func IsLessThan(a, b string) (bool, error) { + greaterOrEqual, err := IsGreaterOrEqual(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..7e2a196 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 := Parse(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 := Parse(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/jsonschema/oas3/core/discriminator.go b/jsonschema/oas3/core/discriminator.go index 56a5168..c63bcb9 100644 --- a/jsonschema/oas3/core/discriminator.go +++ b/jsonschema/oas3/core/discriminator.go @@ -9,7 +9,8 @@ import ( type Discriminator struct { marshaller.CoreModel `model:"discriminator"` - PropertyName marshaller.Node[string] `key:"propertyName"` - Mapping marshaller.Node[*sequencedmap.Map[string, marshaller.Node[string]]] `key:"mapping"` - Extensions core.Extensions `key:"extensions"` + PropertyName marshaller.Node[string] `key:"propertyName"` + Mapping marshaller.Node[*sequencedmap.Map[string, marshaller.Node[string]]] `key:"mapping"` + DefaultMapping marshaller.Node[*string] `key:"defaultMapping"` + Extensions core.Extensions `key:"extensions"` } diff --git a/jsonschema/oas3/discriminator.go b/jsonschema/oas3/discriminator.go index aebb553..f10f00b 100644 --- a/jsonschema/oas3/discriminator.go +++ b/jsonschema/oas3/discriminator.go @@ -16,9 +16,18 @@ type Discriminator struct { marshaller.Model[core.Discriminator] // PropertyName is the name of the property in the payload that will hold the discriminator value. + // This field is REQUIRED in all OpenAPI versions. + // In OpenAPI 3.2+, the property that this references MAY be optional in the schema, + // but when it is optional, DefaultMapping MUST be provided. PropertyName string // Mapping is an object to hold mappings between payload values and schema names or references. Mapping *sequencedmap.Map[string, string] + // DefaultMapping is the schema name or URI reference to a schema that is expected to validate + // the structure of the model when the discriminating property is not present in the payload + // or contains a value for which there is no explicit or implicit mapping. + // This field is part of OpenAPI 3.2+ and is required when the property referenced by + // PropertyName is optional in the schema. + DefaultMapping *string // Extensions provides a list of extensions to the Discriminator object. Extensions *extensions.Extensions } @@ -41,6 +50,14 @@ func (d *Discriminator) GetMapping() *sequencedmap.Map[string, string] { return d.Mapping } +// GetDefaultMapping returns the value of the DefaultMapping field. Returns empty string if not set. +func (d *Discriminator) GetDefaultMapping() string { + if d == nil || d.DefaultMapping == nil { + return "" + } + return *d.DefaultMapping +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (d *Discriminator) GetExtensions() *extensions.Extensions { if d == nil || d.Extensions == nil { @@ -54,10 +71,18 @@ func (d *Discriminator) Validate(ctx context.Context, opts ...validation.Option) core := d.GetCore() errs := []error{} + // propertyName is REQUIRED in all OpenAPI versions if core.PropertyName.Present { if core.PropertyName.Value == "" { errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.propertyName is required"), core, core.PropertyName)) } + } else { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.propertyName is required"), core, core.PropertyName)) + } + + // defaultMapping validation - must not be empty if present + if core.DefaultMapping.Present && (core.DefaultMapping.Value == nil || *core.DefaultMapping.Value == "") { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("discriminator.defaultMapping cannot be empty"), core, core.DefaultMapping)) } d.Valid = len(errs) == 0 && core.GetValid() @@ -79,6 +104,16 @@ func (d *Discriminator) IsEqual(other *Discriminator) bool { return false } + // Compare DefaultMapping (both pointers) + switch { + case d.DefaultMapping == nil && other.DefaultMapping == nil: + // Both nil, continue + case d.DefaultMapping == nil || other.DefaultMapping == nil: + return false + case *d.DefaultMapping != *other.DefaultMapping: + return false + } + // Compare Mapping using sequencedmap's IsEqual method switch { case d.Mapping == nil && other.Mapping == nil: diff --git a/jsonschema/oas3/discriminator_32_test.go b/jsonschema/oas3/discriminator_32_test.go new file mode 100644 index 0000000..7582731 --- /dev/null +++ b/jsonschema/oas3/discriminator_32_test.go @@ -0,0 +1,133 @@ +package oas3_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/pointer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscriminator_OpenAPI32_DefaultMapping_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + expectedDefaultMap string + }{ + { + name: "discriminator with propertyName and defaultMapping", + yml: ` +propertyName: petType +defaultMapping: "#/components/schemas/OtherPet" +mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" +`, + expectedDefaultMap: "#/components/schemas/OtherPet", + }, + { + name: "discriminator with defaultMapping using schema name", + yml: ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + expectedDefaultMap: "OtherPet", + }, + { + name: "discriminator with propertyName and defaultMapping but no mapping", + yml: ` +propertyName: petType +defaultMapping: DefaultPet +`, + expectedDefaultMap: "DefaultPet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + assert.Equal(t, tt.expectedDefaultMap, discriminator.GetDefaultMapping()) + }) + } +} + +func TestDiscriminator_OpenAPI32_IsEqual(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disc1 *oas3.Discriminator + disc2 *oas3.Discriminator + expected bool + }{ + { + name: "equal discriminators with defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + expected: true, + }, + { + name: "different defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("DefaultPet"), + }, + expected: false, + }, + { + name: "one with defaultMapping, one without", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + }, + expected: false, + }, + { + name: "both with same propertyName and defaultMapping", + disc1: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + disc2: &oas3.Discriminator{ + PropertyName: "petType", + DefaultMapping: pointer.From("OtherPet"), + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expected, tt.disc1.IsEqual(tt.disc2)) + }) + } +} diff --git a/jsonschema/oas3/discriminator_validate_test.go b/jsonschema/oas3/discriminator_validate_test.go index 681264a..878f3fa 100644 --- a/jsonschema/oas3/discriminator_validate_test.go +++ b/jsonschema/oas3/discriminator_validate_test.go @@ -87,11 +87,13 @@ func TestDiscriminator_Validate_Error(t *testing.T) { }{ { name: "missing property name", - yml: ` -mapping: + yml: `mapping: dog: "#/components/schemas/Dog" `, - wantErrs: []string{"[2:1] discriminator.propertyName is missing"}, + wantErrs: []string{ + "[1:1] discriminator.propertyName is missing", + "[1:1] discriminator.propertyName is required", + }, }, { name: "empty property name", diff --git a/jsonschema/oas3/discriminator_version_test.go b/jsonschema/oas3/discriminator_version_test.go new file mode 100644 index 0000000..3a160ab --- /dev/null +++ b/jsonschema/oas3/discriminator_version_test.go @@ -0,0 +1,204 @@ +package oas3_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/pointer" + "github.com/speakeasy-api/openapi/validation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscriminator_VersionAwareValidation_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + version string + isValid bool + }{ + { + name: "OAS 3.1 - propertyName required", + yml: ` +propertyName: petType +mapping: + cat: Cat + dog: Dog +`, + version: "3.1.0", + isValid: true, + }, + { + name: "OAS 3.2 - propertyName with defaultMapping", + yml: ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: true, + }, + { + name: "OAS 3.2 - propertyName without defaultMapping (property is required in schema)", + yml: ` +propertyName: petType +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: true, + }, + { + name: "OAS 3.1 - missing propertyName should fail", + yml: `mapping: + cat: Cat + dog: Dog +`, + version: "3.1.0", + isValid: false, + }, + { + name: "OAS 3.2 - missing propertyName should also fail", + yml: `defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog +`, + version: "3.2.0", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var discriminator oas3.Discriminator + + // Collect all errors from unmarshalling + var allErrors []error + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &discriminator) + require.NoError(t, err) + allErrors = append(allErrors, validationErrs...) + + // Create version context + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: &tt.version, + }), + } + + // Collect validation errors + validateErrs := discriminator.Validate(t.Context(), opts...) + allErrors = append(allErrors, validateErrs...) + + if tt.isValid { + assert.Empty(t, allErrors, "expected no validation errors for version %s", tt.version) + assert.True(t, discriminator.Valid, "expected discriminator to be valid for version %s", tt.version) + } else { + assert.NotEmpty(t, allErrors, "expected validation errors for version %s", tt.version) + assert.False(t, discriminator.Valid, "expected discriminator to be invalid for version %s", tt.version) + } + }) + } +} + +func TestDiscriminator_OpenAPI32_CompleteExample_Success(t *testing.T) { + t.Parallel() + + // Full example from the spec showing optional propertyName with defaultMapping + yml := ` +propertyName: petType +defaultMapping: OtherPet +mapping: + cat: Cat + dog: Dog + lizard: Lizard +x-custom-extension: value +` + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Validate with OpenAPI 3.2 + version := "3.2.0" + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: &version, + }), + } + + errs := discriminator.Validate(t.Context(), opts...) + require.Empty(t, errs) + require.True(t, discriminator.Valid) + + // Verify all fields + assert.Equal(t, "petType", discriminator.GetPropertyName()) + assert.Equal(t, "OtherPet", discriminator.GetDefaultMapping()) + + mapping := discriminator.GetMapping() + require.NotNil(t, mapping) + assert.Equal(t, 3, mapping.Len()) + + cat, ok := mapping.Get("cat") + assert.True(t, ok) + assert.Equal(t, "Cat", cat) + + dog, ok := mapping.Get("dog") + assert.True(t, ok) + assert.Equal(t, "Dog", dog) + + lizard, ok := mapping.Get("lizard") + assert.True(t, ok) + assert.Equal(t, "Lizard", lizard) + + extensions := discriminator.GetExtensions() + require.NotNil(t, extensions) + + ext, ok := extensions.Get("x-custom-extension") + require.True(t, ok) + assert.Equal(t, "value", ext.Value) +} + +func TestDiscriminator_BackwardCompatibility_Success(t *testing.T) { + t.Parallel() + + // Verify that existing OAS 3.1 discriminators still work + yml := ` +propertyName: petType +mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" +` + + var discriminator oas3.Discriminator + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &discriminator) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Test with both 3.1 and 3.2 versions + for _, version := range []string{"3.1.0", "3.2.0"} { + t.Run("version_"+version, func(t *testing.T) { + t.Parallel() + + opts := []validation.Option{ + validation.WithContextObject(&oas3.ParentDocumentVersion{ + OpenAPI: pointer.From(version), + }), + } + + errs := discriminator.Validate(t.Context(), opts...) + assert.Empty(t, errs, "valid OAS 3.1 discriminator should work in version %s", version) + assert.True(t, discriminator.Valid) + }) + } +} diff --git a/jsonschema/oas3/jsonschema_validate_test.go b/jsonschema/oas3/jsonschema_validate_test.go index f64a562..66e1bfe 100644 --- a/jsonschema/oas3/jsonschema_validate_test.go +++ b/jsonschema/oas3/jsonschema_validate_test.go @@ -23,7 +23,7 @@ func TestJSONSchema_Validate_Error(t *testing.T) { name: "schema fails direct validation", yml: ` "test"`, - wantErrs: []string{"[2:1] failed to validate either Schema [expected object, got `test`] or bool [line 2: cannot unmarshal !!str `test` into bool]"}, + wantErrs: []string{"[2:1] failed to validate either Schema [expected object, got `te...`] or bool [line 2: cannot unmarshal !!str `test` into bool]"}, }, { name: "child schema fails validation", diff --git a/jsonschema/oas3/schema30.json b/jsonschema/oas3/schema30.dialect.json similarity index 100% rename from jsonschema/oas3/schema30.json rename to jsonschema/oas3/schema30.dialect.json diff --git a/jsonschema/oas3/schema31.base.json b/jsonschema/oas3/schema31.base.json deleted file mode 100644 index a7a59f1..0000000 --- a/jsonschema/oas3/schema31.base.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$id": "https://spec.openapis.org/oas/3.1/meta/base", - "$schema": "https://json-schema.org/draft/2020-12/schema", - - "title": "OAS Base vocabulary", - "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", - - "$vocabulary": { - "https://spec.openapis.org/oas/3.1/vocab/base": true - }, - - "$dynamicAnchor": "meta", - - "type": ["object", "boolean"], - "properties": { - "example": true, - "discriminator": { "$ref": "#/$defs/discriminator" }, - "externalDocs": { "$ref": "#/$defs/external-docs" }, - "xml": { "$ref": "#/$defs/xml" } - }, - - "$defs": { - "extensible": { - "patternProperties": { - "^x-": true - } - }, - - "discriminator": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "propertyName": { - "type": "string" - }, - "mapping": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["propertyName"], - "unevaluatedProperties": false - }, - - "external-docs": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri-reference" - }, - "description": { - "type": "string" - } - }, - "required": ["url"], - "unevaluatedProperties": false - }, - - "xml": { - "$ref": "#/$defs/extensible", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "namespace": { - "type": "string", - "format": "uri" - }, - "prefix": { - "type": "string" - }, - "attribute": { - "type": "boolean" - }, - "wrapped": { - "type": "boolean" - } - }, - "unevaluatedProperties": false - } - } -} diff --git a/jsonschema/oas3/schema31.dialect.json b/jsonschema/oas3/schema31.dialect.json new file mode 100644 index 0000000..68ec971 --- /dev/null +++ b/jsonschema/oas3/schema31.dialect.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/dialect/2024-11-10", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenAPI 3.1 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI v3.1 Descriptions", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://spec.openapis.org/oas/3.1/vocab/base": false + }, + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "https://spec.openapis.org/oas/3.1/meta/2024-11-10" + } + ] +} \ No newline at end of file diff --git a/jsonschema/oas3/schema31.json b/jsonschema/oas3/schema31.json deleted file mode 100644 index eae8386..0000000 --- a/jsonschema/oas3/schema31.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$id": "https://spec.openapis.org/oas/3.1/dialect/base", - "$schema": "https://json-schema.org/draft/2020-12/schema", - - "title": "OpenAPI 3.1 Schema Object Dialect", - "description": "A JSON Schema dialect describing schemas found in OpenAPI documents", - - "$vocabulary": { - "https://json-schema.org/draft/2020-12/vocab/core": true, - "https://json-schema.org/draft/2020-12/vocab/applicator": true, - "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, - "https://json-schema.org/draft/2020-12/vocab/validation": true, - "https://json-schema.org/draft/2020-12/vocab/meta-data": true, - "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, - "https://json-schema.org/draft/2020-12/vocab/content": true, - "https://spec.openapis.org/oas/3.1/vocab/base": false - }, - - "$dynamicAnchor": "meta", - - "allOf": [ - { "$ref": "https://json-schema.org/draft/2020-12/schema" }, - { "$ref": "https://spec.openapis.org/oas/3.1/meta/base" } - ] -} diff --git a/jsonschema/oas3/schema31.meta.json b/jsonschema/oas3/schema31.meta.json new file mode 100644 index 0000000..663ef65 --- /dev/null +++ b/jsonschema/oas3/schema31.meta.json @@ -0,0 +1,93 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/meta/2024-11-10", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OAS Base Vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://spec.openapis.org/oas/3.1/vocab/base": true + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "discriminator": { + "$ref": "#/$defs/discriminator" + }, + "example": true, + "externalDocs": { + "$ref": "#/$defs/external-docs" + }, + "xml": { + "$ref": "#/$defs/xml" + } + }, + "$defs": { + "discriminator": { + "$ref": "#/$defs/extensible", + "properties": { + "mapping": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "propertyName": { + "type": "string" + } + }, + "required": [ + "propertyName" + ], + "type": "object", + "unevaluatedProperties": false + }, + "extensible": { + "patternProperties": { + "^x-": true + } + }, + "external-docs": { + "$ref": "#/$defs/extensible", + "properties": { + "description": { + "type": "string" + }, + "url": { + "format": "uri-reference", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object", + "unevaluatedProperties": false + }, + "xml": { + "$ref": "#/$defs/extensible", + "properties": { + "attribute": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "namespace": { + "format": "uri", + "type": "string" + }, + "prefix": { + "type": "string" + }, + "wrapped": { + "type": "boolean" + } + }, + "type": "object", + "unevaluatedProperties": false + } + } +} + diff --git a/jsonschema/oas3/schema32.dialect.json b/jsonschema/oas3/schema32.dialect.json new file mode 100644 index 0000000..f6bfb68 --- /dev/null +++ b/jsonschema/oas3/schema32.dialect.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/dialect/2025-09-17", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OpenAPI 3.2 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI v3.2.x Descriptions", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://spec.openapis.org/oas/3.2/vocab/base": false + }, + "allOf": [ + { + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + { + "$ref": "https://spec.openapis.org/oas/3.2/meta/2025-09-17" + } + ] +} \ No newline at end of file diff --git a/jsonschema/oas3/schema32.meta.json b/jsonschema/oas3/schema32.meta.json new file mode 100644 index 0000000..556b682 --- /dev/null +++ b/jsonschema/oas3/schema32.meta.json @@ -0,0 +1,114 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/meta/2025-09-17", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OAS Base Vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI JSON Schema Dialect", + "$dynamicAnchor": "meta", + "$vocabulary": { + "https://spec.openapis.org/oas/3.2/vocab/base": true + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "discriminator": { + "$ref": "#/$defs/discriminator" + }, + "example": { + "deprecated": true + }, + "externalDocs": { + "$ref": "#/$defs/external-docs" + }, + "xml": { + "$ref": "#/$defs/xml" + } + }, + "$defs": { + "discriminator": { + "$ref": "#/$defs/extensible", + "properties": { + "mapping": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "defaultMapping": { + "type": "string" + }, + "propertyName": { + "type": "string" + } + }, + "type": "object", + "unevaluatedProperties": false + }, + "extensible": { + "patternProperties": { + "^x-": true + } + }, + "external-docs": { + "$ref": "#/$defs/extensible", + "properties": { + "description": { + "type": "string" + }, + "url": { + "format": "uri-reference", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object", + "unevaluatedProperties": false + }, + "xml": { + "$ref": "#/$defs/extensible", + "properties": { + "nodeType": { + "type": "string", + "enum": [ + "element", + "attribute", + "text", + "cdata", + "none" + ] + }, + "name": { + "type": "string" + }, + "namespace": { + "format": "iri", + "type": "string" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "deprecated": true + }, + "wrapped": { + "type": "boolean", + "deprecated": true + } + }, + "type": "object", + "dependentSchemas": { + "nodeType": { + "properties": { + "attribute": false, + "wrapped": false + } + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/jsonschema/oas3/schema_exclusive_validation_test.go b/jsonschema/oas3/schema_exclusive_validation_test.go index 3ad7bef..b2b16c9 100644 --- a/jsonschema/oas3/schema_exclusive_validation_test.go +++ b/jsonschema/oas3/schema_exclusive_validation_test.go @@ -232,7 +232,7 @@ exclusiveMaximum: false { name: "boolean exclusiveMinimum with 3.1 $schema should fail", yaml: ` -$schema: "https://spec.openapis.org/oas/3.1/dialect/base" +$schema: "https://spec.openapis.org/oas/3.1/dialect/2024-11-10" type: number minimum: 0 maximum: 100 diff --git a/jsonschema/oas3/validation.go b/jsonschema/oas3/validation.go index 475563c..b80c350 100644 --- a/jsonschema/oas3/validation.go +++ b/jsonschema/oas3/validation.go @@ -22,21 +22,40 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed schema31.json -var schema31JSON string - -//go:embed schema31.base.json -var schema31BaseJSON string - -//go:embed schema30.json -var schema30JSON string - -var oasSchemaValidator = make(map[string]*jsValidator.Schema) -var defaultPrinter = message.NewPrinter(language.English) +// custom file to cover for missing openapi 3.0 json schema +// +//go:embed schema30.dialect.json +var schema30DialectJSON string + +// sourced from https://spec.openapis.org/oas/3.1/dialect/2024-11-10.html +// +//go:embed schema31.dialect.json +var schema31DialectJSON string + +// source from https://spec.openapis.org/oas/3.1/meta/2024-11-10.html +// +//go:embed schema31.meta.json +var schema31MetaJSON string + +// sourced from https://spec.openapis.org/oas/3.2/dialect/2025-09-17.html +// +//go:embed schema32.dialect.json +var schema32DialectJSON string + +// source from https://spec.openapis.org/oas/3.2/meta/2025-09-17.html +// +//go:embed schema32.meta.json +var schema32MetaJSON string + +var ( + oasSchemaValidator = make(map[string]*jsValidator.Schema) + defaultPrinter = message.NewPrinter(language.English) +) const ( - JSONSchema31SchemaID = "https://spec.openapis.org/oas/3.1/dialect/base" JSONSchema30SchemaID = "https://spec.openapis.org/oas/3.0/dialect/2024-10-18" + JSONSchema31SchemaID = "https://spec.openapis.org/oas/3.1/meta/2024-11-10" + JSONSchema32SchemaID = "https://spec.openapis.org/oas/3.2/meta/2025-09-17" ) type ParentDocumentVersion struct { @@ -76,6 +95,8 @@ func (js *Schema) Validate(ctx context.Context, opts ...validation.Option) []err switch { case dv.OpenAPI != nil: switch { + case strings.HasPrefix(*dv.OpenAPI, "3.2"): + schema = JSONSchema32SchemaID case strings.HasPrefix(*dv.OpenAPI, "3.1"): schema = JSONSchema31SchemaID case strings.HasPrefix(*dv.OpenAPI, "3.0"): @@ -196,8 +217,10 @@ func getRootCauses(err *jsValidator.ValidationError, js core.Schema) []error { return errs } -var validationInitialized = make(map[string]bool) -var initMutex sync.Mutex +var ( + validationInitialized = make(map[string]bool) + initMutex sync.Mutex +) func initValidation(schema string) *jsValidator.Schema { initMutex.Lock() @@ -211,22 +234,35 @@ func initValidation(schema string) *jsValidator.Schema { c := jsValidator.NewCompiler() switch schema { + case JSONSchema32SchemaID: + oasSchemaMeta, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32MetaJSON)) + if err != nil { + panic(err) + } + if err := c.AddResource(JSONSchema32SchemaID, oasSchemaMeta); err != nil { + panic(err) + } + + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema32DialectJSON)) + if err != nil { + panic(err) + } case JSONSchema31SchemaID: - oasSchemaBase, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31BaseJSON)) + oasSchemaMeta, err := jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31MetaJSON)) if err != nil { panic(err) } - if err := c.AddResource("https://spec.openapis.org/oas/3.1/meta/base", oasSchemaBase); err != nil { + if err := c.AddResource(JSONSchema31SchemaID, oasSchemaMeta); err != nil { panic(err) } - schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31JSON)) + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema31DialectJSON)) if err != nil { panic(err) } case JSONSchema30SchemaID: var err error - schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema30JSON)) + schemaResource, err = jsValidator.UnmarshalJSON(bytes.NewBufferString(schema30DialectJSON)) if err != nil { panic(err) } diff --git a/marshaller/unmarshaller.go b/marshaller/unmarshaller.go index 2b6c958..1909012 100644 --- a/marshaller/unmarshaller.go +++ b/marshaller/unmarshaller.go @@ -675,7 +675,17 @@ func validateNodeKind(resolvedNode *yaml.Node, expectedKind yaml.Kind, parentNam actualKindStr := yml.NodeKindToString(resolvedNode.Kind) if actualKindStr == "scalar" { - actualKindStr = fmt.Sprintf("`%s`", resolvedNode.Value) + // Truncate value to prevent file content disclosure + // External references that fail to parse may contain sensitive file content + value := resolvedNode.Value + maxLen := 7 + if len(value) < maxLen { + maxLen = len(value) / 2 + } + if len(value) > maxLen { + value = value[:maxLen] + "..." + } + actualKindStr = fmt.Sprintf("`%s`", value) } return validation.NewValidationError(validation.NewTypeMismatchError(parentName, "expected %s, got %s", expectedType, actualKindStr), resolvedNode) diff --git a/mise-tasks/examples-check b/mise-tasks/examples-check index 1860dc4..e68d0ad 100755 --- a/mise-tasks/examples-check +++ b/mise-tasks/examples-check @@ -19,7 +19,7 @@ if ! git diff --quiet openapi/README.md arazzo/README.md; then git diff openapi/README.md arazzo/README.md echo "" echo "💡 To fix this, run: mise run update-examples" - echo " Then commit the updated README files." + echo " Then stage/commit the updated README files." exit 1 else echo "✅ All examples in README files are up to date!" diff --git a/mise-tasks/lint b/mise-tasks/lint index 1184134..105c589 100755 --- a/mise-tasks/lint +++ b/mise-tasks/lint @@ -3,7 +3,8 @@ set -euo pipefail echo "🔍 Running linting checks..." -echo "🧹 Running golangci-lint (includes go vet)..." +GOLANGCI_VERSION=$(golangci-lint --version | grep -oP 'golangci-lint has version \K[0-9.]+' || echo "unknown") +echo "🧹 Running golangci-lint v${GOLANGCI_VERSION} (includes go vet)..." golangci-lint run echo "🛡️ Running nilaway..." diff --git a/openapi/README.md b/openapi/README.md index 91175e1..fca42f2 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -3,7 +3,7 @@ OpenAPI

OpenAPI Parser

-

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

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

@@ -21,10 +21,10 @@ ## Features -- **Full OpenAPI 3.0.x and 3.1.x Support**: Parse and work with both OpenAPI 3.0.x and 3.1.x documents +- **Full OpenAPI 3.0.x, 3.1.x, and 3.2.x Support**: Parse and work with OpenAPI 3.0.x, 3.1.x, and 3.2.x documents - **Validation**: Built-in validation against the OpenAPI Specification - **Walking**: Traverse all elements in an OpenAPI document with a powerful iterator pattern -- **Upgrading**: Automatically upgrade OpenAPI 3.0.x documents to 3.1.1 +- **Upgrading**: Automatically upgrade OpenAPI 3.0.x and 3.1.x documents to 3.2.0 - **Mutation**: Modify OpenAPI documents programmatically - **JSON Schema Support**: Direct access to JSON Schema functionality - **Reference Resolution**: Resolve $ref references within documents @@ -35,9 +35,10 @@ ## Supported OpenAPI Versions - OpenAPI 3.0.0 through 3.0.4 -- OpenAPI 3.1.0 through 3.1.1 (latest) +- OpenAPI 3.1.0 through 3.1.2 +- OpenAPI 3.2.0 -The package can automatically upgrade documents from 3.0.x to 3.1.1, handling the differences in specification between versions. +The package can automatically upgrade documents from 3.0.x and 3.1.x to 3.2.0, handling the differences in specification between versions. @@ -323,7 +324,7 @@ for item := range openapi.Walk(ctx, doc) { } operationCount++ - if operationCount >= 2 { + if operationCount >= 3 { return walk.ErrTerminate } return nil @@ -540,6 +541,7 @@ doc := &openapi.OpenAPI{ { URL: "https://api.example.com/v1", Description: pointer.From("Production server"), + Name: pointer.From("prod"), }, }, Paths: paths, @@ -725,7 +727,7 @@ if err := marshaller.Marshal(ctx, inlinedSchema, buf); err != nil { fmt.Printf("%s", buf.String()) ``` -## Upgrade an OpenAPI document from 3.0.x to 3.1.1 +## Upgrade an OpenAPI document from 3.0.x to 3.2.0 Shows the automatic conversion of nullable fields, examples, and other version differences. @@ -736,7 +738,7 @@ openAPIYAML := `openapi: 3.0.3 info: title: Legacy API version: 1.0.0 - description: An API that needs upgrading from 3.0.3 to 3.1.1 + description: An API that needs upgrading from 3.0.3 to 3.2.0 paths: /users: get: diff --git a/openapi/bootstrap.go b/openapi/bootstrap.go index bea0193..1b4019c 100644 --- a/openapi/bootstrap.go +++ b/openapi/bootstrap.go @@ -65,7 +65,9 @@ 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", diff --git a/openapi/bundle.go b/openapi/bundle.go index 7ad818c..959f036 100644 --- a/openapi/bundle.go +++ b/openapi/bundle.go @@ -450,8 +450,13 @@ func walkAndUpdateRefsInResponse(_ context.Context, response *ReferencedResponse // Walk through the response's content schemas if response.Object.Content != nil { for _, mediaType := range response.Object.Content.All() { - if mediaType != nil && mediaType.Schema != nil && mediaType.Schema.IsReference() { - updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + if mediaType != nil { + if mediaType.Schema != nil && mediaType.Schema.IsReference() { + updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + } + if mediaType.ItemSchema != nil && mediaType.ItemSchema.IsReference() { + updateSchemaRefWithSource(mediaType.ItemSchema, componentStorage, sourceLocation) + } } } } @@ -491,8 +496,13 @@ func walkAndUpdateRefsInRequestBody(_ context.Context, body *ReferencedRequestBo // Walk through the request body's content schemas if body.Object.Content != nil { for _, mediaType := range body.Object.Content.All() { - if mediaType != nil && mediaType.Schema != nil && mediaType.Schema.IsReference() { - updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + if mediaType != nil { + if mediaType.Schema != nil && mediaType.Schema.IsReference() { + updateSchemaRefWithSource(mediaType.Schema, componentStorage, sourceLocation) + } + if mediaType.ItemSchema != nil && mediaType.ItemSchema.IsReference() { + updateSchemaRefWithSource(mediaType.ItemSchema, componentStorage, sourceLocation) + } } } } diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index e9cf55e..ed428e0 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 87f7510..bb5e816 100644 --- a/openapi/callbacks_validate_test.go +++ b/openapi/callbacks_validate_test.go @@ -93,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") }) } @@ -268,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...) validation.SortValidationErrors(allErrors) @@ -404,7 +404,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") }) } @@ -501,7 +501,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/components_validate_test.go b/openapi/components_validate_test.go index 8318661..01bffe8 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/example.go b/openapi/core/example.go index 65e7aca..aac485e 100644 --- a/openapi/core/example.go +++ b/openapi/core/example.go @@ -9,9 +9,11 @@ import ( type Example struct { marshaller.CoreModel `model:"example"` - Summary marshaller.Node[*string] `key:"summary"` - Description marshaller.Node[*string] `key:"description"` - Value marshaller.Node[values.Value] `key:"value"` - ExternalValue marshaller.Node[*string] `key:"externalValue"` - Extensions core.Extensions `key:"extensions"` + Summary marshaller.Node[*string] `key:"summary"` + Description marshaller.Node[*string] `key:"description"` + Value marshaller.Node[values.Value] `key:"value"` + ExternalValue marshaller.Node[*string] `key:"externalValue"` + DataValue marshaller.Node[values.Value] `key:"dataValue"` + SerializedValue marshaller.Node[*string] `key:"serializedValue"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/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/mediatype.go b/openapi/core/mediatype.go index dcec036..d6c3a0d 100644 --- a/openapi/core/mediatype.go +++ b/openapi/core/mediatype.go @@ -11,9 +11,12 @@ import ( type MediaType struct { marshaller.CoreModel `model:"mediaType"` - Schema marshaller.Node[oascore.JSONSchema] `key:"schema"` - Encoding marshaller.Node[*sequencedmap.Map[string, *Encoding]] `key:"encoding"` - Example marshaller.Node[values.Value] `key:"example"` - Examples marshaller.Node[*sequencedmap.Map[string, *Reference[*Example]]] `key:"examples"` - Extensions core.Extensions `key:"extensions"` + Schema marshaller.Node[oascore.JSONSchema] `key:"schema"` + ItemSchema marshaller.Node[oascore.JSONSchema] `key:"itemSchema"` + Encoding marshaller.Node[*sequencedmap.Map[string, *Encoding]] `key:"encoding"` + PrefixEncoding marshaller.Node[[]*Encoding] `key:"prefixEncoding"` + ItemEncoding marshaller.Node[*Encoding] `key:"itemEncoding"` + Example marshaller.Node[values.Value] `key:"example"` + Examples marshaller.Node[*sequencedmap.Map[string, *Reference[*Example]]] `key:"examples"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/core/openapi.go b/openapi/core/openapi.go index c3362a9..03ea224 100644 --- a/openapi/core/openapi.go +++ b/openapi/core/openapi.go @@ -11,6 +11,7 @@ type OpenAPI struct { marshaller.CoreModel `model:"openapi"` OpenAPI marshaller.Node[string] `key:"openapi"` + Self marshaller.Node[*string] `key:"$self"` Info marshaller.Node[Info] `key:"info"` ExternalDocs marshaller.Node[*oas3core.ExternalDocumentation] `key:"externalDocs"` Tags marshaller.Node[[]*Tag] `key:"tags"` diff --git a/openapi/core/paths.go b/openapi/core/paths.go index 405f811..2a31581 100644 --- a/openapi/core/paths.go +++ b/openapi/core/paths.go @@ -56,6 +56,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"` } diff --git a/openapi/core/security.go b/openapi/core/security.go index 44bb75e..ec20490 100644 --- a/openapi/core/security.go +++ b/openapi/core/security.go @@ -10,15 +10,17 @@ import ( type SecurityScheme struct { marshaller.CoreModel `model:"securityScheme"` - Type marshaller.Node[string] `key:"type"` - Description marshaller.Node[*string] `key:"description"` - Name marshaller.Node[*string] `key:"name"` - In marshaller.Node[*string] `key:"in"` - Scheme marshaller.Node[*string] `key:"scheme"` - BearerFormat marshaller.Node[*string] `key:"bearerFormat"` - Flows marshaller.Node[*OAuthFlows] `key:"flows"` - OpenIdConnectUrl marshaller.Node[*string] `key:"openIdConnectUrl"` - Extensions core.Extensions `key:"extensions"` + Type marshaller.Node[string] `key:"type"` + Description marshaller.Node[*string] `key:"description"` + Name marshaller.Node[*string] `key:"name"` + In marshaller.Node[*string] `key:"in"` + Scheme marshaller.Node[*string] `key:"scheme"` + BearerFormat marshaller.Node[*string] `key:"bearerFormat"` + Flows marshaller.Node[*OAuthFlows] `key:"flows"` + OpenIdConnectUrl marshaller.Node[*string] `key:"openIdConnectUrl"` + OAuth2MetadataUrl marshaller.Node[*string] `key:"oauth2MetadataUrl"` + Deprecated marshaller.Node[*bool] `key:"deprecated"` + Extensions core.Extensions `key:"extensions"` } type SecurityRequirement struct { @@ -61,19 +63,21 @@ func (s *SecurityRequirement) GetMapKeyNodeOrRootLine(key string, rootNode *yaml type OAuthFlows struct { marshaller.CoreModel `model:"oAuthFlows"` - Implicit marshaller.Node[*OAuthFlow] `key:"implicit"` - Password marshaller.Node[*OAuthFlow] `key:"password"` - ClientCredentials marshaller.Node[*OAuthFlow] `key:"clientCredentials"` - AuthorizationCode marshaller.Node[*OAuthFlow] `key:"authorizationCode"` - Extensions core.Extensions `key:"extensions"` + Implicit marshaller.Node[*OAuthFlow] `key:"implicit"` + Password marshaller.Node[*OAuthFlow] `key:"password"` + ClientCredentials marshaller.Node[*OAuthFlow] `key:"clientCredentials"` + AuthorizationCode marshaller.Node[*OAuthFlow] `key:"authorizationCode"` + DeviceAuthorization marshaller.Node[*OAuthFlow] `key:"deviceAuthorization"` + Extensions core.Extensions `key:"extensions"` } type OAuthFlow struct { marshaller.CoreModel `model:"oAuthFlow"` - AuthorizationURL marshaller.Node[*string] `key:"authorizationUrl"` - TokenURL marshaller.Node[*string] `key:"tokenUrl"` - RefreshURL marshaller.Node[*string] `key:"refreshUrl"` - Scopes marshaller.Node[*sequencedmap.Map[string, string]] `key:"scopes"` - Extensions core.Extensions `key:"extensions"` + AuthorizationURL marshaller.Node[*string] `key:"authorizationUrl"` + DeviceAuthorizationURL marshaller.Node[*string] `key:"deviceAuthorizationUrl"` + TokenURL marshaller.Node[*string] `key:"tokenUrl"` + RefreshURL marshaller.Node[*string] `key:"refreshUrl"` + Scopes marshaller.Node[*sequencedmap.Map[string, string]] `key:"scopes"` + Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/core/server.go b/openapi/core/server.go index ab1e91b..fc059a0 100644 --- a/openapi/core/server.go +++ b/openapi/core/server.go @@ -11,6 +11,7 @@ type Server struct { URL marshaller.Node[string] `key:"url"` Description marshaller.Node[*string] `key:"description"` + Name marshaller.Node[*string] `key:"name"` Variables marshaller.Node[*sequencedmap.Map[string, *ServerVariable]] `key:"variables"` Extensions core.Extensions `key:"extensions"` } diff --git a/openapi/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/examples.go b/openapi/examples.go index e336181..f2bc01b 100644 --- a/openapi/examples.go +++ b/openapi/examples.go @@ -20,10 +20,15 @@ type Example struct { Summary *string // Description is a description of the example. Description *string - // Value is the example value. Mutually exclusive with ExternalValue. + // Value is the example value. Mutually exclusive with ExternalValue, DataValue, and SerializedValue. + // Deprecated for non-JSON serialization targets: Use DataValue and/or SerializedValue instead. Value values.Value - // ExternalValue is a URI to the location of the example value. May be relative to the location of the document. Mutually exclusive with Value. + // ExternalValue is a URI to the location of the example value. May be relative to the location of the document. Mutually exclusive with Value and SerializedValue. ExternalValue *string + // DataValue is an example of the data structure that MUST be valid according to the relevant Schema Object. If this field is present, Value MUST be absent. + DataValue values.Value + // SerializedValue is an example of the serialized form of the value, including encoding and escaping. If this field is present, Value and ExternalValue MUST be absent. + SerializedValue *string // Extensions provides a list of extensions to the Example object. Extensions *extensions.Extensions } @@ -62,6 +67,22 @@ func (e *Example) GetExternalValue() string { return *e.ExternalValue } +// GetDataValue returns the value of the DataValue field. Returns nil if not set. +func (e *Example) GetDataValue() values.Value { + if e == nil { + return nil + } + return e.DataValue +} + +// GetSerializedValue returns the value of the SerializedValue field. Returns empty string if not set. +func (e *Example) GetSerializedValue() string { + if e == nil || e.SerializedValue == nil { + return "" + } + return *e.SerializedValue +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (e *Example) GetExtensions() *extensions.Extensions { if e == nil || e.Extensions == nil { @@ -81,10 +102,26 @@ func (e *Example) Validate(ctx context.Context, opts ...validation.Option) []err core := e.GetCore() errs := []error{} + // Check mutual exclusivity: value and externalValue if core.Value.Present && core.ExternalValue.Present { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.value and externalValue are mutually exclusive"), core, core.Value)) } + // Check mutual exclusivity: dataValue and value + if core.DataValue.Present && core.Value.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.dataValue and value are mutually exclusive"), core, core.DataValue)) + } + + // Check mutual exclusivity: serializedValue and value + if core.SerializedValue.Present && core.Value.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.serializedValue and value are mutually exclusive"), core, core.SerializedValue)) + } + + // Check mutual exclusivity: serializedValue and externalValue + if core.SerializedValue.Present && core.ExternalValue.Present { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("example.serializedValue and externalValue are mutually exclusive"), core, core.SerializedValue)) + } + if core.ExternalValue.Present { if _, err := url.Parse(*e.ExternalValue); err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError(fmt.Sprintf("example.externalValue is not a valid uri: %s", err)), core, core.ExternalValue)) diff --git a/openapi/examples_unmarshal_test.go b/openapi/examples_unmarshal_test.go index 180192c..89793f4 100644 --- a/openapi/examples_unmarshal_test.go +++ b/openapi/examples_unmarshal_test.go @@ -12,7 +12,14 @@ import ( func TestExample_Unmarshal_Success(t *testing.T) { t.Parallel() - yml := ` + tests := []struct { + name string + yml string + test func(t *testing.T, example *openapi.Example) + }{ + { + name: "all legacy fields", + yml: ` summary: Example of a pet description: A pet object example value: @@ -21,22 +28,95 @@ value: status: available externalValue: https://example.com/examples/pet.json x-test: some-value -` +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Example of a pet", example.GetSummary()) + require.Equal(t, "A pet object example", example.GetDescription()) + require.Equal(t, "https://example.com/examples/pet.json", example.GetExternalValue()) - var example openapi.Example + value := example.GetValue() + require.NotNil(t, value) - validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &example) - require.NoError(t, err) - require.Empty(t, validationErrs) + ext, ok := example.GetExtensions().Get("x-test") + require.True(t, ok) + require.Equal(t, "some-value", ext.Value) + }, + }, + { + name: "dataValue field", + yml: ` +summary: Data value example +description: Example using dataValue +dataValue: + author: A. Writer + title: The Newest Book +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Data value example", example.GetSummary()) + require.Equal(t, "Example using dataValue", example.GetDescription()) - require.Equal(t, "Example of a pet", example.GetSummary()) - require.Equal(t, "A pet object example", example.GetDescription()) - require.Equal(t, "https://example.com/examples/pet.json", example.GetExternalValue()) + dataValue := example.GetDataValue() + require.NotNil(t, dataValue) + }, + }, + { + name: "serializedValue field", + yml: ` +summary: Serialized value example +serializedValue: "flag=true" +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Serialized value example", example.GetSummary()) + require.Equal(t, "flag=true", example.GetSerializedValue()) + }, + }, + { + name: "dataValue and serializedValue together", + yml: ` +summary: Combined example +dataValue: + author: A. Writer + title: An Older Book + rating: 4.5 +serializedValue: '{"author":"A. Writer","title":"An Older Book","rating":4.5}' +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, "Combined example", example.GetSummary()) - value := example.GetValue() - require.NotNil(t, value) + dataValue := example.GetDataValue() + require.NotNil(t, dataValue) - ext, ok := example.GetExtensions().Get("x-test") - require.True(t, ok) - require.Equal(t, "some-value", ext.Value) + serializedValue := example.GetSerializedValue() + require.Equal(t, `{"author":"A. Writer","title":"An Older Book","rating":4.5}`, serializedValue) + }, + }, + { + name: "serializedValue with JSON content", + yml: ` +serializedValue: '{"name":"Fluffy","petType":"Cat","color":"White"}' +`, + test: func(t *testing.T, example *openapi.Example) { + t.Helper() + require.Equal(t, `{"name":"Fluffy","petType":"Cat","color":"White"}`, example.GetSerializedValue()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var example openapi.Example + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &example) + require.NoError(t, err) + require.Empty(t, validationErrs) + + tt.test(t, &example) + }) + } } diff --git a/openapi/examples_validate_test.go b/openapi/examples_validate_test.go index f1ec2ae..dd1ac9a 100644 --- a/openapi/examples_validate_test.go +++ b/openapi/examples_validate_test.go @@ -87,6 +87,42 @@ value: 42 yml: ` summary: Boolean example value: true +`, + }, + { + name: "valid example with dataValue", + yml: ` +summary: Data value example +dataValue: + author: A. Writer + title: The Newest Book +`, + }, + { + name: "valid example with serializedValue", + yml: ` +summary: Serialized value example +serializedValue: "flag=true" +`, + }, + { + name: "valid example with dataValue and serializedValue", + yml: ` +summary: Combined example +dataValue: + author: A. Writer + title: An Older Book + rating: 4.5 +serializedValue: '{"author":"A. Writer","title":"An Older Book","rating":4.5}' +`, + }, + { + name: "valid example with dataValue and externalValue", + yml: ` +dataValue: + id: 123 + name: test +externalValue: https://example.com/examples/user.json `, }, } @@ -150,6 +186,51 @@ externalValue: ":invalid" "[3:16] example.externalValue is not a valid uri: parse \":invalid\": missing protocol scheme", }, }, + { + name: "dataValue and value are mutually exclusive", + yml: ` +summary: Invalid example +dataValue: + id: 123 +value: "test" +`, + wantErrs: []string{"example.dataValue and value are mutually exclusive"}, + }, + { + name: "serializedValue and value are mutually exclusive", + yml: ` +summary: Invalid example +serializedValue: "test=123" +value: "test" +`, + wantErrs: []string{"example.serializedValue and value are mutually exclusive"}, + }, + { + name: "serializedValue and externalValue are mutually exclusive", + yml: ` +summary: Invalid example +serializedValue: "test=123" +externalValue: https://example.com/test.json +`, + wantErrs: []string{"example.serializedValue and externalValue are mutually exclusive"}, + }, + { + name: "multiple mutual exclusivity violations", + yml: ` +summary: Invalid example +dataValue: + id: 123 +value: "test" +serializedValue: "test=123" +externalValue: https://example.com/test.json +`, + wantErrs: []string{ + "example.value and externalValue are mutually exclusive", + "example.dataValue and value are mutually exclusive", + "example.serializedValue and value are mutually exclusive", + "example.serializedValue and externalValue are mutually exclusive", + }, + }, } for _, tt := range tests { 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/header.go b/openapi/header.go index e797459..36591af 100644 --- a/openapi/header.go +++ b/openapi/header.go @@ -139,8 +139,11 @@ func (h *Header) Validate(ctx context.Context, opts ...validation.Option) []erro errs = append(errs, h.Schema.Validate(ctx, opts...)...) } - for _, obj := range h.Content.All() { - errs = append(errs, obj.Validate(ctx, opts...)...) + for mediaType, obj := range h.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, obj.Validate(ctx, contentOpts...)...) } for _, obj := range h.Examples.All() { diff --git a/openapi/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/mediatype.go b/openapi/mediatype.go index ecf1668..6cc46c9 100644 --- a/openapi/mediatype.go +++ b/openapi/mediatype.go @@ -2,6 +2,7 @@ package openapi import ( "context" + "strings" "github.com/speakeasy-api/openapi/extensions" "github.com/speakeasy-api/openapi/jsonschema/oas3" @@ -12,14 +13,27 @@ import ( "github.com/speakeasy-api/openapi/values" ) +// MediaTypeContext holds the media type string for validation purposes +type MediaTypeContext struct { + MediaType string +} + // MediaType provides a schema and examples for the associated media type. type MediaType struct { marshaller.Model[core.MediaType] - // Schema is the schema defining the type used for the parameter. + // Schema is the schema defining the type used for the media type. Schema *oas3.JSONSchema[oas3.Referenceable] + // ItemSchema is a schema describing each item within a sequential media type like text/event-stream. + ItemSchema *oas3.JSONSchema[oas3.Referenceable] // Encoding is a map allowing for more complex encoding scenarios. Encoding *sequencedmap.Map[string, *Encoding] + // PrefixEncoding provides positional encoding information for multipart content (OpenAPI 3.2+). + // This field SHALL only apply when the media type is multipart. This field MUST NOT be present if encoding is present. + PrefixEncoding []*Encoding + // ItemEncoding provides encoding information for array items in multipart content (OpenAPI 3.2+). + // This field SHALL only apply when the media type is multipart. This field MUST NOT be present if encoding is present. + ItemEncoding *Encoding // Example is an example of the media type's value. Example values.Value // Examples is a map of examples of the media type's value. @@ -37,6 +51,14 @@ func (m *MediaType) GetSchema() *oas3.JSONSchema[oas3.Referenceable] { return m.Schema } +// GetItemSchema returns the value of the ItemSchema field. Returns nil if not set. +func (m *MediaType) GetItemSchema() *oas3.JSONSchema[oas3.Referenceable] { + if m == nil { + return nil + } + return m.ItemSchema +} + // GetEncoding returns the value of the Encoding field. Returns nil if not set. func (m *MediaType) GetEncoding() *sequencedmap.Map[string, *Encoding] { if m == nil { @@ -45,6 +67,22 @@ func (m *MediaType) GetEncoding() *sequencedmap.Map[string, *Encoding] { return m.Encoding } +// GetPrefixEncoding returns the value of the PrefixEncoding field. Returns nil if not set. +func (m *MediaType) GetPrefixEncoding() []*Encoding { + if m == nil { + return nil + } + return m.PrefixEncoding +} + +// GetItemEncoding returns the value of the ItemEncoding field. Returns nil if not set. +func (m *MediaType) GetItemEncoding() *Encoding { + if m == nil { + return nil + } + return m.ItemEncoding +} + // GetExamples returns the value of the Examples field. Returns nil if not set. func (m *MediaType) GetExamples() *sequencedmap.Map[string, *ReferencedExample] { if m == nil { @@ -70,6 +108,10 @@ func (m *MediaType) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, m.Schema.Validate(ctx, opts...)...) } + if core.ItemSchema.Present { + errs = append(errs, m.ItemSchema.Validate(ctx, opts...)...) + } + for _, obj := range m.Examples.All() { errs = append(errs, obj.Validate(ctx, opts...)...) } @@ -78,6 +120,61 @@ func (m *MediaType) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, obj.Validate(ctx, opts...)...) } + // Validate prefixEncoding field if present + if core.PrefixEncoding.Present { + for _, enc := range m.PrefixEncoding { + if enc != nil { + errs = append(errs, enc.Validate(ctx, opts...)...) + } + } + } + + // Validate itemEncoding field if present + if core.ItemEncoding.Present { + errs = append(errs, m.ItemEncoding.Validate(ctx, opts...)...) + } + + // Validate mutual exclusivity: encoding MUST NOT be present with prefixEncoding or itemEncoding + if core.Encoding.Present && (core.PrefixEncoding.Present || core.ItemEncoding.Present) { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("encoding field MUST NOT be present when prefixEncoding or itemEncoding is present"), + core, + core.Encoding, + )) + } + + // Validate multipart-only constraint for encoding, prefixEncoding, and itemEncoding + o := validation.NewOptions(opts...) + mtCtx := validation.GetContextObject[MediaTypeContext](o) + if mtCtx != nil && mtCtx.MediaType != "" { + isMultipart := strings.HasPrefix(strings.ToLower(mtCtx.MediaType), "multipart/") + isFormURLEncoded := strings.ToLower(mtCtx.MediaType) == "application/x-www-form-urlencoded" + + if core.PrefixEncoding.Present && !isMultipart { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("prefixEncoding field SHALL only apply when the media type is multipart"), + core, + core.PrefixEncoding, + )) + } + + if core.ItemEncoding.Present && !isMultipart { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("itemEncoding field SHALL only apply when the media type is multipart"), + core, + core.ItemEncoding, + )) + } + + if core.Encoding.Present && !isMultipart && !isFormURLEncoded { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("encoding field SHALL only apply when the media type is multipart or application/x-www-form-urlencoded"), + core, + core.Encoding, + )) + } + } + m.Valid = len(errs) == 0 && core.GetValid() return errs diff --git a/openapi/mediatype_multipart_validate_test.go b/openapi/mediatype_multipart_validate_test.go new file mode 100644 index 0000000..03a66e3 --- /dev/null +++ b/openapi/mediatype_multipart_validate_test.go @@ -0,0 +1,187 @@ +package openapi_test + +import ( + "bytes" + "testing" + + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMediaType_MultipartValidation_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + }{ + { + name: "prefixEncoding with multipart/mixed", + yml: ` +description: Test response +content: + multipart/mixed: + schema: + type: array + prefixItems: + - type: object + - type: string + prefixEncoding: + - contentType: application/json + - contentType: text/plain +`, + }, + { + name: "itemEncoding with multipart/form-data", + yml: ` +description: Test response +content: + multipart/form-data: + itemSchema: + type: object + itemEncoding: + contentType: application/json +`, + }, + { + name: "encoding with multipart/form-data", + yml: ` +description: Test response +content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + encoding: + file: + contentType: image/png +`, + }, + { + name: "encoding with application/x-www-form-urlencoded", + yml: ` +description: Test response +content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + type: string + encoding: + name: + contentType: text/plain +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var response openapi.Response + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &response) + require.NoError(t, err, "unmarshal should succeed") + require.Empty(t, validationErrs, "unmarshal validation should succeed") + + errs := response.Validate(t.Context()) + require.Empty(t, errs, "validation should succeed") + require.True(t, response.Valid, "response should be valid") + }) + } +} + +func TestMediaType_MultipartValidation_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + wantErrs []string + }{ + { + name: "prefixEncoding with non-multipart media type", + yml: ` +description: Test response +content: + application/json: + schema: + type: array + prefixEncoding: + - contentType: application/json +`, + wantErrs: []string{ + "prefixEncoding field SHALL only apply when the media type is multipart", + }, + }, + { + name: "itemEncoding with non-multipart media type", + yml: ` +description: Test response +content: + application/json: + itemSchema: + type: object + itemEncoding: + contentType: application/json +`, + wantErrs: []string{ + "itemEncoding field SHALL only apply when the media type is multipart", + }, + }, + { + name: "encoding with non-multipart non-form-urlencoded media type", + yml: ` +description: Test response +content: + application/json: + schema: + type: object + properties: + file: + type: string + encoding: + file: + contentType: image/png +`, + wantErrs: []string{ + "encoding field SHALL only apply when the media type is multipart or application/x-www-form-urlencoded", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var response openapi.Response + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &response) + require.NoError(t, err, "unmarshal should succeed") + require.Empty(t, validationErrs, "unmarshal validation should succeed") + + errs := response.Validate(t.Context()) + require.NotEmpty(t, errs, "validation should fail") + require.False(t, response.Valid, "response should be invalid") + + var errMessages []string + for _, err := range errs { + errMessages = append(errMessages, err.Error()) + } + + for _, wantErr := range tt.wantErrs { + found := false + for _, msg := range errMessages { + if assert.Contains(t, msg, wantErr) { + found = true + break + } + } + assert.True(t, found, "expected error message not found: %s", wantErr) + } + }) + } +} diff --git a/openapi/mediatype_validate_test.go b/openapi/mediatype_validate_test.go index 27b8c02..eace23f 100644 --- a/openapi/mediatype_validate_test.go +++ b/openapi/mediatype_validate_test.go @@ -99,6 +99,82 @@ schema: type: string x-test: some-value x-custom: custom-data +`, + }, + { + name: "valid media type with itemSchema only", + yml: ` +itemSchema: + type: object + properties: + id: + type: integer + name: + type: string +`, + }, + { + name: "valid media type with itemSchema and example", + yml: ` +itemSchema: + type: string +example: "hello world" +`, + }, + { + name: "valid media type with itemSchema and examples", + yml: ` +itemSchema: + $ref: "#/components/schemas/User" +examples: + user1: + value: + id: 1 + name: John + user2: + value: + id: 2 + name: Jane +`, + }, + { + name: "valid media type with prefixEncoding", + yml: ` +schema: + type: array + prefixItems: + - type: object + - type: string +prefixEncoding: + - contentType: application/json + - contentType: text/plain +`, + }, + { + name: "valid media type with itemEncoding", + yml: ` +itemSchema: + type: object + properties: + id: + type: integer +itemEncoding: + contentType: application/json +`, + }, + { + name: "valid media type with both prefixEncoding and itemEncoding", + yml: ` +schema: + type: array + prefixItems: + - type: object + items: + type: string +prefixEncoding: + - contentType: application/json +itemEncoding: + contentType: text/plain `, }, } @@ -148,6 +224,42 @@ encoding: "[13:17] schema.type expected array, got string", }, }, + { + name: "encoding and prefixEncoding cannot coexist", + yml: ` +schema: + type: object + properties: + file: + type: string +encoding: + file: + contentType: image/png +prefixEncoding: + - contentType: application/json +`, + wantErrs: []string{ + "[8:3] encoding field MUST NOT be present when prefixEncoding or itemEncoding is present", + }, + }, + { + name: "encoding and itemEncoding cannot coexist", + yml: ` +schema: + type: object + properties: + file: + type: string +encoding: + file: + contentType: image/png +itemEncoding: + contentType: application/json +`, + wantErrs: []string{ + "[8:3] encoding field MUST NOT be present when prefixEncoding or itemEncoding is present", + }, + }, } for _, tt := range tests { diff --git a/openapi/openapi.go b/openapi/openapi.go index 4dd39c7..435df31 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,13 +17,12 @@ 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 + Version = "3.2.0" +) - Version30XMaxPatch = 4 - Version31XMaxPatch = 1 +var ( + MinimumSupportedVersion = version.MustParse("3.0.0") + MaximumSupportedVersion = version.MustParse(Version) ) // OpenAPI represents an OpenAPI document compatible with the OpenAPI Specification 3.0.X and 3.1.X. @@ -34,6 +32,9 @@ type OpenAPI struct { // OpenAPI is the version of the OpenAPI Specification that this document conforms to. OpenAPI string + // Self provides the self-assigned URI of this document, which also serves as its base URI for resolving relative references. + // It MUST be in the form of a URI reference as defined by RFC3986. + Self *string // Info provides various information about the API and document. Info Info // ExternalDocs provides additional external documentation for this API. @@ -61,6 +62,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 { @@ -69,6 +77,14 @@ func (o *OpenAPI) GetOpenAPI() string { return o.OpenAPI } +// GetSelf returns the value of the Self field. Returns empty string if not set. +func (o *OpenAPI) GetSelf() string { + if o == nil || o.Self == nil { + return "" + } + return *o.Self +} + // GetInfo returns the value of the Info field. Returns nil if not set. func (o *OpenAPI) GetInfo() *Info { if o == nil { @@ -161,23 +177,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.Parse(o.OpenAPI) if err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.openapi invalid OpenAPI version %s: %s", o.OpenAPI, err.Error()), core, core.OpenAPI)) } - - 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.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.openapi only OpenAPI versions between %s and %s are supported", MinimumSupportedVersion, MaximumSupportedVersion), core, core.OpenAPI)) + } } errs = append(errs, o.Info.Validate(ctx, opts...)...) @@ -210,6 +217,12 @@ func (o *OpenAPI) Validate(ctx context.Context, opts ...validation.Option) []err errs = append(errs, o.Components.Validate(ctx, opts...)...) } + if core.Self.Present && o.Self != nil { + if _, err := url.Parse(*o.Self); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.$self is not a valid uri reference: %s", err), core, core.Self)) + } + } + if core.JSONSchemaDialect.Present && o.JSONSchemaDialect != nil { if _, err := url.Parse(*o.JSONSchemaDialect); err != nil { errs = append(errs, validation.NewValueError(validation.NewValueValidationError("openapi.jsonSchemaDialect is not a valid uri: %s", err), core, core.JSONSchemaDialect)) diff --git a/openapi/openapi_examples_test.go b/openapi/openapi_examples_test.go index 59ab0e8..01565b0 100644 --- a/openapi/openapi_examples_test.go +++ b/openapi/openapi_examples_test.go @@ -19,7 +19,7 @@ import ( "github.com/speakeasy-api/openapi/yml" ) -// The below examples should be copied into the README.md file if ever changed +// The below examples should be copied into the README.md file if ever changed using `mise run update-examples` // Example_reading demonstrates how to read and parse an OpenAPI document from a file. // This includes validation by default and shows how to access document properties. @@ -47,7 +47,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 } @@ -132,7 +132,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 @@ -265,10 +265,10 @@ func Example_validating() { // [56:7] schema.examples expected array, got object // [59:15] schema.properties.name expected one of [boolean, object], got string // [59:15] schema.properties.name expected one of [boolean, object], got string - // [59:15] schema.properties.name failed to validate either Schema [schema.properties.name expected object, got `string`] or bool [schema.properties.name line 59: cannot unmarshal !!str `string` into bool] + // [59:15] schema.properties.name failed to validate either Schema [schema.properties.name expected object, got `str...`] or bool [schema.properties.name line 59: cannot unmarshal !!str `string` into bool] // [60:18] schema.properties.example expected one of [boolean, object], got string // [60:18] schema.properties.example expected one of [boolean, object], got string - // [60:18] schema.properties.example failed to validate either Schema [schema.properties.example expected object, got `John Doe`] or bool [schema.properties.example line 60: cannot unmarshal !!str `John Doe` into bool] + // [60:18] schema.properties.example failed to validate either Schema [schema.properties.example expected object, got `John Do...`] or bool [schema.properties.example line 60: cannot unmarshal !!str `John Doe` into bool] // [63:9] schema.examples expected sequence, got object // // Fixing validation errors... @@ -385,8 +385,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 @@ -414,6 +414,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 @@ -628,6 +629,7 @@ func Example_creating() { { URL: "https://api.example.com/v1", Description: pointer.From("Production server"), + Name: pointer.From("prod"), }, }, Paths: paths, @@ -641,7 +643,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 @@ -649,6 +651,7 @@ func Example_creating() { // servers: // - url: https://api.example.com/v1 // description: Production server + // name: prod // paths: // /users: // get: @@ -765,7 +768,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 @@ -944,7 +947,7 @@ func Example_inliningSchema() { // } } -// Example_upgrading demonstrates how to upgrade an OpenAPI document from 3.0.x to 3.1.1. +// Example_upgrading demonstrates how to upgrade an OpenAPI document from 3.0.x to 3.2.0. // Shows the automatic conversion of nullable fields, examples, and other version differences. func Example_upgrading() { ctx := context.Background() @@ -954,7 +957,7 @@ func Example_upgrading() { info: title: Legacy API version: 1.0.0 - description: An API that needs upgrading from 3.0.3 to 3.1.1 + description: An API that needs upgrading from 3.0.3 to 3.2.0 paths: /users: get: @@ -1009,14 +1012,14 @@ 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 - // description: An API that needs upgrading from 3.0.3 to 3.1.1 + // description: An API that needs upgrading from 3.0.3 to 3.2.0 // paths: // /users: // get: diff --git a/openapi/openapi_unmarshal_test.go b/openapi/openapi_unmarshal_test.go index 20d4d97..b3ed5de 100644 --- a/openapi/openapi_unmarshal_test.go +++ b/openapi/openapi_unmarshal_test.go @@ -1,6 +1,7 @@ package openapi_test import ( + "fmt" "strings" "testing" @@ -121,7 +122,6 @@ paths: {}`, wantErrs: []string{ "[1:1] openapi.openapi invalid OpenAPI version : invalid version ", "[1:1] openapi.openapi is missing", - "[1:1] openapi.openapi only OpenAPI version 3.1.1 and below is supported", }, }, { @@ -137,7 +137,7 @@ info: title: Test API version: 1.0.0 paths: {}`, - wantErrs: []string{"[1:10] openapi.openapi only OpenAPI version 3.1.1 and below is supported"}, + wantErrs: []string{fmt.Sprintf("[1:10] openapi.openapi only OpenAPI versions between %s and %s are supported", openapi.MinimumSupportedVersion, openapi.MaximumSupportedVersion)}, }, } diff --git a/openapi/openapi_validate_test.go b/openapi/openapi_validate_test.go index 61f12c0..f7bd670 100644 --- a/openapi/openapi_validate_test.go +++ b/openapi/openapi_validate_test.go @@ -101,6 +101,28 @@ info: paths: {} x-custom: value x-api-version: 2.0 +`, + }, + { + name: "valid_with_self_absolute_uri", + yml: ` +openapi: 3.2.0 +$self: https://example.com/api/openapi.yaml +info: + title: Test API + version: 1.0.0 +paths: {} +`, + }, + { + name: "valid_with_self_relative_uri", + yml: ` +openapi: 3.2.0 +$self: /api/openapi.yaml +info: + title: Test API + version: 1.0.0 +paths: {} `, }, { @@ -185,7 +207,7 @@ info: version: 1.0.0 paths: {} `, - wantErrs: []string{"only OpenAPI version 3.1.1 and below is supported"}, + wantErrs: []string{"openapi.openapi only OpenAPI versions between"}, }, { name: "invalid_info_missing_title", @@ -246,6 +268,18 @@ paths: {} `, wantErrs: []string{"[7:3] externalDocumentation.url is missing"}, }, + { + name: "invalid_self_not_uri", + yml: ` +openapi: 3.2.0 +$self: "ht!tp://invalid-scheme" +info: + title: Test API + version: 1.0.0 +paths: {} +`, + wantErrs: []string{"openapi.$self is not a valid uri reference"}, + }, } for _, tt := range tests { diff --git a/openapi/operation.go b/openapi/operation.go index b3c19f9..da02ea9 100644 --- a/openapi/operation.go +++ b/openapi/operation.go @@ -151,6 +151,7 @@ func (o *Operation) GetExtensions() *extensions.Extensions { } // IsDeprecated is an alias for GetDeprecated for backward compatibility. +// // Deprecated: Use GetDeprecated instead for consistency with other models. func (o *Operation) IsDeprecated() bool { return o.GetDeprecated() diff --git a/openapi/operation_validate_test.go b/openapi/operation_validate_test.go index e2949cb..92c878d 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/parameter.go b/openapi/parameter.go index a21b9c4..68304dc 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.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.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.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,11 +262,29 @@ 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...)...) + } } - for _, obj := range p.Content.All() { - errs = append(errs, obj.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 mediaType, obj := range p.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, obj.Validate(ctx, contentOpts...)...) } for _, obj := range p.Examples.All() { diff --git a/openapi/parameter_validate_test.go b/openapi/parameter_validate_test.go index 0e19fba..84a1776 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.in must be one of [query, header, path, cookie]"}, + wantErrs: []string{"[3:5] parameter.in must be one of [query, querystring, header, path, cookie]"}, }, { name: "multiple validation errors", @@ -201,6 +235,56 @@ required: false "[4:11] parameter.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 { diff --git a/openapi/paths.go b/openapi/paths.go index 1be74d1..1b59778 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" @@ -80,12 +82,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 { @@ -102,6 +122,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 } @@ -137,46 +160,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 { @@ -222,6 +285,15 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er core := p.GetCore() errs := []error{} + o := validation.NewOptions(opts...) + + oa := validation.GetContextObject[OpenAPI](o) + // If OpenAPI object is not provided, assume the latest version + openapiVersion := Version + if oa != nil { + openapiVersion = oa.OpenAPI + } + for _, op := range p.All() { errs = append(errs, op.Validate(ctx, opts...)...) } @@ -234,6 +306,33 @@ func (p *PathItem) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, parameter.Validate(ctx, opts...)...) } + supportsAdditionalOperations, err := version.IsGreaterOrEqual(openapiVersion, Version) + 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", openapiVersion), 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 ec2294d..30461c2 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{"[3:5] parameter.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/reference.go b/openapi/reference.go index da75067..f64e843 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" "github.com/speakeasy-api/openapi/internal/interfaces" @@ -525,7 +526,17 @@ func (r *Reference[T, V, C]) resolve(ctx context.Context, opts references.Resolv if !ok { return nil, nil, nil, fmt.Errorf("root document must be *OpenAPI, got %T", opts.RootDocument) } - result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), opts) + + // Use $self as base URI if present in the target document (OpenAPI 3.2+) + // The $self field provides the self-assigned URI of the document per RFC3986 Section 5.1.1 + resolveOpts := opts + if targetDoc, ok := opts.TargetDocument.(*OpenAPI); ok && targetDoc != nil { + if self := targetDoc.GetSelf(); self != "" { + resolveOpts.TargetLocation = self + } + } + + result, validationErrs, err := references.Resolve(ctx, *r.Reference, unmarshaler[T, V, C](rootDoc), resolveOpts) if err != nil { return nil, nil, validationErrs, err } @@ -626,11 +637,13 @@ func joinReferenceChain(chain []string) string { return chain[0] } - result := chain[0] + var result strings.Builder + result.WriteString(chain[0]) for i := 1; i < len(chain); i++ { - result += " -> " + chain[i] + result.WriteString(" -> ") + result.WriteString(chain[i]) } - return result + return result.String() } func unmarshaler[T any, V interfaces.Validator[T], C marshaller.CoreModeler](_ *OpenAPI) func(context.Context, *yaml.Node, bool) (*Reference[T, V, C], []error, error) { diff --git a/openapi/reference_resolve_test.go b/openapi/reference_resolve_test.go index 3924e21..92f74f8 100644 --- a/openapi/reference_resolve_test.go +++ b/openapi/reference_resolve_test.go @@ -1,10 +1,12 @@ package openapi import ( + "bytes" "io" "io/fs" "os" "path/filepath" + "strings" "testing" "time" @@ -1065,3 +1067,198 @@ func TestReference_ParentLinks(t *testing.T) { }, "SetTopLevelParent on nil reference should not panic") }) } + +func TestResolveObject_WithSelf_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create mock filesystem to simulate the virtual location implied by $self + mockFS := NewMockVirtualFS() + + // Read the shared file that should be resolved relative to $self + sharedPath := filepath.Join("testdata", "resolve_test", "shared.yaml") + sharedContent, err := os.ReadFile(sharedPath) + require.NoError(t, err) + + // The $self is /test/api/openapi.yaml (absolute file path) + // So a relative reference ./shared.yaml should resolve to /test/api/shared.yaml + mockFS.AddFile("/test/api/shared.yaml", sharedContent) + + // Load the document with $self field + testDataPath := filepath.Join("testdata", "resolve_test", "with_self.yaml") + file, err := os.Open(testDataPath) + require.NoError(t, err) + defer file.Close() + + doc, validationErrs, err := Unmarshal(ctx, file) + require.NoError(t, err) + assert.Empty(t, validationErrs) + + // Verify $self is set correctly + require.NotNil(t, doc.Self) + assert.Equal(t, "/test/api/openapi.yaml", *doc.Self) + assert.Equal(t, "/test/api/openapi.yaml", doc.GetSelf()) + + // Setup resolve options - use a filesystem path as TargetLocation + // but $self should override it for external reference resolution + absPath, err := filepath.Abs(testDataPath) + require.NoError(t, err) + + opts := ResolveOptions{ + TargetLocation: absPath, // Filesystem location (different from $self) + RootDocument: doc, + VirtualFS: mockFS, // Use mock FS to intercept file access + } + + // Test resolving the external reference to shared.yaml + // The reference is './shared.yaml#/components/parameters/SharedParam' + // With $self = /test/api/openapi.yaml + // It should resolve to /test/api/shared.yaml#/components/parameters/SharedParam + require.NotNil(t, doc.Components) + require.NotNil(t, doc.Components.Parameters) + + externalParam, exists := doc.Components.Parameters.Get("ExternalParam") + require.True(t, exists, "ExternalParam should exist in components") + require.True(t, externalParam.IsReference(), "ExternalParam should be a reference") + + // Resolve the reference - it should use $self as base URI + validationErrs, err = externalParam.Resolve(ctx, opts) + require.NoError(t, err) + assert.Empty(t, validationErrs) + + resolved := externalParam.GetObject() + require.NotNil(t, resolved, "Resolved parameter should not be nil") + + // Verify the resolved parameter is the SharedParam from shared.yaml + assert.Equal(t, "sharedParam", resolved.GetName()) + assert.Equal(t, ParameterInQuery, resolved.GetIn()) + assert.True(t, resolved.GetRequired()) + assert.Equal(t, "A parameter defined in the shared file", resolved.GetDescription()) + + // Verify that the mock FS was accessed with the path resolved from $self + // The relative reference './shared.yaml' combined with $self '/test/api/openapi.yaml' + // should result in accessing '/test/api/shared.yaml' + assert.Equal(t, 1, mockFS.GetAccessCount("/test/api/shared.yaml"), + "shared.yaml should be accessed via path resolved from $self, not from TargetLocation") +} + +func TestSecurityFileDisclosure_WithMockFS(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Create a mock file system with simulated sensitive content + mockFS := NewMockVirtualFS() + sensitiveContent := []byte(`root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin`) + + mockFS.AddFile("/etc/passwd", sensitiveContent) + + // Schema files can be in any directory (since $self is /etc/passwd - absolute ref is resolved) + schemasContent := []byte(` +User: + type: object + properties: + name: + type: string +`) + mockFS.AddFile("/test/api/shared/schemas.yaml", schemasContent) + + // Document with reference that directly points to /etc/passwd + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +components: + schemas: + User: + $ref: "/etc/passwd" +` + + doc, validationErrs, err := Unmarshal(ctx, bytes.NewBufferString(yml)) + require.NoError(t, err, "Document should parse") + if len(validationErrs) > 0 { + t.Logf("Validation errors during unmarshal: %v", validationErrs) + } + + // Try to resolve references using the mock FS + resolveOpts := ResolveOptions{ + RootDocument: doc, + TargetLocation: ".", + DisableExternalRefs: false, + VirtualFS: mockFS, + } + + // Try to resolve the schema reference + if doc.Components != nil && doc.Components.Schemas != nil { + for schemaName, schema := range doc.Components.Schemas.All() { + t.Logf("Checking schema: %s, IsReference: %v", schemaName, schema.IsReference()) + if schema.IsReference() { + t.Logf("Attempting to resolve reference: %s", schema.GetReference()) + resolveErrs, resolveErr := schema.Resolve(ctx, resolveOpts) + + // Should fail because /etc/passwd is not valid YAML + if resolveErr == nil && len(resolveErrs) == 0 { + t.Error("Expected resolution to fail for non-OpenAPI file") + } + + // Collect all error messages + var allErrors []string + if resolveErr != nil { + allErrors = append(allErrors, resolveErr.Error()) + t.Logf("Resolve error: %s", resolveErr.Error()) + } + for _, err := range resolveErrs { + if err != nil { + allErrors = append(allErrors, err.Error()) + t.Logf("Validation error: %s", err.Error()) + } + } + + combinedError := strings.Join(allErrors, "\n") + + // Check if our known sensitive content appears in the error + sensitivePatterns := []string{ + "root:x:0:0", + "daemon:x:1:1", + "/bin/bash", + "/usr/sbin/nologin", + } + + foundSensitiveContent := false + for _, pattern := range sensitivePatterns { + if len(combinedError) > 0 && containsString(combinedError, pattern) { + t.Errorf("SECURITY ISSUE: Sensitive content leaked in error message. Pattern found: %q", pattern) + foundSensitiveContent = true + break + } + } + + if !foundSensitiveContent { + t.Logf("PASS: No sensitive content found in error messages") + } + + // Verify file was accessed + if len(mockFS.GetAccessLog()) > 0 { + t.Logf("Files accessed: %v", mockFS.GetAccessLog()) + } + } + } + } +} + +// Helper function for case-sensitive string containment +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || indexString(s, substr) >= 0) +} + +func indexString(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/openapi/requests.go b/openapi/requests.go index 2927d0f..0756000 100644 --- a/openapi/requests.go +++ b/openapi/requests.go @@ -60,8 +60,11 @@ func (r *RequestBody) Validate(ctx context.Context, opts ...validation.Option) [ errs = append(errs, validation.NewValueError(validation.NewMissingValueError("requestBody.content is required"), core, core.Content)) } - for _, content := range r.Content.All() { - errs = append(errs, content.Validate(ctx, opts...)...) + for mediaType, content := range r.Content.All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, content.Validate(ctx, contentOpts...)...) } r.Valid = len(errs) == 0 && core.GetValid() diff --git a/openapi/responses.go b/openapi/responses.go index 65d8b03..6b6cf05 100644 --- a/openapi/responses.go +++ b/openapi/responses.go @@ -198,8 +198,11 @@ func (r *Response) Validate(ctx context.Context, opts ...validation.Option) []er errs = append(errs, header.Validate(ctx, opts...)...) } - for _, content := range r.GetContent().All() { - errs = append(errs, content.Validate(ctx, opts...)...) + for mediaType, content := range r.GetContent().All() { + // Pass media type context for validation + contentOpts := append([]validation.Option{}, opts...) + contentOpts = append(contentOpts, validation.WithContextObject(&MediaTypeContext{MediaType: mediaType})) + errs = append(errs, content.Validate(ctx, contentOpts...)...) } for _, link := range r.GetLinks().All() { diff --git a/openapi/security.go b/openapi/security.go index 9370535..82d7715 100644 --- a/openapi/security.go +++ b/openapi/security.go @@ -64,6 +64,10 @@ type SecurityScheme struct { Flows *OAuthFlows // OpenIdConnectUrl is a URL to discover OAuth2 configuration values. OpenIdConnectUrl *string + // OAuth2MetadataUrl is a URL to the OAuth2 authorization server metadata (RFC8414). + OAuth2MetadataUrl *string + // Deprecated declares this security scheme to be deprecated. + Deprecated *bool // Extensions provides a list of extensions to the SecurityScheme object. Extensions *extensions.Extensions } @@ -134,6 +138,22 @@ func (s *SecurityScheme) GetOpenIdConnectUrl() string { return *s.OpenIdConnectUrl } +// GetOAuth2MetadataUrl returns the value of the OAuth2MetadataUrl field. Returns empty string if not set. +func (s *SecurityScheme) GetOAuth2MetadataUrl() string { + if s == nil || s.OAuth2MetadataUrl == nil { + return "" + } + return *s.OAuth2MetadataUrl +} + +// GetDeprecated returns the value of the Deprecated field. Returns false if not set. +func (s *SecurityScheme) GetDeprecated() bool { + if s == nil || s.Deprecated == nil { + return false + } + return *s.Deprecated +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (s *SecurityScheme) GetExtensions() *extensions.Extensions { if s == nil || s.Extensions == nil { @@ -178,6 +198,12 @@ func (s *SecurityScheme) Validate(ctx context.Context, opts ...validation.Option } else { errs = append(errs, s.Flows.Validate(ctx, opts...)...) } + // Validate oauth2MetadataUrl if present + if core.OAuth2MetadataUrl.Present && s.OAuth2MetadataUrl != nil && *s.OAuth2MetadataUrl != "" { + if _, err := url.Parse(*s.OAuth2MetadataUrl); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("securityScheme.oauth2MetadataUrl is not a valid uri: %s", err), core, core.OAuth2MetadataUrl)) + } + } case SecuritySchemeTypeOpenIDConnect: if !core.OpenIdConnectUrl.Present || *s.OpenIdConnectUrl == "" { errs = append(errs, validation.NewValueError(validation.NewMissingValueError("securityScheme.openIdConnectUrl is required for type=openIdConnect"), core, core.OpenIdConnectUrl)) @@ -258,9 +284,25 @@ func (s *SecurityRequirement) Validate(ctx context.Context, opts ...validation.O } for securityScheme := range s.Keys() { - if openapi.Components == nil || !openapi.Components.SecuritySchemes.Has(securityScheme) { - errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("securityRequirement scheme %s is not defined in components.securitySchemes", securityScheme), core, core, securityScheme)) + // Per OpenAPI 3.2 spec: property names that are identical to a component name + // MUST be treated as a component name (takes precedence over URI resolution) + if openapi.Components != nil && openapi.Components.SecuritySchemes.Has(securityScheme) { + continue + } + + // If not found as component name, check if it's a valid URI reference + // to a Security Scheme Object (OpenAPI 3.2 feature) + if _, err := url.Parse(securityScheme); err == nil { + // It's a valid URI - in a full implementation, we would try to resolve it + // For now, we accept it as valid if it parses as a URI + // TODO A complete implementation would need to: + // 1. Resolve the URI to a Security Scheme Object + // 2. Validate that the resolved object is indeed a Security Scheme + continue } + + // Not found as component name and not a valid URI + errs = append(errs, validation.NewMapKeyError(validation.NewValueValidationError("securityRequirement scheme %s is not defined in components.securitySchemes and is not a valid URI reference", securityScheme), core, core, securityScheme)) } s.Valid = len(errs) == 0 && core.GetValid() @@ -280,6 +322,8 @@ type OAuthFlows struct { ClientCredentials *OAuthFlow // AuthorizationCode represents configuration fields for the OAuth2 Authorization Code flow. AuthorizationCode *OAuthFlow + // DeviceAuthorization represents configuration fields for the OAuth2 Device Authorization flow (RFC8628). + DeviceAuthorization *OAuthFlow // Extensions provides a list of extensions to the OAuthFlows object. Extensions *extensions.Extensions @@ -290,10 +334,11 @@ var _ interfaces.Model[core.OAuthFlows] = (*OAuthFlows)(nil) type OAuthFlowType string const ( - OAuthFlowTypeImplicit OAuthFlowType = "implicit" - OAuthFlowTypePassword OAuthFlowType = "password" - OAuthFlowTypeClientCredentials OAuthFlowType = "clientCredentials" - OAuthFlowTypeAuthorizationCode OAuthFlowType = "authorizationCode" + OAuthFlowTypeImplicit OAuthFlowType = "implicit" + OAuthFlowTypePassword OAuthFlowType = "password" + OAuthFlowTypeClientCredentials OAuthFlowType = "clientCredentials" + OAuthFlowTypeAuthorizationCode OAuthFlowType = "authorizationCode" + OAuthFlowTypeDeviceAuthorization OAuthFlowType = "deviceAuthorization" ) // GetImplicit returns the value of the Implicit field. Returns nil if not set. @@ -328,6 +373,14 @@ func (o *OAuthFlows) GetAuthorizationCode() *OAuthFlow { return o.AuthorizationCode } +// GetDeviceAuthorization returns the value of the DeviceAuthorization field. Returns nil if not set. +func (o *OAuthFlows) GetDeviceAuthorization() *OAuthFlow { + if o == nil { + return nil + } + return o.DeviceAuthorization +} + // GetExtensions returns the value of the Extensions field. Returns an empty extensions map if not set. func (o *OAuthFlows) GetExtensions() *extensions.Extensions { if o == nil || o.Extensions == nil { @@ -353,6 +406,9 @@ func (o *OAuthFlows) Validate(ctx context.Context, opts ...validation.Option) [] if o.AuthorizationCode != nil { errs = append(errs, o.AuthorizationCode.Validate(ctx, append(opts, validation.WithContextObject(pointer.From(OAuthFlowTypeAuthorizationCode)))...)...) } + if o.DeviceAuthorization != nil { + errs = append(errs, o.DeviceAuthorization.Validate(ctx, append(opts, validation.WithContextObject(pointer.From(OAuthFlowTypeDeviceAuthorization)))...)...) + } o.Valid = len(errs) == 0 && core.GetValid() @@ -365,6 +421,8 @@ type OAuthFlow struct { // AuthorizationUrl is a URL to be used for obtaining authorization. AuthorizationURL *string + // DeviceAuthorizationUrl is a URL to be used for obtaining device authorization (RFC8628). + DeviceAuthorizationURL *string // TokenUrl is a URL to be used for obtaining access tokens. TokenURL *string // RefreshUrl is a URL to be used for refreshing access tokens. @@ -385,6 +443,14 @@ func (o *OAuthFlow) GetAuthorizationURL() string { return *o.AuthorizationURL } +// GetDeviceAuthorizationURL returns the value of the DeviceAuthorizationURL field. Returns empty string if not set. +func (o *OAuthFlow) GetDeviceAuthorizationURL() string { + if o == nil || o.DeviceAuthorizationURL == nil { + return "" + } + return *o.DeviceAuthorizationURL +} + // GetTokenURL returns the value of the TokenURL field. Returns empty string if not set. func (o *OAuthFlow) GetTokenURL() string { if o == nil || o.TokenURL == nil { @@ -469,6 +535,21 @@ func (o *OAuthFlow) Validate(ctx context.Context, opts ...validation.Option) []e errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.tokenUrl is not a valid uri: %s", err), core, core.TokenURL)) } } + case OAuthFlowTypeDeviceAuthorization: + if !core.DeviceAuthorizationURL.Present || *o.DeviceAuthorizationURL == "" { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("oAuthFlow.deviceAuthorizationUrl is required for type=deviceAuthorization"), core, core.DeviceAuthorizationURL)) + } else { + if _, err := url.Parse(*o.DeviceAuthorizationURL); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.deviceAuthorizationUrl is not a valid uri: %s", err), core, core.DeviceAuthorizationURL)) + } + } + if !core.TokenURL.Present || *o.TokenURL == "" { + errs = append(errs, validation.NewValueError(validation.NewMissingValueError("oAuthFlow.tokenUrl is required for type=deviceAuthorization"), core, core.TokenURL)) + } else { + if _, err := url.Parse(*o.TokenURL); err != nil { + errs = append(errs, validation.NewValueError(validation.NewValueValidationError("oAuthFlow.tokenUrl is not a valid uri: %s", err), core, core.TokenURL)) + } + } } if core.RefreshURL.Present { diff --git a/openapi/security_unmarshal_test.go b/openapi/security_unmarshal_test.go index 683171b..a6855f4 100644 --- a/openapi/security_unmarshal_test.go +++ b/openapi/security_unmarshal_test.go @@ -91,6 +91,39 @@ type: http scheme: bearer x-custom: value x-another: 123 +`, + }, + { + name: "oauth2_with_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server +`, + }, + { + name: "deprecated_scheme", + yml: ` +type: http +scheme: bearer +deprecated: true +`, + }, + { + name: "oauth2_device_authorization", + yml: ` +type: oauth2 +flows: + deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -186,6 +219,11 @@ authorizationCode: scopes: read: Read access write: Write access +deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + device: Device access x-custom: value ` @@ -209,6 +247,10 @@ x-custom: value require.Equal(t, "https://example.com/oauth/token", oauthFlows.GetAuthorizationCode().GetTokenURL()) require.Equal(t, "https://example.com/oauth/refresh", oauthFlows.GetAuthorizationCode().GetRefreshURL()) + require.NotNil(t, oauthFlows.GetDeviceAuthorization()) + require.Equal(t, "https://example.com/oauth/device_authorization", oauthFlows.GetDeviceAuthorization().GetDeviceAuthorizationURL()) + require.Equal(t, "https://example.com/oauth/token", oauthFlows.GetDeviceAuthorization().GetTokenURL()) + ext, ok := oauthFlows.GetExtensions().Get("x-custom") require.True(t, ok) require.Equal(t, "value", ext.Value) @@ -271,6 +313,15 @@ tokenUrl: https://example.com/oauth/token scopes: read: Read access x-custom: value +`, + }, + { + name: "device_authorization_flow", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access `, }, } diff --git a/openapi/security_validate_test.go b/openapi/security_validate_test.go index b548597..1e460b3 100644 --- a/openapi/security_validate_test.go +++ b/openapi/security_validate_test.go @@ -94,6 +94,39 @@ type: http scheme: bearer x-custom: value x-another: 123 +`, + }, + { + name: "valid_oauth2_with_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: https://example.com/.well-known/oauth-authorization-server +`, + }, + { + name: "valid_deprecated_scheme", + yml: ` +type: http +scheme: bearer +deprecated: true +`, + }, + { + name: "valid_oauth2_device_authorization", + yml: ` +type: oauth2 +flows: + deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -182,6 +215,20 @@ type: openIdConnect `, wantErrs: []string{"openIdConnectUrl is required for type=openIdConnect"}, }, + { + name: "oauth2_invalid_metadata_url", + yml: ` +type: oauth2 +flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access +oauth2MetadataUrl: ://invalid-url +`, + wantErrs: []string{"oauth2MetadataUrl is not a valid uri"}, + }, } for _, tt := range tests { @@ -292,9 +339,9 @@ func TestSecurityRequirement_Validate_Error(t *testing.T) { { name: "undefined_security_scheme", yml: ` -undefined_scheme: [] +"://invalid uri": [] `, - expectedErr: "securityRequirement scheme undefined_scheme is not defined in components.securitySchemes", + expectedErr: "securityRequirement scheme ://invalid uri is not defined in components.securitySchemes and is not a valid URI reference", }, } @@ -322,6 +369,101 @@ undefined_scheme: [] } } +func TestSecurityRequirement_Validate_URIReferences_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yml string + }{ + { + name: "uri_reference_absolute", + yml: ` +https://example.com/security/schemes/oauth2: [] +`, + }, + { + name: "uri_reference_relative", + yml: ` +./security/oauth2: [] +`, + }, + { + name: "uri_reference_with_fragment", + yml: ` +https://example.com/api#/components/securitySchemes/oauth2: [] +`, + }, + { + name: "mixed_component_and_uri", + yml: ` +api_key: [] +https://example.com/security/oauth2: + - read + - write +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var securityRequirement openapi.SecurityRequirement + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(tt.yml), &securityRequirement) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Create a mock OpenAPI document with one known component + openAPIDoc := &openapi.OpenAPI{ + Components: &openapi.Components{ + SecuritySchemes: sequencedmap.New( + sequencedmap.NewElem("api_key", &openapi.ReferencedSecurityScheme{ + Object: &openapi.SecurityScheme{Type: openapi.SecuritySchemeTypeAPIKey}, + }), + ), + }, + } + + errs := securityRequirement.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) + require.Empty(t, errs, "Expected no validation errors for valid URI references") + }) + } +} + +func TestSecurityRequirement_Validate_ComponentNamePrecedence_Success(t *testing.T) { + t.Parallel() + + // Test that component names take precedence over URI interpretation + // per spec: "Property names that are identical to a component name under + // the Components Object MUST be treated as a component name" + yml := ` +foo: [] +` + + var securityRequirement openapi.SecurityRequirement + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &securityRequirement) + require.NoError(t, err) + require.Empty(t, validationErrs) + + // Create a mock OpenAPI document where "foo" is both a valid URI segment + // and a component name - component name should take precedence + openAPIDoc := &openapi.OpenAPI{ + Components: &openapi.Components{ + SecuritySchemes: sequencedmap.New( + sequencedmap.NewElem("foo", &openapi.ReferencedSecurityScheme{ + Object: &openapi.SecurityScheme{Type: openapi.SecuritySchemeTypeHTTP}, + }), + ), + }, + } + + errs := securityRequirement.Validate(t.Context(), validation.WithContextObject(openAPIDoc)) + require.Empty(t, errs, "Component name 'foo' should take precedence over URI interpretation") +} + func TestOAuthFlows_Validate_Success(t *testing.T) { t.Parallel() @@ -392,6 +534,16 @@ implicit: scopes: read: Read access x-custom: value +`, + }, + { + name: "valid_device_authorization_flow", + yml: ` +deviceAuthorization: + deviceAuthorizationUrl: https://example.com/oauth/device_authorization + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access `, }, } @@ -478,6 +630,16 @@ x-custom: value `, flowType: openapi.OAuthFlowTypePassword, }, + { + name: "valid_device_authorization_flow", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + }, } for _, tt := range tests { @@ -558,6 +720,26 @@ tokenUrl: https://example.com/oauth/token flowType: openapi.OAuthFlowTypePassword, expectedErr: "scopes is required (empty map is allowed)", }, + { + name: "device_authorization_missing_device_authorization_url", + yml: ` +tokenUrl: https://example.com/oauth/token +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + expectedErr: "deviceAuthorizationUrl is required for type=deviceAuthorization", + }, + { + name: "device_authorization_missing_token_url", + yml: ` +deviceAuthorizationUrl: https://example.com/oauth/device_authorization +scopes: + read: Read access +`, + flowType: openapi.OAuthFlowTypeDeviceAuthorization, + expectedErr: "tokenUrl is required for type=deviceAuthorization", + }, } for _, tt := range tests { diff --git a/openapi/server.go b/openapi/server.go index 7f8f1cb..83789bd 100644 --- a/openapi/server.go +++ b/openapi/server.go @@ -28,6 +28,8 @@ type Server struct { URL string // A description of the server. May contain CommonMark syntax. Description *string + // An optional unique string to refer to the host designated by the URL. + Name *string // A map of variables available to be templated into the URL. Variables *sequencedmap.Map[string, *ServerVariable] @@ -53,6 +55,14 @@ func (s *Server) GetDescription() string { return *s.Description } +// GetName returns the value of the Name field. Returns empty string if not set. +func (s *Server) GetName() string { + if s == nil || s.Name == nil { + return "" + } + return *s.Name +} + // GetVariables returns the value of the Variables field. Returns nil if not set. func (s *Server) GetVariables() *sequencedmap.Map[string, *ServerVariable] { if s == nil { diff --git a/openapi/server_unmarshal_test.go b/openapi/server_unmarshal_test.go index d1a785f..b34c6dd 100644 --- a/openapi/server_unmarshal_test.go +++ b/openapi/server_unmarshal_test.go @@ -56,6 +56,26 @@ x-test: some-value require.Equal(t, "some-value", ext.Value) } +func TestServer_Unmarshal_WithName_Success(t *testing.T) { + t.Parallel() + + yml := ` +url: https://api.example.com/v1 +description: Production server +name: prod +` + + var server openapi.Server + + validationErrs, err := marshaller.Unmarshal(t.Context(), bytes.NewBufferString(yml), &server) + require.NoError(t, err) + require.Empty(t, validationErrs) + + require.Equal(t, "https://api.example.com/v1", server.GetURL()) + require.Equal(t, "Production server", server.GetDescription()) + require.Equal(t, "prod", server.GetName()) +} + func TestServerVariable_Unmarshal_Success(t *testing.T) { t.Parallel() diff --git a/openapi/tag.go b/openapi/tag.go index 7bd0cdf..bba656f 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 { @@ -73,7 +103,62 @@ func (t *Tag) Validate(ctx context.Context, opts ...validation.Option) []error { errs = append(errs, t.ExternalDocs.Validate(ctx, opts...)...) } + // Get OpenAPI object from validation options to check parent relationships + o := validation.NewOptions(opts...) + openapi := validation.GetContextObject[OpenAPI](o) + + // If we have an OpenAPI object with tags, validate parent relationships + if openapi != nil && openapi.Tags != nil && t.Parent != nil && *t.Parent != "" { + allTags := openapi.Tags + + // Check if parent tag exists + parentExists := false + for _, tag := range allTags { + if tag != nil && tag.Name == *t.Parent { + parentExists = true + break + } + } + + if !parentExists { + 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)) { + errs = append(errs, validation.NewValueError( + validation.NewValueValidationError("circular parent reference detected for tag '%s'", t.Name), + core, core.Parent)) + } + } + t.Valid = len(errs) == 0 && core.GetValid() return errs } + +// 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..74a88db --- /dev/null +++ b/openapi/tag_kind_registry.go @@ -0,0 +1,61 @@ +package openapi + +import "fmt" + +// TagKind represents commonly used values for the Tag.Kind field. +// These values are registered in the OpenAPI Initiative's Tag Kind Registry +// at https://spec.openapis.org/registry/tag-kind/ +type TagKind string + +var _ fmt.Stringer = (*TagKind)(nil) + +// Officially registered Tag Kind values from the OpenAPI Initiative registry +const ( + // TagKindNav represents tags used for navigation purposes + 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..c35cc2e 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.Empty(t, tag.GetDescription()) + require.Empty(t, tag.GetParent()) + require.Empty(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 66ca5fd..95a5a22 100644 --- a/openapi/tag_validate_test.go +++ b/openapi/tag_validate_test.go @@ -7,6 +7,7 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/validation" "github.com/stretchr/testify/require" ) @@ -57,6 +58,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 +197,187 @@ 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} + + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + + for _, tag := range allTags { + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) + 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} + + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) + require.NotEmpty(t, errs, "expected validation errors") + + found := false + 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() + + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: tt.tags, + } + + // Check each tag that has a parent + for _, tag := range tt.tags { + if tag.Parent != nil { + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) + 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} + + // Create an OpenAPI document with all tags for validation + doc := &openapi.OpenAPI{ + OpenAPI: "3.2.0", + Info: openapi.Info{ + Title: "Test API", + Version: "1.0.0", + }, + Tags: allTags, + } + + for _, tag := range allTags { + errs := tag.Validate(t.Context(), validation.WithContextObject(doc)) + 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 4387b0b..bfc5946 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" @@ -13,10 +13,12 @@ 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" servers: - url: "https://api.example.com/v1" description: "Production server" 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/bundled_counter_expected.yaml b/openapi/testdata/inline/bundled_counter_expected.yaml index b9074c4..cf9b92a 100644 --- a/openapi/testdata/inline/bundled_counter_expected.yaml +++ b/openapi/testdata/inline/bundled_counter_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -100,6 +100,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "#/components/parameters/DestinationParam" + requestBody: + $ref: "#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts @@ -380,6 +396,15 @@ components: schema: type: string format: uuid + DestinationParam: + name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" ComplexFilterParam: name: filter in: query @@ -572,6 +597,44 @@ components: required: - userId - username + CompressionConfig: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + ResourceMetadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true Organization: type: object properties: @@ -887,6 +950,34 @@ components: format: email profile: $ref: "#/components/schemas/UserProfile" + CopyRequest: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + $ref: '#/components/schemas/CompressionConfig' + required: + - source_path + - destination_path + required: true responses: UserResponse: description: Single user response @@ -923,6 +1014,35 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + CopyResponse: + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + $ref: '#/components/schemas/ResourceMetadata' + created_at: + type: string + format: date-time + required: + - copy_id + - status examples: UserExample: summary: Example user diff --git a/openapi/testdata/inline/bundled_expected.yaml b/openapi/testdata/inline/bundled_expected.yaml index 1a285a3..1924fa6 100644 --- a/openapi/testdata/inline/bundled_expected.yaml +++ b/openapi/testdata/inline/bundled_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -100,6 +100,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "#/components/parameters/DestinationParam" + requestBody: + $ref: "#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts @@ -380,6 +396,15 @@ components: schema: type: string format: uuid + DestinationParam: + name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" ComplexFilterParam: name: filter in: query @@ -572,6 +597,44 @@ components: required: - userId - username + CompressionConfig: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + ResourceMetadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true Organization: type: object properties: @@ -887,6 +950,34 @@ components: format: email profile: $ref: "#/components/schemas/UserProfile" + CopyRequest: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + $ref: '#/components/schemas/CompressionConfig' + required: + - source_path + - destination_path + required: true responses: UserResponse: description: Single user response @@ -923,6 +1014,35 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + CopyResponse: + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + $ref: '#/components/schemas/ResourceMetadata' + created_at: + type: string + format: date-time + required: + - copy_id + - status examples: UserExample: summary: Example user diff --git a/openapi/testdata/inline/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 diff --git a/openapi/testdata/inline/inline_expected.yaml b/openapi/testdata/inline/inline_expected.yaml index f541b85..cc15dc2 100644 --- a/openapi/testdata/inline/inline_expected.yaml +++ b/openapi/testdata/inline/inline_expected.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -386,6 +386,134 @@ paths: tags: ["introduction", "first-post"] created_at: "2023-01-01T12:00:00Z" updated_at: "2023-01-01T12:00:00Z" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - name: destination + in: header + description: Destination for copy operation + required: true + schema: + type: string + format: uri + example: "https://backup.example.com/resources/" + requestBody: + description: Copy operation request + content: + application/json: + schema: + type: object + properties: + source_path: + type: string + description: Source resource path + destination_path: + type: string + description: Destination resource path + options: + type: object + properties: + preserve_metadata: + type: boolean + default: true + overwrite_existing: + type: boolean + default: false + compression: + type: object + properties: + enabled: + type: boolean + default: false + algorithm: + type: string + enum: + - gzip + - bzip2 + - lz4 + - zstd + default: gzip + level: + type: integer + maximum: 9 + minimum: 1 + default: 6 + required: + - source_path + - destination_path + required: true + responses: + "201": + description: Copy operation result + content: + application/json: + schema: + type: object + properties: + copy_id: + type: string + format: uuid + description: Unique copy operation identifier + status: + type: string + enum: + - queued + - processing + - completed + source_path: + type: string + destination_path: + type: string + metadata: + type: object + properties: + size: + type: integer + description: File size in bytes + mime_type: + type: string + description: MIME type of the resource + checksum: + type: string + description: Resource checksum + tags: + type: array + items: + type: string + custom_properties: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + required: + - copy_id + - status + "400": + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details + required: + - code + - message post: tags: - posts diff --git a/openapi/testdata/inline/inline_input.yaml b/openapi/testdata/inline/inline_input.yaml index 42cf0bb..6873423 100644 --- a/openapi/testdata/inline/inline_input.yaml +++ b/openapi/testdata/inline/inline_input.yaml @@ -1,4 +1,4 @@ -openapi: 3.1.0 +openapi: 3.2.0 info: title: Enhanced Test API with Complex References version: 1.0.0 @@ -106,6 +106,22 @@ paths: examples: posts_example: $ref: "#/components/examples/PostsExample" + additionalOperations: + COPY: + operationId: copyPost + summary: Copy post to another location + description: Custom COPY operation with external references + tags: + - posts + parameters: + - $ref: "external_custom_operations.yaml#/components/parameters/DestinationParam" + requestBody: + $ref: "external_custom_operations.yaml#/components/requestBodies/CopyRequest" + responses: + "201": + $ref: "external_custom_operations.yaml#/components/responses/CopyResponse" + "400": + $ref: "#/components/responses/ErrorResponse" post: tags: - posts diff --git a/openapi/testdata/resolve_test/shared.yaml b/openapi/testdata/resolve_test/shared.yaml new file mode 100644 index 0000000..6503214 --- /dev/null +++ b/openapi/testdata/resolve_test/shared.yaml @@ -0,0 +1,14 @@ +openapi: 3.2.0 +info: + title: Shared Components + version: 1.0.0 +paths: {} +components: + parameters: + SharedParam: + name: sharedParam + in: query + required: true + schema: + type: string + description: A parameter defined in the shared file diff --git a/openapi/testdata/resolve_test/with_self.yaml b/openapi/testdata/resolve_test/with_self.yaml new file mode 100644 index 0000000..0ae2862 --- /dev/null +++ b/openapi/testdata/resolve_test/with_self.yaml @@ -0,0 +1,18 @@ +openapi: 3.2.0 +$self: /test/api/openapi.yaml +info: + title: Test API with $self + version: 1.0.0 +paths: {} +components: + parameters: + # Reference to a relative external file - should resolve using $self as base + ExternalParam: + $ref: "./shared.yaml#/components/parameters/SharedParam" + responses: + TestResponse: + description: Test response + content: + application/json: + schema: + type: object 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_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/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..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.1 +openapi: 3.2.0 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_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/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/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/testdata/walk.openapi.yaml b/openapi/testdata/walk.openapi.yaml index 7d1a2fc..a6b5b38 100644 --- a/openapi/testdata/walk.openapi.yaml +++ b/openapi/testdata/walk.openapi.yaml @@ -105,20 +105,14 @@ paths: in: query description: Fields to include required: false - schema: - type: string - description: Comma-separated list of fields content: - application/json: + multipart/form-data: schema: - type: string - description: JSON string parameter - examples: - include-example: - summary: Include example - description: Example include parameter - value: "name,email" - x-custom: operation-parameter-example-extension + type: object + properties: + profileImage: + type: string + format: binary encoding: profileImage: contentType: image/png @@ -130,6 +124,13 @@ paths: description: Rate limit value x-custom: encoding-header-extension x-custom: encoding-extension + examples: + include-example: + summary: Include example + description: Example include parameter + value: + profileImage: + x-custom: operation-parameter-example-extension x-custom: operation-parameter-content-extension x-custom: operation-parameter-extension requestBody: @@ -324,6 +325,35 @@ paths: application/json: schema: $ref: "#/components/schemas/User" + "202": + description: Batch user creation accepted + content: + application/json: + itemSchema: + $ref: "#/components/schemas/User" + x-custom: batch-create-response-content-extension + "203": + description: Multipart response with prefix encoding + content: + multipart/mixed: + schema: + type: array + prefixItems: + - type: object + - type: string + prefixEncoding: + - contentType: application/json + - contentType: text/plain + x-custom: prefix-encoding-response-content-extension + "204": + description: Multipart streaming response with item encoding + content: + multipart/mixed: + itemSchema: + type: object + itemEncoding: + contentType: application/json + x-custom: item-encoding-response-content-extension "400": description: Invalid user data x-custom: create-user-operation-extension diff --git a/openapi/upgrade.go b/openapi/upgrade.go index 7bdae7a..ec056ac 100644 --- a/openapi/upgrade.go +++ b/openapi/upgrade.go @@ -2,80 +2,373 @@ package openapi import ( "context" + "fmt" "slices" - "strings" + "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" + "github.com/speakeasy-api/openapi/sequencedmap" "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.2.0 to 3.2.1. +func WithUpgradeSameMinorVersion() Option[UpgradeOptions] { return func(uo *UpgradeOptions) { - uo.upgradeSamePatchVersion = true + uo.upgradeSameMinorVersion = true } } -// Upgrade upgrades any OpenAPI 3x document to OpenAPI 3.1.1 (the latest version currently supported). +func WithUpgradeTargetVersion(version string) Option[UpgradeOptions] { + return func(uo *UpgradeOptions) { + uo.targetVersion = version + } +} + +// Upgrade upgrades any OpenAPI 3x document to OpenAPI 3.2.0 (the latest version currently supported). // It currently won't resolve any external references, so only this document itself will be upgraded. func Upgrade(ctx context.Context, doc *OpenAPI, opts ...Option[UpgradeOptions]) (bool, error) { if doc == nil { 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.Parse(doc.OpenAPI) + if err != nil { + return false, err + } + + targetVersion, err := version.Parse(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) + if err := upgradeFrom31To32(ctx, doc, currentVersion, targetVersion); err != nil { + return false, err + } + + _, 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.Parse("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(ctx context.Context, doc *OpenAPI, currentVersion *version.Version, targetVersion *version.Version) error { + if !targetVersion.GreaterThan(*currentVersion) { + return nil + } + + // Upgrade path additionalOperations for non-standard HTTP methods + migrateAdditionalOperations31to32(ctx, doc) + + // 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.Parse("3.2.0") + if err != nil { + 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 +// 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)) + } + } + } +} + +// 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 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 +381,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 +401,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..fb85de1 100644 --- a/openapi/upgrade_test.go +++ b/openapi/upgrade_test.go @@ -7,49 +7,50 @@ 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) { 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, 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", @@ -58,6 +59,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 { @@ -101,6 +110,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 +162,25 @@ func TestUpgrade_NoUpgradeNeeded(t *testing.T) { expectedVersion string }{ { - name: "already_3_1_0_no_options", - version: "3.1.0", + name: "already_3_2_0_no_options", + version: "3.2.0", options: nil, shouldUpgrade: false, - expectedVersion: "3.1.0", + expectedVersion: "3.2.0", }, { - name: "already_3_1_1_no_options", - version: "3.1.1", - options: nil, - shouldUpgrade: false, - expectedVersion: "3.1.1", - }, - { - 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, }, } @@ -238,7 +267,7 @@ components: // Upgrade (no options needed for 3.0.x documents) upgraded, err := openapi.Upgrade(ctx, doc1) require.NoError(t, err, "upgrade should not fail") - assert.Equal(t, openapi.Version, doc1.OpenAPI, "upgraded version should be 3.1.1") + assert.Equal(t, openapi.Version, doc1.OpenAPI, "upgraded version should be 3.2.0") assert.True(t, upgraded, "upgrade should have been performed") // Marshal back @@ -254,7 +283,7 @@ components: doc2, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(marshalledContent)) require.NoError(t, err, "second unmarshal should not fail") require.Empty(t, validationErrs, "second unmarshal should not have validation errors") - assert.Equal(t, openapi.Version, doc2.OpenAPI, "second doc version should be 3.1.1") + assert.Equal(t, openapi.Version, doc2.OpenAPI, "second doc version should be 3.2.0") // Marshal again var buf2 bytes.Buffer @@ -292,3 +321,433 @@ 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") +} + +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) { + t.Helper() + // 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) { + t.Helper() + // 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) { + t.Helper() + // 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) { + t.Helper() + // 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) { + t.Helper() + // 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) { + t.Helper() + // 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 + +//nolint:unparam // version parameter kept for flexibility even though currently only used with "3.1.0" +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 +} diff --git a/openapi/walk.go b/openapi/walk.go index cbffa2c..8c6b4c0 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}) } @@ -540,11 +549,26 @@ func walkMediaType(ctx context.Context, mediaType *MediaType, loc []LocationCont return false } + // Walk through item schema + if !walkSchema(ctx, mediaType.ItemSchema, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "itemSchema"}), openAPI, yield) { + return false + } + // Walk through encoding if !walkEncodings(ctx, mediaType.Encoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "encoding"}), openAPI, yield) { return false } + // Walk through prefixEncoding + if !walkPrefixEncodings(ctx, mediaType.PrefixEncoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "prefixEncoding"}), openAPI, yield) { + return false + } + + // Walk through itemEncoding + if !walkEncoding(ctx, mediaType.ItemEncoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "itemEncoding"}), openAPI, yield) { + return false + } + // Walk through examples if !walkReferencedExamples(ctx, mediaType.Examples, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "examples"}), openAPI, yield) { return false @@ -574,6 +598,26 @@ func walkEncodings(ctx context.Context, encodings *sequencedmap.Map[string, *Enc return true } +// walkPrefixEncodings walks through prefix encodings (array) +func walkPrefixEncodings(ctx context.Context, encodings []*Encoding, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { + if len(encodings) == 0 { + return true + } + + // Get the last loc so we can set the parent index + parentLoc := loc[len(loc)-1] + loc = loc[:len(loc)-1] + + for i, encoding := range encodings { + parentLoc.ParentIndex = pointer.From(i) + + if !walkEncoding(ctx, encoding, append(loc, parentLoc), openAPI, yield) { + return false + } + } + return true +} + // walkEncoding walks through an encoding func walkEncoding(ctx context.Context, encoding *Encoding, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { if encoding == nil { diff --git a/openapi/walk_test.go b/openapi/walk_test.go index 1975fae..b1ce223 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -607,38 +607,92 @@ func TestWalkSchema_Success(t *testing.T) { func TestWalkMediaType_Success(t *testing.T) { t.Parallel() - openAPIDoc, err := loadOpenAPIDocument(t.Context()) - require.NoError(t, err) + tests := []struct { + name string + location string + assertMediaType func(t *testing.T, mt *openapi.MediaType) + }{ + { + name: "media type with schema", + location: "/paths/~1users~1{id}/get/requestBody/content/application~1json", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.NotNil(t, mt.Schema, "Schema should not be nil") + assert.True(t, mt.Schema.IsLeft() || mt.Schema.IsRight(), "Schema should be Left or Right") + assert.Nil(t, mt.ItemSchema, "ItemSchema should be nil when Schema is present") + }, + }, + { + name: "media type with itemSchema", + location: "/paths/~1users/post/responses/202/content/application~1json", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.Nil(t, mt.Schema, "Schema should be nil when ItemSchema is present") + assert.NotNil(t, mt.ItemSchema, "ItemSchema should not be nil") + assert.True(t, mt.ItemSchema.IsLeft() || mt.ItemSchema.IsRight(), "ItemSchema should be Left or Right") + }, + }, + { + name: "media type with prefixEncoding", + location: "/paths/~1users/post/responses/203/content/multipart~1mixed", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.NotNil(t, mt.PrefixEncoding, "PrefixEncoding should not be nil") + assert.Len(t, mt.PrefixEncoding, 2, "PrefixEncoding should have 2 items") + assert.Equal(t, "application/json", mt.PrefixEncoding[0].GetContentTypeValue(), "First prefix encoding should be application/json") + assert.Equal(t, "text/plain", mt.PrefixEncoding[1].GetContentTypeValue(), "Second prefix encoding should be text/plain") + assert.Nil(t, mt.Encoding, "Encoding should be nil when PrefixEncoding is present") + }, + }, + { + name: "media type with itemEncoding", + location: "/paths/~1users/post/responses/204/content/multipart~1mixed", + assertMediaType: func(t *testing.T, mt *openapi.MediaType) { + t.Helper() + assert.NotNil(t, mt.ItemEncoding, "ItemEncoding should not be nil") + assert.Equal(t, "application/json", mt.ItemEncoding.GetContentTypeValue(), "ItemEncoding should be application/json") + assert.Nil(t, mt.Encoding, "Encoding should be nil when ItemEncoding is present") + }, + }, + } - matchedLocations := []string{} - expectedLoc := "/paths/~1users~1{id}/get/requestBody/content/application~1json" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() - for item := range openapi.Walk(t.Context(), openAPIDoc) { - err := item.Match(openapi.Matcher{ - MediaType: func(mt *openapi.MediaType) error { - mediaTypeLoc := string(item.Location.ToJSONPointer()) - matchedLocations = append(matchedLocations, mediaTypeLoc) + openAPIDoc, err := loadOpenAPIDocument(ctx) + require.NoError(t, err) - if mediaTypeLoc == expectedLoc { - assert.NotNil(t, mt.Schema) - // Schema could be either Left (direct schema) or Right (reference) - // Just verify it exists - assert.True(t, mt.Schema.IsLeft() || mt.Schema.IsRight()) + matchedLocations := []string{} + found := false - return walk.ErrTerminate + for item := range openapi.Walk(ctx, openAPIDoc) { + err := item.Match(openapi.Matcher{ + MediaType: func(mt *openapi.MediaType) error { + mediaTypeLoc := string(item.Location.ToJSONPointer()) + matchedLocations = append(matchedLocations, mediaTypeLoc) + + if mediaTypeLoc == tt.location { + tt.assertMediaType(t, mt) + found = true + return walk.ErrTerminate + } + + return nil + }, + }) + + if errors.Is(err, walk.ErrTerminate) { + break } + require.NoError(t, err) + } - return nil - }, + assert.True(t, found, "Should find MediaType at location: %s", tt.location) + assert.Contains(t, matchedLocations, tt.location, "Should visit MediaType at location: %s", tt.location) }) - - if errors.Is(err, walk.ErrTerminate) { - break - } - require.NoError(t, err) } - - assert.Contains(t, matchedLocations, expectedLoc) } func TestWalkComponents_Success(t *testing.T) { @@ -1084,3 +1138,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") +} diff --git a/yml/config.go b/yml/config.go index 556dc2d..0bb9aed 100644 --- a/yml/config.go +++ b/yml/config.go @@ -187,7 +187,7 @@ func inspectData(data []byte) (OutputFormat, int, IndentationStyle) { } func getGlobalStringStyle(doc *yaml.Node, cfg *Config) { - const minSamples = 3 + const minSamples = 5 keyStyles := make([]yaml.Style, 0, minSamples) valueStyles := make([]yaml.Style, 0, minSamples) @@ -253,7 +253,8 @@ func looksLikeNumber(s string) bool { return err == nil } -// mostCommonStyle returns the most frequently occurring style from the provided styles +// mostCommonStyle returns the most frequently occurring style from the provided styles. +// In case of a tie, it returns the style that appears first in the slice for deterministic behavior. func mostCommonStyle(styles []yaml.Style) yaml.Style { if len(styles) == 0 { return 0 @@ -264,10 +265,12 @@ func mostCommonStyle(styles []yaml.Style) yaml.Style { counts[style]++ } - // Find the style with the highest count + // Find the style with the highest count by iterating through the original slice. + // This ensures deterministic results when there are ties - the first occurrence wins. var maxCount int var mostCommon yaml.Style - for style, count := range counts { + for _, style := range styles { + count := counts[style] if count > maxCount { maxCount = count mostCommon = style