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
+
+```
+
-
+
@@ -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
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: