diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2f87de45..e1cbf0d0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,13 +8,11 @@ updates: day: "monday" time: "09:00" open-pull-requests-limit: 10 - reviewers: - - "speakeasy-api/maintainers" assignees: - "speakeasy-api/maintainers" commit-message: - prefix: "deps" - prefix-development: "deps" + prefix: "fix" + prefix-development: "chore" include: "scope" labels: - "dependencies" @@ -42,8 +40,6 @@ updates: day: "monday" time: "09:00" open-pull-requests-limit: 5 - reviewers: - - "speakeasy-api/maintainers" assignees: - "speakeasy-api/maintainers" commit-message: diff --git a/.mise.toml b/.mise.toml index 0f8443fd..7f8c0342 100644 --- a/.mise.toml +++ b/.mise.toml @@ -6,12 +6,12 @@ gotestsum = "latest" [tasks.setup-vscode-symlinks] description = "Create VSCode symlinks for tools not automatically handled by mise-vscode" run = [ - "mkdir -p .vscode/mise-tools", - "ln -sf $(mise exec golangci-lint@2.1.1 -- which golangci-lint) .vscode/mise-tools/golangci-lint", + "mkdir -p .vscode/mise-tools", + "ln -sf $(mise exec golangci-lint@2.1.1 -- which golangci-lint) .vscode/mise-tools/golangci-lint", ] [hooks] postinstall = [ - "mise run setup-vscode-symlinks", - "go install go.uber.org/nilaway/cmd/nilaway@8ad05f0", + "mise run setup-vscode-symlinks", + "go install go.uber.org/nilaway/cmd/nilaway@8ad05f0", ] diff --git a/README.md b/README.md index 58983538..555fc08b 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,22 @@ go install github.com/speakeasy-api/openapi/cmd/openapi@latest The CLI provides three main command groups: - **`openapi spec`** - Commands for working with OpenAPI specifications ([documentation](./openapi/cmd/README.md)) + - `bootstrap` - Create a new OpenAPI document with best practice examples + - `bundle` - Bundle external references into components section + - `clean` - Remove unused components from an OpenAPI specification + - `inline` - Inline all references in an OpenAPI specification + - `join` - Join multiple OpenAPI documents into a single document + - `optimize` - Optimize an OpenAPI specification by deduplicating inline schemas + - `upgrade` - Upgrade an OpenAPI specification to the latest supported version + - `validate` - Validate an OpenAPI specification document + - **`openapi arazzo`** - Commands for working with Arazzo workflow documents ([documentation](./arazzo/cmd/README.md)) + - `validate` - Validate an Arazzo workflow document + - **`openapi overlay`** - Commands for working with OpenAPI overlays ([documentation](./overlay/cmd/README.md)) + - `apply` - Apply an overlay to an OpenAPI specification + - `compare` - Compare two specifications and generate an overlay describing differences + - `validate` - Validate an OpenAPI overlay document #### Quick Examples diff --git a/arazzo/successaction.go b/arazzo/successaction.go index d39f9f06..752de124 100644 --- a/arazzo/successaction.go +++ b/arazzo/successaction.go @@ -160,7 +160,7 @@ func validationActionWorkflowIDAndStepID(ctx context.Context, parentName string, // Get the parent match function from the location parentLoc := item.Location[len(item.Location)-1] - err := parentLoc.Parent(Matcher{ + err := parentLoc.ParentMatchFunc(Matcher{ Workflow: func(workflow *Workflow) error { return item.Match(Matcher{ Step: func(step *Step) error { diff --git a/arazzo/walk.go b/arazzo/walk.go index c3408998..2e4925e0 100644 --- a/arazzo/walk.go +++ b/arazzo/walk.go @@ -68,24 +68,24 @@ func walk(ctx context.Context, arazzo *Arazzo, yield func(WalkItem) bool) { // Visit each of the top level fields in turn populating their location context with field and any key/index information loc := Locations{} - if !walkInfo(ctx, &arazzo.Info, append(loc, LocationContext{Parent: arazzoMatchFunc, ParentField: "info"}), arazzo, yield) { + if !walkInfo(ctx, &arazzo.Info, append(loc, LocationContext{ParentMatchFunc: arazzoMatchFunc, ParentField: "info"}), arazzo, yield) { return } - if !walkSourceDescriptions(ctx, arazzo.SourceDescriptions, append(loc, LocationContext{Parent: arazzoMatchFunc, ParentField: "sourceDescriptions"}), arazzo, yield) { + if !walkSourceDescriptions(ctx, arazzo.SourceDescriptions, append(loc, LocationContext{ParentMatchFunc: arazzoMatchFunc, ParentField: "sourceDescriptions"}), arazzo, yield) { return } - if !walkWorkflows(ctx, arazzo.Workflows, append(loc, LocationContext{Parent: arazzoMatchFunc, ParentField: "workflows"}), arazzo, yield) { + if !walkWorkflows(ctx, arazzo.Workflows, append(loc, LocationContext{ParentMatchFunc: arazzoMatchFunc, ParentField: "workflows"}), arazzo, yield) { return } - if !walkComponents(ctx, arazzo.Components, append(loc, LocationContext{Parent: arazzoMatchFunc, ParentField: "components"}), arazzo, yield) { + if !walkComponents(ctx, arazzo.Components, append(loc, LocationContext{ParentMatchFunc: arazzoMatchFunc, ParentField: "components"}), arazzo, yield) { return } // Visit Arazzo Extensions - yield(WalkItem{Match: getMatchFunc(arazzo.Extensions), Location: append(loc, LocationContext{Parent: arazzoMatchFunc, ParentField: ""}), Arazzo: arazzo}) + yield(WalkItem{Match: getMatchFunc(arazzo.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: arazzoMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkInfo(_ context.Context, info *Info, loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -100,7 +100,7 @@ func walkInfo(_ context.Context, info *Info, loc Locations, arazzo *Arazzo, yiel } // Visit Info Extensions - return yield(WalkItem{Match: getMatchFunc(info.Extensions), Location: append(loc, LocationContext{Parent: infoMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(info.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: infoMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkSourceDescriptions(ctx context.Context, sourceDescriptions []*SourceDescription, loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -134,7 +134,7 @@ func walkSourceDescription(_ context.Context, sd *SourceDescription, loc Locatio } // Visit SourceDescription Extensions - return yield(WalkItem{Match: getMatchFunc(sd.Extensions), Location: append(loc, LocationContext{Parent: sdMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(sd.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: sdMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkWorkflows(ctx context.Context, workflows []*Workflow, loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -168,32 +168,32 @@ func walkWorkflow(ctx context.Context, workflow *Workflow, loc Locations, arazzo } // Walk through parameters - if !walkReusableParameters(ctx, workflow.Parameters, append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: "parameters"}), arazzo, yield) { + if !walkReusableParameters(ctx, workflow.Parameters, append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: "parameters"}), arazzo, yield) { return false } // Walk through inputs schema using oas3 walking - if !walkJSONSchema(ctx, workflow.Inputs, append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: "inputs"}), arazzo, yield) { + if !walkJSONSchema(ctx, workflow.Inputs, append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: "inputs"}), arazzo, yield) { return false } // Walk through steps - if !walkSteps(ctx, workflow.Steps, append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: "steps"}), arazzo, yield) { + if !walkSteps(ctx, workflow.Steps, append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: "steps"}), arazzo, yield) { return false } // Walk through success actions - if !walkReusableSuccessActions(ctx, workflow.SuccessActions, append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: "successActions"}), arazzo, yield) { + if !walkReusableSuccessActions(ctx, workflow.SuccessActions, append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: "successActions"}), arazzo, yield) { return false } // Walk through failure actions - if !walkReusableFailureActions(ctx, workflow.FailureActions, append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: "failureActions"}), arazzo, yield) { + if !walkReusableFailureActions(ctx, workflow.FailureActions, append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: "failureActions"}), arazzo, yield) { return false } // Visit Workflow Extensions - return yield(WalkItem{Match: getMatchFunc(workflow.Extensions), Location: append(loc, LocationContext{Parent: workflowMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(workflow.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: workflowMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkReusableParameters(ctx context.Context, parameters []*ReusableParameter, loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -273,10 +273,10 @@ func convertSchemaLocation(schemaLoc walkpkg.Locations[oas3.SchemaMatchFunc], ba // Convert each oas3 location context to arazzo location context for _, schemaLocCtx := range schemaLoc { result = append(result, LocationContext{ - Parent: convertSchemaMatchFunc(schemaLocCtx.Parent), - ParentField: schemaLocCtx.ParentField, - ParentKey: schemaLocCtx.ParentKey, - ParentIndex: schemaLocCtx.ParentIndex, + ParentMatchFunc: convertSchemaMatchFunc(schemaLocCtx.ParentMatchFunc), + ParentField: schemaLocCtx.ParentField, + ParentKey: schemaLocCtx.ParentKey, + ParentIndex: schemaLocCtx.ParentIndex, }) } @@ -314,22 +314,22 @@ func walkStep(ctx context.Context, step *Step, loc Locations, arazzo *Arazzo, yi } // Walk through parameters - if !walkReusableParameters(ctx, step.Parameters, append(loc, LocationContext{Parent: stepMatchFunc, ParentField: "parameters"}), arazzo, yield) { + if !walkReusableParameters(ctx, step.Parameters, append(loc, LocationContext{ParentMatchFunc: stepMatchFunc, ParentField: "parameters"}), arazzo, yield) { return false } // Walk through success actions - if !walkReusableSuccessActions(ctx, step.OnSuccess, append(loc, LocationContext{Parent: stepMatchFunc, ParentField: "onSuccess"}), arazzo, yield) { + if !walkReusableSuccessActions(ctx, step.OnSuccess, append(loc, LocationContext{ParentMatchFunc: stepMatchFunc, ParentField: "onSuccess"}), arazzo, yield) { return false } // Walk through failure actions - if !walkReusableFailureActions(ctx, step.OnFailure, append(loc, LocationContext{Parent: stepMatchFunc, ParentField: "onFailure"}), arazzo, yield) { + if !walkReusableFailureActions(ctx, step.OnFailure, append(loc, LocationContext{ParentMatchFunc: stepMatchFunc, ParentField: "onFailure"}), arazzo, yield) { return false } // Visit Step Extensions - return yield(WalkItem{Match: getMatchFunc(step.Extensions), Location: append(loc, LocationContext{Parent: stepMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(step.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: stepMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkReusableSuccessActions(ctx context.Context, actions []*ReusableSuccessAction, loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -414,27 +414,27 @@ func walkComponents(ctx context.Context, components *Components, loc Locations, } // Walk through inputs - if !walkComponentInputs(ctx, components.Inputs, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "inputs"}), arazzo, yield) { + if !walkComponentInputs(ctx, components.Inputs, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "inputs"}), arazzo, yield) { return false } // Walk through parameters - if !walkComponentParameters(ctx, components.Parameters, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "parameters"}), arazzo, yield) { + if !walkComponentParameters(ctx, components.Parameters, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "parameters"}), arazzo, yield) { return false } // Walk through success actions - if !walkComponentSuccessActions(ctx, components.SuccessActions, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "successActions"}), arazzo, yield) { + if !walkComponentSuccessActions(ctx, components.SuccessActions, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "successActions"}), arazzo, yield) { return false } // Walk through failure actions - if !walkComponentFailureActions(ctx, components.FailureActions, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "failureActions"}), arazzo, yield) { + if !walkComponentFailureActions(ctx, components.FailureActions, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "failureActions"}), arazzo, yield) { return false } // Visit Components Extensions - return yield(WalkItem{Match: getMatchFunc(components.Extensions), Location: append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(components.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkComponentInputs(ctx context.Context, inputs *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]], loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -487,7 +487,7 @@ func walkParameter(_ context.Context, parameter *Parameter, loc Locations, arazz } // Visit Parameter Extensions - return yield(WalkItem{Match: getMatchFunc(parameter.Extensions), Location: append(loc, LocationContext{Parent: parameterMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(parameter.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parameterMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkComponentSuccessActions(ctx context.Context, actions *sequencedmap.Map[string, *SuccessAction], loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -521,7 +521,7 @@ func walkSuccessAction(_ context.Context, action *SuccessAction, loc Locations, } // Visit SuccessAction Extensions - return yield(WalkItem{Match: getMatchFunc(action.Extensions), Location: append(loc, LocationContext{Parent: actionMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(action.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: actionMatchFunc, ParentField: ""}), Arazzo: arazzo}) } func walkComponentFailureActions(ctx context.Context, actions *sequencedmap.Map[string, *FailureAction], loc Locations, arazzo *Arazzo, yield func(WalkItem) bool) bool { @@ -555,7 +555,7 @@ func walkFailureAction(_ context.Context, action *FailureAction, loc Locations, } // Visit FailureAction Extensions - return yield(WalkItem{Match: getMatchFunc(action.Extensions), Location: append(loc, LocationContext{Parent: actionMatchFunc, ParentField: ""}), Arazzo: arazzo}) + return yield(WalkItem{Match: getMatchFunc(action.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: actionMatchFunc, ParentField: ""}), Arazzo: arazzo}) } type matchHandler[T any] struct { diff --git a/jsonpointer/models.go b/jsonpointer/models.go index 853dec7d..9b8f600f 100644 --- a/jsonpointer/models.go +++ b/jsonpointer/models.go @@ -46,8 +46,6 @@ func navigateModel(sourceVal reflect.Value, currentPart navigationPart, stack [] key := currentPart.unescapeValue() - // First, check if this is an embedded map (anonymous field that implements sequenced map interface) - // This follows the same pattern as in marshaller/unmarshaller.go sourceType := sourceVal.Type() if sourceType.Kind() == reflect.Ptr { sourceType = sourceType.Elem() diff --git a/jsonschema/oas3/schema.go b/jsonschema/oas3/schema.go index 334441d6..e766f730 100644 --- a/jsonschema/oas3/schema.go +++ b/jsonschema/oas3/schema.go @@ -673,7 +673,9 @@ func (s *Schema) IsEqual(other *Schema) bool { // Both nil, continue case s.Type == nil || other.Type == nil: return false - case !s.Type.IsEqual(other.Type): + } + // Compare both type arrays have the same types + if !equalSlices(s.GetType(), other.GetType()) { return false } @@ -806,7 +808,7 @@ func (s *Schema) IsEqual(other *Schema) bool { } // Compare string slices - if !equalStringSlices(s.Required, other.Required) { + if !equalSlices(s.Required, other.Required) { return false } @@ -980,7 +982,7 @@ func equalSequencedMaps(a, b *sequencedmap.Map[string, *JSONSchema[Referenceable return a.IsEqualFunc(b, equalJSONSchemas) } -func equalStringSlices(a, b []string) bool { +func equalSlices[T any](a, b []T) bool { // Treat nil and empty slices as equal if len(a) == 0 && len(b) == 0 { return true @@ -988,8 +990,8 @@ func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } - for i, itemA := range a { - if itemA != b[i] { + for i := range a { + if !reflect.DeepEqual(a[i], b[i]) { return false } } diff --git a/jsonschema/oas3/walk.go b/jsonschema/oas3/walk.go index 20843885..4910c08a 100644 --- a/jsonschema/oas3/walk.go +++ b/jsonschema/oas3/walk.go @@ -69,21 +69,21 @@ func walkSchema(ctx context.Context, schema *JSONSchema[Referenceable], loc walk // Walk through allOf schemas for i, schema := range js.AllOf { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "allOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "allOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { return false } } // Walk through oneOf schemas for i, schema := range js.OneOf { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "oneOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "oneOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { return false } } // Walk through anyOf schemas for i, schema := range js.AnyOf { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "anyOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "anyOf", ParentIndex: pointer.From(i)}), rootSchema, yield) { return false } } @@ -93,105 +93,105 @@ func walkSchema(ctx context.Context, schema *JSONSchema[Referenceable], loc walk discriminatorMatchFunc := getSchemaMatchFunc(js.Discriminator) discriminatorLoc := loc - discriminatorLoc = append(discriminatorLoc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "discriminator"}) + discriminatorLoc = append(discriminatorLoc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "discriminator"}) if !yield(SchemaWalkItem{Match: discriminatorMatchFunc, Location: discriminatorLoc, Schema: rootSchema}) { return false } // Visit discriminator Extensions - if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.Discriminator.Extensions), Location: append(discriminatorLoc, walk.LocationContext[SchemaMatchFunc]{Parent: discriminatorMatchFunc, ParentField: ""}), Schema: rootSchema}) { + if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.Discriminator.Extensions), Location: append(discriminatorLoc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: discriminatorMatchFunc, ParentField: ""}), Schema: rootSchema}) { return false } } // Walk through prefixItems schemas for i, schema := range js.PrefixItems { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "prefixItems", ParentIndex: pointer.From(i)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "prefixItems", ParentIndex: pointer.From(i)}), rootSchema, yield) { return false } } // Visit contains schema - if !walkSchema(ctx, js.Contains, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "contains"}), rootSchema, yield) { + if !walkSchema(ctx, js.Contains, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "contains"}), rootSchema, yield) { return false } // Visit if schema - if !walkSchema(ctx, js.If, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "if"}), rootSchema, yield) { + if !walkSchema(ctx, js.If, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "if"}), rootSchema, yield) { return false } // Visit then schema - if !walkSchema(ctx, js.Then, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "then"}), rootSchema, yield) { + if !walkSchema(ctx, js.Then, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "then"}), rootSchema, yield) { return false } // Visit else schema - if !walkSchema(ctx, js.Else, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "else"}), rootSchema, yield) { + if !walkSchema(ctx, js.Else, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "else"}), rootSchema, yield) { return false } // Walk through dependentSchemas schemas for property, schema := range js.DependentSchemas.All() { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "dependentSchemas", ParentKey: pointer.From(property)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "dependentSchemas", ParentKey: pointer.From(property)}), rootSchema, yield) { return false } } // Walk through patternProperties schemas for property, schema := range js.PatternProperties.All() { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "patternProperties", ParentKey: pointer.From(property)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "patternProperties", ParentKey: pointer.From(property)}), rootSchema, yield) { return false } } // Visit propertyNames schema - if !walkSchema(ctx, js.PropertyNames, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "propertyNames"}), rootSchema, yield) { + if !walkSchema(ctx, js.PropertyNames, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "propertyNames"}), rootSchema, yield) { return false } // Visit unevaluatedItems schema - if !walkSchema(ctx, js.UnevaluatedItems, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "unevaluatedItems"}), rootSchema, yield) { + if !walkSchema(ctx, js.UnevaluatedItems, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "unevaluatedItems"}), rootSchema, yield) { return false } // Visit unevaluatedProperties schema - if !walkSchema(ctx, js.UnevaluatedProperties, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "unevaluatedProperties"}), rootSchema, yield) { + if !walkSchema(ctx, js.UnevaluatedProperties, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "unevaluatedProperties"}), rootSchema, yield) { return false } // Visit items schema - if !walkSchema(ctx, js.Items, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "items"}), rootSchema, yield) { + if !walkSchema(ctx, js.Items, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "items"}), rootSchema, yield) { return false } // Visit not schema - if !walkSchema(ctx, js.Not, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "not"}), rootSchema, yield) { + if !walkSchema(ctx, js.Not, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "not"}), rootSchema, yield) { return false } // Walk through properties schemas for property, schema := range js.Properties.All() { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "properties", ParentKey: pointer.From(property)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "properties", ParentKey: pointer.From(property)}), rootSchema, yield) { return false } } // Walk through $defs schemas for property, schema := range js.Defs.All() { - if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "$defs", ParentKey: pointer.From(property)}), rootSchema, yield) { + if !walkSchema(ctx, schema, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "$defs", ParentKey: pointer.From(property)}), rootSchema, yield) { return false } } // Visit additionalProperties schema - if !walkSchema(ctx, js.AdditionalProperties, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "additionalProperties"}), rootSchema, yield) { + if !walkSchema(ctx, js.AdditionalProperties, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "additionalProperties"}), rootSchema, yield) { return false } // Visit externalDocs - if !walkExternalDocs(ctx, js.ExternalDocs, append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "externalDocs"}), rootSchema, yield) { + if !walkExternalDocs(ctx, js.ExternalDocs, append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "externalDocs"}), rootSchema, yield) { return false } @@ -199,20 +199,20 @@ func walkSchema(ctx context.Context, schema *JSONSchema[Referenceable], loc walk xmlMatchFunc := getSchemaMatchFunc(js.XML) xmlLoc := loc - xmlLoc = append(xmlLoc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: "xml"}) + xmlLoc = append(xmlLoc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: "xml"}) if !yield(SchemaWalkItem{Match: xmlMatchFunc, Location: xmlLoc, Schema: rootSchema}) { return false } // Visit xml Extensions - if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.XML.Extensions), Location: append(xmlLoc, walk.LocationContext[SchemaMatchFunc]{Parent: xmlMatchFunc, ParentField: ""}), Schema: rootSchema}) { + if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.XML.Extensions), Location: append(xmlLoc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: xmlMatchFunc, ParentField: ""}), Schema: rootSchema}) { return false } } // Visit extensions - if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.Extensions), Location: append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: schemaMatchFunc, ParentField: ""}), Schema: rootSchema}) { + if !yield(SchemaWalkItem{Match: getSchemaMatchFunc(js.Extensions), Location: append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: schemaMatchFunc, ParentField: ""}), Schema: rootSchema}) { return false } } @@ -231,7 +231,7 @@ func walkExternalDocs(_ context.Context, externalDocs *ExternalDocumentation, lo return false } - return yield(SchemaWalkItem{Match: getSchemaMatchFunc(externalDocs.Extensions), Location: append(loc, walk.LocationContext[SchemaMatchFunc]{Parent: externalDocsMatchFunc, ParentField: ""}), Schema: rootSchema}) + return yield(SchemaWalkItem{Match: getSchemaMatchFunc(externalDocs.Extensions), Location: append(loc, walk.LocationContext[SchemaMatchFunc]{ParentMatchFunc: externalDocsMatchFunc, ParentField: ""}), Schema: rootSchema}) } type schemaMatchHandler[T any] struct { diff --git a/mise-tasks/test-cli b/mise-tasks/test-cli index 56a75c09..285faa6c 100755 --- a/mise-tasks/test-cli +++ b/mise-tasks/test-cli @@ -25,6 +25,7 @@ echo " ✓ Testing subcommand help..." $CLI spec validate --help > /dev/null $CLI spec upgrade --help > /dev/null $CLI spec inline --help > /dev/null +$CLI spec clean --help > /dev/null $CLI spec bundle --help > /dev/null $CLI spec join --help > /dev/null $CLI spec bootstrap --help > /dev/null @@ -104,6 +105,23 @@ if ! diff -q dist/test/test-bundled-counter.yaml openapi/testdata/inline/bundled exit 1 fi +# Test clean command with known test files +echo " ✓ Testing clean command..." +$CLI spec clean openapi/testdata/clean/clean_input.yaml dist/test/test-cleaned.yaml > /dev/null +$CLI spec clean openapi/testdata/clean/clean_empty_input.yaml dist/test/test-cleaned-empty.yaml > /dev/null + +# Compare clean outputs with expected +echo " ✓ Comparing clean outputs with expected..." +if ! diff -q dist/test/test-cleaned.yaml openapi/testdata/clean/clean_expected.yaml > /dev/null; then + echo " ❌ Clean output differs from expected" + exit 1 +fi + +if ! diff -q dist/test/test-cleaned-empty.yaml openapi/testdata/clean/clean_empty_expected.yaml > /dev/null; then + echo " ❌ Clean empty output differs from expected" + exit 1 +fi + # Test join command with known test files echo " ✓ Testing join command..." $CLI spec join openapi/testdata/join/main.yaml openapi/testdata/join/subdir/second.yaml openapi/testdata/join/third.yaml dist/test/test-joined-counter.yaml > /dev/null @@ -141,6 +159,8 @@ $CLI spec validate dist/test/test-bundled.yaml > /dev/null $CLI spec validate dist/test/test-bundled-counter.yaml > /dev/null $CLI spec validate dist/test/test-joined-counter.yaml > /dev/null $CLI spec validate dist/test/test-joined-filepath.yaml > /dev/null +$CLI spec validate dist/test/test-cleaned.yaml > /dev/null +$CLI spec validate dist/test/test-cleaned-empty.yaml > /dev/null $CLI spec validate dist/test/test-joined-conflicts.yaml > /dev/null # Test arazzo validation with known test files @@ -212,7 +232,7 @@ echo "✅ All CLI integration tests passed!" echo "📊 Test summary:" echo " - Tested all command help outputs" echo " - Validated known good and bad files" -echo " - Tested bootstrap, upgrade, inline, bundle, join commands" +echo " - Tested bootstrap, upgrade, inline, clean, bundle, join commands" echo " - Compared outputs with expected results" echo " - Tested arazzo validation" echo " - Tested overlay validation, apply, and compare" diff --git a/openapi/clean.go b/openapi/clean.go new file mode 100644 index 00000000..3ac23d2c --- /dev/null +++ b/openapi/clean.go @@ -0,0 +1,480 @@ +package openapi + +import ( + "context" + "fmt" + "strings" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/sequencedmap" +) + +// Clean removes unused components from the OpenAPI document. +// It walks through the document to track all referenced components and removes +// any components that are not referenced. Security schemes are handled specially +// as they can be referenced by name in security blocks rather than by $ref. +// +// This function modifies the document in place. +// +// Why use Clean? +// +// - **Reduce document size**: Remove unused component definitions that bloat the specification +// - **Improve clarity**: Keep only the components that are actually used in the API +// - **Optimize tooling performance**: Smaller documents with fewer unused components process faster +// - **Maintain clean specifications**: Prevent accumulation of dead code in API definitions +// - **Prepare for distribution**: Clean up specifications before sharing or publishing +// +// What gets cleaned: +// +// - Unused schemas in components/schemas +// - Unused responses in components/responses +// - Unused parameters in components/parameters +// - Unused examples in components/examples +// - Unused request bodies in components/requestBodies +// - Unused headers in components/headers +// - Unused security schemes in components/securitySchemes (with special handling) +// - Unused links in components/links +// - Unused callbacks in components/callbacks +// - Unused path items in components/pathItems +// +// Special handling for security schemes: +// +// Security schemes can be referenced in two ways: +// 1. By $ref (like other components) +// 2. By name in security requirement objects (global or operation-level) +// +// The Clean function handles both cases correctly. +// +// Example usage: +// +// // Load an OpenAPI document with potentially unused components +// doc := &OpenAPI{...} +// +// // Clean up unused components (modifies doc in place) +// err := Clean(ctx, doc) +// if err != nil { +// return fmt.Errorf("failed to clean document: %w", err) +// } +// +// // doc now has only the components that are actually referenced +// +// Parameters: +// - ctx: Context for the operation +// - doc: The OpenAPI document to clean (modified in place) +// +// Returns: +// - error: Any error that occurred during cleaning +func Clean(ctx context.Context, doc *OpenAPI) error { + if doc == nil || doc.Components == nil { + return nil + } + + // Track referenced components by type and name + referencedComponents := &referencedComponentTracker{ + schemas: make(map[string]bool), + responses: make(map[string]bool), + parameters: make(map[string]bool), + examples: make(map[string]bool), + requestBodies: make(map[string]bool), + headers: make(map[string]bool), + securitySchemes: make(map[string]bool), + links: make(map[string]bool), + callbacks: make(map[string]bool), + pathItems: make(map[string]bool), + } + + // Walk through the document and track all references + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + // Track schema references + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + return trackSchemaReferences(schema, referencedComponents) + }, + // Track component references + ReferencedPathItem: func(ref *ReferencedPathItem) error { + return trackPathItemReference(ref, referencedComponents.pathItems) + }, + ReferencedParameter: func(ref *ReferencedParameter) error { + return trackParameterReference(ref, referencedComponents.parameters) + }, + ReferencedExample: func(ref *ReferencedExample) error { + return trackExampleReference(ref, referencedComponents.examples) + }, + ReferencedRequestBody: func(ref *ReferencedRequestBody) error { + return trackRequestBodyReference(ref, referencedComponents.requestBodies) + }, + ReferencedResponse: func(ref *ReferencedResponse) error { + return trackResponseReference(ref, referencedComponents.responses) + }, + ReferencedHeader: func(ref *ReferencedHeader) error { + return trackHeaderReference(ref, referencedComponents.headers) + }, + ReferencedCallback: func(ref *ReferencedCallback) error { + return trackCallbackReference(ref, referencedComponents.callbacks) + }, + ReferencedLink: func(ref *ReferencedLink) error { + return trackLinkReference(ref, referencedComponents.links) + }, + ReferencedSecurityScheme: func(ref *ReferencedSecurityScheme) error { + return trackSecuritySchemeReference(ref, referencedComponents.securitySchemes) + }, + // Track security requirements (special case for security schemes) + Security: func(req *SecurityRequirement) error { + if req != nil { + for schemeName := range req.All() { + referencedComponents.securitySchemes[schemeName] = true + } + } + return nil + }, + }) + if err != nil { + return fmt.Errorf("failed to track references: %w", err) + } + } + + // Remove unused components + removeUnusedComponentsFromDocument(doc, referencedComponents) + + return nil +} + +// referencedComponentTracker tracks which components are referenced +type referencedComponentTracker struct { + schemas map[string]bool + responses map[string]bool + parameters map[string]bool + examples map[string]bool + requestBodies map[string]bool + headers map[string]bool + securitySchemes map[string]bool + links map[string]bool + callbacks map[string]bool + pathItems map[string]bool +} + +// trackSchemaReferences tracks references within JSON schemas +func trackSchemaReferences(schema *oas3.JSONSchema[oas3.Referenceable], tracker *referencedComponentTracker) error { + if schema == nil { + return nil + } + + // Walk through the schema to find all references + for item := range oas3.Walk(context.Background(), schema) { + err := item.Match(oas3.SchemaMatcher{ + Schema: func(s *oas3.JSONSchema[oas3.Referenceable]) error { + schemaObj := s.GetLeft() + if schemaObj != nil && schemaObj.Ref != nil { + refStr := schemaObj.Ref.String() + componentName := extractComponentName(refStr, "schemas") + if componentName != "" { + tracker.schemas[componentName] = true + } + } + return nil + }, + }) + if err != nil { + return err + } + } + + return nil +} + +// trackPathItemReference tracks a reference to a path item component +func trackPathItemReference(ref *ReferencedPathItem, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "pathItems") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackParameterReference tracks a reference to a parameter component +func trackParameterReference(ref *ReferencedParameter, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "parameters") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackExampleReference tracks a reference to an example component +func trackExampleReference(ref *ReferencedExample, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "examples") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackRequestBodyReference tracks a reference to a request body component +func trackRequestBodyReference(ref *ReferencedRequestBody, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "requestBodies") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackResponseReference tracks a reference to a response component +func trackResponseReference(ref *ReferencedResponse, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "responses") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackHeaderReference tracks a reference to a header component +func trackHeaderReference(ref *ReferencedHeader, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "headers") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackCallbackReference tracks a reference to a callback component +func trackCallbackReference(ref *ReferencedCallback, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "callbacks") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackLinkReference tracks a reference to a link component +func trackLinkReference(ref *ReferencedLink, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "links") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// trackSecuritySchemeReference tracks a reference to a security scheme component +func trackSecuritySchemeReference(ref *ReferencedSecurityScheme, tracker map[string]bool) error { + if ref == nil || !ref.IsReference() { + return nil + } + refStr := ref.GetReference().String() + componentName := extractComponentName(refStr, "securitySchemes") + if componentName != "" { + tracker[componentName] = true + } + return nil +} + +// extractComponentName extracts the component name from a reference string +func extractComponentName(refStr, componentType string) string { + prefix := "#/components/" + componentType + "/" + if strings.HasPrefix(refStr, prefix) { + return strings.TrimPrefix(refStr, prefix) + } + return "" +} + +// removeUnusedComponentsFromDocument removes unused components from the document +func removeUnusedComponentsFromDocument(doc *OpenAPI, tracker *referencedComponentTracker) { + if doc.Components == nil { + return + } + + // Remove unused schemas + if doc.Components.Schemas != nil { + newSchemas := sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + for name, schema := range doc.Components.Schemas.All() { + if tracker.schemas[name] { + newSchemas.Set(name, schema) + } + } + if newSchemas.Len() > 0 { + doc.Components.Schemas = newSchemas + } else { + doc.Components.Schemas = nil + } + } + + // Remove unused responses + if doc.Components.Responses != nil { + newResponses := sequencedmap.New[string, *ReferencedResponse]() + for name, response := range doc.Components.Responses.All() { + if tracker.responses[name] { + newResponses.Set(name, response) + } + } + if newResponses.Len() > 0 { + doc.Components.Responses = newResponses + } else { + doc.Components.Responses = nil + } + } + + // Remove unused parameters + if doc.Components.Parameters != nil { + newParameters := sequencedmap.New[string, *ReferencedParameter]() + for name, parameter := range doc.Components.Parameters.All() { + if tracker.parameters[name] { + newParameters.Set(name, parameter) + } + } + if newParameters.Len() > 0 { + doc.Components.Parameters = newParameters + } else { + doc.Components.Parameters = nil + } + } + + // Remove unused examples + if doc.Components.Examples != nil { + newExamples := sequencedmap.New[string, *ReferencedExample]() + for name, example := range doc.Components.Examples.All() { + if tracker.examples[name] { + newExamples.Set(name, example) + } + } + if newExamples.Len() > 0 { + doc.Components.Examples = newExamples + } else { + doc.Components.Examples = nil + } + } + + // Remove unused request bodies + if doc.Components.RequestBodies != nil { + newRequestBodies := sequencedmap.New[string, *ReferencedRequestBody]() + for name, requestBody := range doc.Components.RequestBodies.All() { + if tracker.requestBodies[name] { + newRequestBodies.Set(name, requestBody) + } + } + if newRequestBodies.Len() > 0 { + doc.Components.RequestBodies = newRequestBodies + } else { + doc.Components.RequestBodies = nil + } + } + + // Remove unused headers + if doc.Components.Headers != nil { + newHeaders := sequencedmap.New[string, *ReferencedHeader]() + for name, header := range doc.Components.Headers.All() { + if tracker.headers[name] { + newHeaders.Set(name, header) + } + } + if newHeaders.Len() > 0 { + doc.Components.Headers = newHeaders + } else { + doc.Components.Headers = nil + } + } + + // Remove unused security schemes + if doc.Components.SecuritySchemes != nil { + newSecuritySchemes := sequencedmap.New[string, *ReferencedSecurityScheme]() + for name, securityScheme := range doc.Components.SecuritySchemes.All() { + if tracker.securitySchemes[name] { + newSecuritySchemes.Set(name, securityScheme) + } + } + if newSecuritySchemes.Len() > 0 { + doc.Components.SecuritySchemes = newSecuritySchemes + } else { + doc.Components.SecuritySchemes = nil + } + } + + // Remove unused links + if doc.Components.Links != nil { + newLinks := sequencedmap.New[string, *ReferencedLink]() + for name, link := range doc.Components.Links.All() { + if tracker.links[name] { + newLinks.Set(name, link) + } + } + if newLinks.Len() > 0 { + doc.Components.Links = newLinks + } else { + doc.Components.Links = nil + } + } + + // Remove unused callbacks + if doc.Components.Callbacks != nil { + newCallbacks := sequencedmap.New[string, *ReferencedCallback]() + for name, callback := range doc.Components.Callbacks.All() { + if tracker.callbacks[name] { + newCallbacks.Set(name, callback) + } + } + if newCallbacks.Len() > 0 { + doc.Components.Callbacks = newCallbacks + } else { + doc.Components.Callbacks = nil + } + } + + // Remove unused path items + if doc.Components.PathItems != nil { + newPathItems := sequencedmap.New[string, *ReferencedPathItem]() + for name, pathItem := range doc.Components.PathItems.All() { + if tracker.pathItems[name] { + newPathItems.Set(name, pathItem) + } + } + if newPathItems.Len() > 0 { + doc.Components.PathItems = newPathItems + } else { + doc.Components.PathItems = nil + } + } + + // If all component sections are empty, remove the components object entirely + if doc.Components.Schemas == nil && + doc.Components.Responses == nil && + doc.Components.Parameters == nil && + doc.Components.Examples == nil && + doc.Components.RequestBodies == nil && + doc.Components.Headers == nil && + doc.Components.SecuritySchemes == nil && + doc.Components.Links == nil && + doc.Components.Callbacks == nil && + doc.Components.PathItems == nil { + doc.Components = nil + } +} diff --git a/openapi/clean_test.go b/openapi/clean_test.go new file mode 100644 index 00000000..10070fcc --- /dev/null +++ b/openapi/clean_test.go @@ -0,0 +1,117 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClean_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/clean/clean_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Clean unused components + err = openapi.Clean(ctx, inputDoc) + require.NoError(t, err) + + // Marshal the cleaned document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/clean/clean_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Cleaned document should match expected output") +} + +func TestClean_RemoveAllUnusedComponents_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document with all unused components + inputFile, err := os.Open("testdata/clean/clean_empty_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Clean unused components + err = openapi.Clean(ctx, inputDoc) + require.NoError(t, err) + + // Marshal the cleaned document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/clean/clean_empty_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Document with all unused components should have components removed entirely") +} + +func TestClean_EmptyDocument_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Test with nil document + err := openapi.Clean(ctx, nil) + require.NoError(t, err) + + // Test with minimal document (no components) + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Empty API", + Version: "1.0.0", + }, + } + + err = openapi.Clean(ctx, doc) + require.NoError(t, err) +} + +func TestClean_NoComponents_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Test with document that has no components section + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "API without components", + Version: "1.0.0", + }, + Paths: &openapi.Paths{}, + } + + err := openapi.Clean(ctx, doc) + require.NoError(t, err) + assert.Nil(t, doc.Components, "Components should remain nil") +} diff --git a/openapi/cmd/README.md b/openapi/cmd/README.md index de5ada0e..9bff7aac 100644 --- a/openapi/cmd/README.md +++ b/openapi/cmd/README.md @@ -11,9 +11,11 @@ OpenAPI specifications define REST APIs in a standard format. These commands hel - [`validate`](#validate) - [`upgrade`](#upgrade) - [`inline`](#inline) + - [`clean`](#clean) - [`bundle`](#bundle) - [Bundle vs Inline](#bundle-vs-inline) - [`join`](#join) + - [`optimize`](#optimize) - [`bootstrap`](#bootstrap) - [Common Options](#common-options) - [Output Formats](#output-formats) @@ -127,6 +129,93 @@ paths: # components section removed (unused after inlining) ``` +### `clean` + +Remove unused components from an OpenAPI specification to create a cleaner, more maintainable document. + +```bash +# Clean to stdout (pipe-friendly) +openapi spec clean ./spec.yaml + +# Clean to specific file +openapi spec clean ./spec.yaml ./cleaned-spec.yaml + +# Clean in-place +openapi spec clean -w ./spec.yaml +``` + +What cleaning does: + +- Removes unused components from all component types (schemas, responses, parameters, etc.) +- Tracks all references throughout the document including `$ref` and security scheme name references +- Preserves all components that are actually used in the specification +- Handles complex reference patterns including circular references and nested components + +**Before cleaning:** + +```yaml +paths: + /users: + get: + responses: + '200': + $ref: "#/components/responses/UserResponse" +components: + schemas: + User: + type: object + properties: + name: + type: string + UnusedSchema: # This will be removed + type: object + properties: + id: + type: string + responses: + UserResponse: + description: User response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + UnusedResponse: # This will be removed + description: Unused response +``` + +**After cleaning:** + +```yaml +paths: + /users: + get: + responses: + '200': + $ref: "#/components/responses/UserResponse" +components: + schemas: + User: + type: object + properties: + name: + type: string + responses: + UserResponse: + description: User response + content: + application/json: + schema: + $ref: "#/components/schemas/User" +# UnusedSchema and UnusedResponse removed +``` + +**Use Clean when:** + +- You want to remove unused components after refactoring +- You're preparing a specification for publication or distribution +- You want to reduce document size and complexity +- You're maintaining a large specification with many components + ### `bundle` Bundle external references into the components section while preserving the reference structure. @@ -233,6 +322,113 @@ Features: - Merges paths, components, and other sections - Preserves all valid OpenAPI structure and references +### `optimize` + +Optimize an OpenAPI specification by finding duplicate inline schemas and extracting them to reusable components. + +```bash +# Interactive optimization (shows schemas, prompts for custom names) +openapi spec optimize ./spec.yaml + +# Non-interactive optimization (auto-generated names) +openapi spec optimize ./spec.yaml --non-interactive + +# Optimize to specific file +openapi spec optimize ./spec.yaml ./optimized-spec.yaml + +# Optimize in-place +openapi spec optimize -w ./spec.yaml +``` + +What optimization does: + +- Finds inline JSON schemas that appear multiple times with identical content +- Replaces duplicate inline schemas with references to newly created components +- Preserves existing component schemas (not modified or replaced) +- Only processes complex schemas (objects, enums, oneOf/allOf/anyOf, conditionals) +- Ignores simple type schemas (string, number, boolean) that don't benefit from extraction + +**Interactive Mode (default):** + +- Shows each duplicate schema in a beautiful formatted code block +- Displays all locations where the schema appears +- Prompts for custom component names +- Allows meaningful naming instead of auto-generated names + +**Non-Interactive Mode (`--non-interactive`):** + +- Uses automatically generated names based on schema content hash +- No user prompts - suitable for automation and CI/CD pipelines +- Generates names like `Schema_da0c4bbf` based on content + +**Before optimization:** + +```yaml +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + id: {type: integer} + name: {type: string} + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: {type: integer} + name: {type: string} +``` + +**After optimization:** + +```yaml +paths: + /users: + get: + responses: + '200': + content: + application/json: + schema: + $ref: "#/components/schemas/User" + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + properties: + id: {type: integer} + name: {type: string} +``` + +**Benefits of optimization:** + +- Reduces document size by eliminating duplicate schema definitions +- Improves maintainability by centralizing schema definitions +- Enhances reusability by making schemas available as components +- Optimizes tooling performance with smaller, cleaner documents +- Follows OpenAPI best practices for schema organization + +**Use Optimize when:** + +- You have inline schemas that are duplicated across your specification +- You want to improve document maintainability and reduce redundancy +- You're preparing specifications for better tooling support +- You want to follow OpenAPI best practices for component reuse + ### `bootstrap` Create a new OpenAPI document with best practice examples. @@ -295,5 +491,11 @@ openapi spec validate ./spec-bundled.yaml ```bash # Create a processing pipeline openapi spec bundle ./spec.yaml | \ +openapi spec clean | \ openapi spec upgrade | \ openapi spec validate + +# Alternative: Clean after bundling to remove unused components +openapi spec bundle ./spec.yaml ./bundled.yaml +openapi spec clean ./bundled.yaml ./clean-bundled.yaml +openapi spec validate ./clean-bundled.yaml diff --git a/openapi/cmd/clean.go b/openapi/cmd/clean.go new file mode 100644 index 00000000..15670571 --- /dev/null +++ b/openapi/cmd/clean.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +var cleanCmd = &cobra.Command{ + Use: "clean [output-file]", + Short: "Remove unused components from an OpenAPI specification", + Long: `Remove unused components from an OpenAPI specification to create a cleaner, more focused document. + +This command analyzes an OpenAPI document to identify which components are actually referenced +and removes any unused components, reducing document size and improving clarity. + +What gets cleaned: +- Unused schemas in components/schemas +- Unused responses in components/responses +- Unused parameters in components/parameters +- Unused examples in components/examples +- Unused request bodies in components/requestBodies +- Unused headers in components/headers +- Unused security schemes in components/securitySchemes (with special handling) +- Unused links in components/links +- Unused callbacks in components/callbacks +- Unused path items in components/pathItems + +Special handling for security schemes: +Security schemes can be referenced in two ways: +1. By $ref (like other components) +2. By name in security requirement objects (global or operation-level) + +The clean command correctly handles both cases and preserves security schemes +that are referenced by name in security blocks. + +Benefits of cleaning: +- Reduce document size by removing dead code +- Improve clarity by keeping only used components +- Optimize tooling performance with smaller documents +- Maintain clean specifications for distribution +- Prepare documents for sharing or publishing + +Output options: +- No output file specified: writes to stdout (pipe-friendly) +- Output file specified: writes to the specified file +- --write flag: writes in-place to the input file`, + Args: cobra.RangeArgs(1, 2), + Run: runClean, +} + +var ( + cleanWriteInPlace bool +) + +func init() { + cleanCmd.Flags().BoolVarP(&cleanWriteInPlace, "write", "w", false, "write result in-place to input file") +} + +func runClean(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + inputFile := args[0] + + var outputFile string + if len(args) > 1 { + outputFile = args[1] + } + + processor, err := NewOpenAPIProcessor(inputFile, outputFile, cleanWriteInPlace) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := cleanOpenAPI(ctx, processor); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func cleanOpenAPI(ctx context.Context, processor *OpenAPIProcessor) error { + // Load the OpenAPI document + doc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + if doc == nil { + return errors.New("failed to parse OpenAPI document: document is nil") + } + + // Report validation errors but continue with cleaning + processor.ReportValidationErrors(validationErrors) + + // Count components before cleaning + componentCounts := countComponents(doc) + + // Perform the cleaning + if err := openapi.Clean(ctx, doc); err != nil { + return fmt.Errorf("failed to clean OpenAPI document: %w", err) + } + + // Count components after cleaning and report + newComponentCounts := countComponents(doc) + reportCleaningResults(processor, componentCounts, newComponentCounts) + + return processor.WriteDocument(ctx, doc) +} + +// countComponents counts the number of components in each section +func countComponents(doc *openapi.OpenAPI) map[string]int { + counts := make(map[string]int) + + if doc.Components == nil { + return counts + } + + if doc.Components.Schemas != nil { + counts["schemas"] = doc.Components.Schemas.Len() + } + if doc.Components.Responses != nil { + counts["responses"] = doc.Components.Responses.Len() + } + if doc.Components.Parameters != nil { + counts["parameters"] = doc.Components.Parameters.Len() + } + if doc.Components.Examples != nil { + counts["examples"] = doc.Components.Examples.Len() + } + if doc.Components.RequestBodies != nil { + counts["requestBodies"] = doc.Components.RequestBodies.Len() + } + if doc.Components.Headers != nil { + counts["headers"] = doc.Components.Headers.Len() + } + if doc.Components.SecuritySchemes != nil { + counts["securitySchemes"] = doc.Components.SecuritySchemes.Len() + } + if doc.Components.Links != nil { + counts["links"] = doc.Components.Links.Len() + } + if doc.Components.Callbacks != nil { + counts["callbacks"] = doc.Components.Callbacks.Len() + } + if doc.Components.PathItems != nil { + counts["pathItems"] = doc.Components.PathItems.Len() + } + + return counts +} + +// reportCleaningResults reports what was cleaned +func reportCleaningResults(processor *OpenAPIProcessor, before, after map[string]int) { + totalBefore := 0 + totalAfter := 0 + removedAny := false + + for componentType := range before { + beforeCount := before[componentType] + afterCount := after[componentType] + totalBefore += beforeCount + totalAfter += afterCount + + if beforeCount > afterCount { + removed := beforeCount - afterCount + processor.PrintInfo(fmt.Sprintf("Removed %d unused %s (%d → %d)", removed, componentType, beforeCount, afterCount)) + removedAny = true + } + } + + if !removedAny { + processor.PrintSuccess("No unused components found - document is already clean") + } else { + totalRemoved := totalBefore - totalAfter + processor.PrintSuccess(fmt.Sprintf("Successfully removed %d unused components (%d → %d total)", totalRemoved, totalBefore, totalAfter)) + } +} + +// GetCleanCommand returns the clean command for external use +func GetCleanCommand() *cobra.Command { + return cleanCmd +} diff --git a/openapi/cmd/optimize.go b/openapi/cmd/optimize.go new file mode 100644 index 00000000..274870bb --- /dev/null +++ b/openapi/cmd/optimize.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + "unicode/utf8" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/marshaller" + "github.com/speakeasy-api/openapi/openapi" + "github.com/spf13/cobra" +) + +const ( + reset = "\x1b[0m" + + // 256-color (widely supported). Tweak these if your terminal theme needs it. + bgBlock = "\x1b[48;5;236m" // dark gray background + fgBlock = "\x1b[38;5;252m" // light foreground for contrast + fgBorder = "\x1b[38;5;240m" // dimmer border color +) + +var optimizeCmd = &cobra.Command{ + Use: "optimize [output-file]", + Short: "Optimize an OpenAPI specification by deduplicating inline schemas", + Long: `Optimize an OpenAPI specification by finding duplicate inline schemas and extracting them to reusable components. + +This command analyzes an OpenAPI document to identify inline JSON schemas that appear multiple times +with identical content and replaces them with references to newly created or existing components. + +What gets optimized: +- Duplicate object schemas with identical properties +- Duplicate enum schemas with identical values +- Duplicate oneOf/allOf/anyOf schemas with identical structure +- Duplicate conditional schemas (if/then/else) +- Duplicate schemas with complex patterns (additionalProperties, patternProperties, etc.) + +What is preserved: +- Existing component schemas (not modified or replaced) +- Simple type schemas (string, number, boolean) - not extracted +- Schemas that appear only once (no duplication) +- Top-level component schemas remain unchanged + +Interactive mode (default): +- Shows each duplicate schema and prompts for a custom name +- Displays the schema content in a formatted code block +- Allows you to provide meaningful names instead of generated ones +- Press Enter to accept the suggested name + +Non-interactive mode (--non-interactive): +- Uses automatically generated names based on schema content hash +- No user prompts - suitable for automation and CI/CD + +Benefits of optimization: +- Reduce document size by eliminating duplicate schema definitions +- Improve maintainability by centralizing schema definitions +- Enhance reusability by making schemas available as components +- Optimize tooling performance with smaller, cleaner documents +- Follow OpenAPI best practices for schema organization + +Output options: +- No output file specified: writes to stdout (pipe-friendly) +- Output file specified: writes to the specified file +- --write flag: writes in-place to the input file + +Examples: + # Interactive optimization (default) + openapi spec optimize api.yaml + + # Non-interactive optimization + openapi spec optimize api.yaml --non-interactive + + # Optimize and write to a new file + openapi spec optimize api.yaml optimized-api.yaml + + # Optimize in-place + openapi spec optimize api.yaml --write`, + Args: cobra.RangeArgs(1, 2), + Run: runOptimize, +} + +var ( + optimizeWriteInPlace bool + optimizeNonInteractive bool +) + +func init() { + optimizeCmd.Flags().BoolVarP(&optimizeWriteInPlace, "write", "w", false, "write result in-place to input file") + optimizeCmd.Flags().BoolVar(&optimizeNonInteractive, "non-interactive", false, "run in non-interactive mode (no prompts)") +} + +func runOptimize(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + inputFile := args[0] + + var outputFile string + if len(args) > 1 { + outputFile = args[1] + } + + processor, err := NewOpenAPIProcessor(inputFile, outputFile, optimizeWriteInPlace) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := optimizeOpenAPI(ctx, processor); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func optimizeOpenAPI(ctx context.Context, processor *OpenAPIProcessor) error { + // Load the OpenAPI document + doc, validationErrors, err := processor.LoadDocument(ctx) + if err != nil { + return err + } + if doc == nil { + return errors.New("failed to parse OpenAPI document: document is nil") + } + + // Report validation errors but continue with optimization + processor.ReportValidationErrors(validationErrors) + + // Count components before optimization + componentCounts := countComponents(doc) + + // Create the appropriate callback based on mode + var nameCallback openapi.OptimizeNameCallback + if !optimizeNonInteractive { + nameCallback = createInteractiveCallback(ctx, processor) + } + + // Perform the optimization + if err := openapi.Optimize(ctx, doc, nameCallback); err != nil { + return fmt.Errorf("failed to optimize OpenAPI document: %w", err) + } + + // Count components after optimization and report + newComponentCounts := countComponents(doc) + reportOptimizationResults(processor, componentCounts, newComponentCounts) + + return processor.WriteDocument(ctx, doc) +} + +// boxedCode renders code in a beautiful box with ANSI colors +func boxedCode(code string) string { + // normalize line endings and expand tabs (optional) + lines := strings.Split(strings.ReplaceAll(code, "\r\n", "\n"), "\n") + for i := range lines { + lines[i] = strings.ReplaceAll(lines[i], "\t", " ") + } + + // measure visible width (runes, good enough for ASCII/YAML) + maxWidth := 0 + for _, l := range lines { + if w := utf8.RuneCountInString(l); w > maxWidth { + maxWidth = w + } + } + + // padding inside the box + padLeft, padRight := 2, 2 + inner := maxWidth + padLeft + padRight + + // build top border + top := fmt.Sprintf("%s┌%s┐%s", + fgBorder, + strings.Repeat("─", inner), + reset, + ) + + // build bottom border + bot := fmt.Sprintf("%s└%s┘%s", + fgBorder, + strings.Repeat("─", inner), + reset, + ) + + // build body lines with background + var b strings.Builder + b.WriteString(top + "\n") + + // optional empty padding row + empty := fmt.Sprintf("%s│%s%s%s%s│%s", + fgBorder, bgBlock+fgBlock, strings.Repeat(" ", inner), reset, fgBorder, reset, + ) + b.WriteString(empty + "\n") + + for _, l := range lines { + // right pad each line to maxWidth width + spaces := strings.Repeat(" ", maxWidth-utf8.RuneCountInString(l)) + b.WriteString(fmt.Sprintf( + "%s│%s%s%s%s%s%s%s│%s\n", + fgBorder, bgBlock+fgBlock, strings.Repeat(" ", padLeft), + l, spaces, strings.Repeat(" ", padRight), reset, fgBorder, reset, + )) + } + + b.WriteString(empty + "\n") + b.WriteString(bot) + + return b.String() +} + +// createInteractiveCallback creates a callback that prompts the user for schema names +func createInteractiveCallback(ctx context.Context, processor *OpenAPIProcessor) openapi.OptimizeNameCallback { + reader := bufio.NewReader(os.Stdin) + + return func(suggestedName, hash string, locations []string, schema *oas3.JSONSchema[oas3.Referenceable]) string { + // Display schema information + fmt.Print("\n" + strings.Repeat("=", 80) + "\n") + fmt.Printf("Found duplicate schema at %d locations:\n", len(locations)) + for i, location := range locations { + fmt.Printf(" %d. %s\n", i+1, location) + } + fmt.Printf("\nSchema content:\n") + fmt.Print(strings.Repeat("-", 40) + "\n") + + // Convert schema to YAML for display using marshaller + if schema != nil && !schema.IsReference() && schema.GetLeft() != nil { + var schemaBuilder strings.Builder + err := marshaller.Marshal(ctx, schema.GetLeft(), &schemaBuilder) + if err == nil { + // Display the schema in a beautiful code block + fmt.Println(boxedCode(schemaBuilder.String())) + } else { + fmt.Printf(" (Unable to display schema: %v)\n", err) + } + } + + fmt.Print(strings.Repeat("-", 40) + "\n") + fmt.Printf("Suggested name: %s\n", suggestedName) + fmt.Printf("Enter custom name (or press Enter to use suggested): ") + + // Read user input + input, err := reader.ReadString('\n') + if err != nil { + processor.PrintInfo(fmt.Sprintf("⚠️ Error reading input: %v, using suggested name", err)) + return suggestedName + } + + // Clean up the input + customName := strings.TrimSpace(input) + if customName == "" { + processor.PrintInfo("Using suggested name: " + suggestedName) + return suggestedName + } + + // Validate the custom name (basic validation) + if !isValidComponentName(customName) { + processor.PrintInfo(fmt.Sprintf("⚠️ Invalid component name '%s', using suggested name: %s", customName, suggestedName)) + return suggestedName + } + + processor.PrintSuccess("Using custom name: " + customName) + return customName + } +} + +// isValidComponentName performs basic validation on component names +func isValidComponentName(name string) bool { + if name == "" { + return false + } + + // Component names should be valid identifiers + // Allow letters, numbers, underscores, and hyphens + for _, r := range name { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && + (r < '0' || r > '9') && r != '_' && r != '-' { + return false + } + } + + return true +} + +// reportOptimizationResults reports what was optimized +func reportOptimizationResults(processor *OpenAPIProcessor, before, after map[string]int) { + beforeSchemas := before["schemas"] + afterSchemas := after["schemas"] + + if afterSchemas > beforeSchemas { + added := afterSchemas - beforeSchemas + processor.PrintSuccess(fmt.Sprintf("Successfully optimized document: added %d new schema components (%d → %d schemas)", added, beforeSchemas, afterSchemas)) + processor.PrintInfo("Duplicate inline schemas have been extracted to reusable components") + } else { + processor.PrintSuccess("Document analyzed - no duplicate schemas found to optimize") + } +} + +// GetOptimizeCommand returns the optimize command for external use +func GetOptimizeCommand() *cobra.Command { + return optimizeCmd +} diff --git a/openapi/cmd/root.go b/openapi/cmd/root.go index 3a2c1918..fe441abd 100644 --- a/openapi/cmd/root.go +++ b/openapi/cmd/root.go @@ -7,7 +7,9 @@ func Apply(rootCmd *cobra.Command) { rootCmd.AddCommand(validateCmd) rootCmd.AddCommand(upgradeCmd) rootCmd.AddCommand(inlineCmd) + rootCmd.AddCommand(cleanCmd) rootCmd.AddCommand(bundleCmd) rootCmd.AddCommand(joinCmd) rootCmd.AddCommand(bootstrapCmd) + rootCmd.AddCommand(optimizeCmd) } diff --git a/openapi/inline.go b/openapi/inline.go index 0a883760..a16650d2 100644 --- a/openapi/inline.go +++ b/openapi/inline.go @@ -173,7 +173,7 @@ func inlineObject[T any](ctx context.Context, obj *T, doc *OpenAPI, opts Resolve } } - parent := item.Location[len(item.Location)-1].Parent + parent := item.Location[len(item.Location)-1].ParentMatchFunc parentIsSchema := false _ = parent(Matcher{ diff --git a/openapi/optimize.go b/openapi/optimize.go new file mode 100644 index 00000000..89e4de97 --- /dev/null +++ b/openapi/optimize.go @@ -0,0 +1,451 @@ +package openapi + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/speakeasy-api/openapi/hashing" + "github.com/speakeasy-api/openapi/jsonpointer" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" + walkpkg "github.com/speakeasy-api/openapi/walk" +) + +// OptimizeNameCallback is a callback function that receives information about a new component +// being created and returns the name to use for that component. +// +// Parameters: +// - suggestedName: The suggested name (e.g., "Schema_abc123def") +// - hash: The hash of the schema content +// - locations: Array of JSON pointers to where the inline schemas were found +// - schema: The JSON schema that will be turned into a component +// +// Returns: +// - The name to use for the new component +type OptimizeNameCallback func(suggestedName string, hash string, locations []string, schema *oas3.JSONSchema[oas3.Referenceable]) string + +// Optimize finds all inline JSON schemas with the same content (hash) and replaces them +// with references to existing or newly created components. +// +// The optimization process: +// 1. Walks through all JSON schemas and collects hashes of inline schemas (except top-level components) +// 2. Only considers complex schemas (object schemas, enums, oneOf/allOf/anyOf, etc.) - not simple types +// 3. For schemas with multiple matches, tries to match them to existing components first +// 4. Creates new components with Schema_{hash} names for unmatched duplicates +// 5. Replaces inline schemas with references to the components +// +// The nameCallback allows customization of component names. If nil, default names are used. +// +// This function modifies the document in place. +// +// Why use Optimize? +// +// - **Reduce document size**: Eliminate duplicate inline schema definitions +// - **Improve maintainability**: Centralize schema definitions in components +// - **Enhance reusability**: Make schemas available for reference throughout the document +// - **Optimize tooling performance**: Reduce parsing overhead from duplicate schemas +// - **Standardize structure**: Follow OpenAPI best practices for schema organization +// +// Example usage: +// +// // Load an OpenAPI document with duplicate inline schemas +// doc := &OpenAPI{...} +// +// // Optimize with default naming +// err := Optimize(ctx, doc, nil) +// if err != nil { +// return fmt.Errorf("failed to optimize document: %w", err) +// } +// +// // Optimize with custom naming +// err = Optimize(ctx, doc, func(suggested, hash string, locations []string, schema *oas3.JSONSchema[oas3.Referenceable]) string { +// return "CustomSchema_" + hash[:8] +// }) +// +// Parameters: +// - ctx: Context for the operation +// - doc: The OpenAPI document to optimize (modified in place) +// - nameCallback: Optional callback to customize component names +// +// Returns: +// - error: Any error that occurred during optimization +func Optimize(ctx context.Context, doc *OpenAPI, nameCallback OptimizeNameCallback) error { + if doc == nil { + return nil + } + + // Initialize components if needed + if doc.Components == nil { + doc.Components = &Components{} + } + if doc.Components.Schemas == nil { + doc.Components.Schemas = sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + } + + // Step 1: Collect all inline schemas and their locations + schemaCollector := &inlineSchemaCollector{ + schemas: sequencedmap.New[string, *schemaInfo](), + existingComponents: sequencedmap.New[string, string](), // hash -> component name + } + + // First, catalog existing components by their hash + for name, schema := range doc.Components.Schemas.All() { + if schema != nil && !schema.IsReference() && schema.GetLeft() != nil { + if isComplexSchema(schema.GetLeft()) { + hash := hashing.Hash(schema.GetLeft()) + schemaCollector.existingComponents.Set(hash, name) + } + } + } + + // Walk through the document to collect inline schemas + for item := range Walk(ctx, doc) { + err := item.Match(Matcher{ + Schema: func(schema *oas3.JSONSchema[oas3.Referenceable]) error { + return schemaCollector.collectSchema(schema, item.Location) + }, + }) + if err != nil { + return fmt.Errorf("failed to collect schemas: %w", err) + } + } + + // Step 2: Collect all individual schema locations and sort by depth (deepest first) + type schemaLocationWithDepth struct { + hash string + location schemaLocation + jsonPointer string + depth int + schema *oas3.JSONSchema[oas3.Referenceable] + } + + var allLocations []schemaLocationWithDepth + + for hash, info := range schemaCollector.schemas.All() { + if len(info.locations) <= 1 { + continue // Skip schemas that appear only once + } + + for i, location := range info.locations { + jsonPtr := info.jsonPointers[i] + depth := strings.Count(jsonPtr, "/") + + allLocations = append(allLocations, schemaLocationWithDepth{ + hash: hash, + location: location, + jsonPointer: jsonPtr, + depth: depth, + schema: info.schema, + }) + } + } + + // Sort by depth (deepest first) + for i := 0; i < len(allLocations); i++ { + for j := i + 1; j < len(allLocations); j++ { + if allLocations[j].depth > allLocations[i].depth { + allLocations[i], allLocations[j] = allLocations[j], allLocations[i] + } + } + } + + // Step 3: Replace all inline occurrences with references (deepest first) + processedHashes := sequencedmap.New[string, string]() // hash -> componentName + + for _, loc := range allLocations { + // Check if we already have a component for this hash + componentName, exists := processedHashes.Get(loc.hash) + if !exists { + // Check existing components first + if existingName, hasExisting := schemaCollector.existingComponents.Get(loc.hash); hasExisting { + componentName = existingName + } else { + // Generate component name but don't create the component yet + suggestedName := "Schema_" + loc.hash[:8] + if nameCallback != nil { + // Get all locations for this hash for the callback + var allJsonPointers []string + for _, otherLoc := range allLocations { + if otherLoc.hash == loc.hash { + allJsonPointers = append(allJsonPointers, otherLoc.jsonPointer) + } + } + componentName = nameCallback(suggestedName, loc.hash, allJsonPointers, loc.schema) + } else { + componentName = suggestedName + } + + // Ensure the name is unique + componentName = ensureUniqueName(componentName, doc.Components.Schemas) + } + processedHashes.Set(loc.hash, componentName) + } + + // Replace this specific location + err := replaceInlineSchema(ctx, doc, loc.location, componentName) + if err != nil { + return fmt.Errorf("failed to replace inline schema for hash %s at %s: %w", loc.hash, loc.jsonPointer, err) + } + } + + // Step 4: Create components after all replacements are done + // Group locations by hash to get the final schema for each component + finalSchemas := sequencedmap.New[string, *oas3.JSONSchema[oas3.Referenceable]]() + for hash, info := range schemaCollector.schemas.All() { + if len(info.locations) > 1 { + finalSchemas.Set(hash, info.schema) + } + } + + // Add the components to the document + for hash, componentName := range processedHashes.All() { + if _, hasExisting := schemaCollector.existingComponents.Get(hash); !hasExisting { + if schema, exists := finalSchemas.Get(hash); exists { + doc.Components.Schemas.Set(componentName, schema.ShallowCopy()) + } + } + } + + return nil +} + +// schemaInfo holds information about a collected schema +type schemaInfo struct { + schema *oas3.JSONSchema[oas3.Referenceable] + locations []schemaLocation + jsonPointers []string +} + +// schemaLocation holds location information for a schema +type schemaLocation struct { + parent any // The actual parent object (extracted from MatchFunc) + locationCtx LocationContext // The location context with MatchFunc +} + +// inlineSchemaCollector collects inline schemas and their locations +type inlineSchemaCollector struct { + schemas *sequencedmap.Map[string, *schemaInfo] // hash -> schema info + existingComponents *sequencedmap.Map[string, string] // hash -> component name +} + +// collectSchema collects a schema if it's an inline complex schema +func (c *inlineSchemaCollector) collectSchema(schema *oas3.JSONSchema[oas3.Referenceable], location Locations) error { + if schema == nil || schema.IsReference() { + return nil + } + + schemaObj := schema.GetLeft() + if schemaObj == nil { + return nil + } + + // Skip if this is a top-level component schema + if isTopLevelComponentSchema(location) { + return nil + } + + // Only collect complex schemas + if !isComplexSchema(schemaObj) { + return nil + } + + // Calculate hash + hash := hashing.Hash(schemaObj) + + // Build JSON pointer for this location + jsonPtr := buildJSONPointer(location) + + // Get parent information for replacement + var parent any + var locationCtx LocationContext + if len(location) > 0 { + lastLoc := location[len(location)-1] + + // Extract the actual parent object from the MatchFunc + var capturedParent any + _ = lastLoc.ParentMatchFunc(Matcher{ + Any: func(obj any) error { + capturedParent = obj + return nil + }, + }) + + locationCtx = lastLoc + parent = capturedParent + } + + // Add to collection + if info, exists := c.schemas.Get(hash); exists { + info.locations = append(info.locations, schemaLocation{ + parent: parent, + locationCtx: locationCtx, + }) + info.jsonPointers = append(info.jsonPointers, jsonPtr) + } else { + c.schemas.Set(hash, &schemaInfo{ + schema: schema, + locations: []schemaLocation{{ + parent: parent, + locationCtx: locationCtx, + }}, + jsonPointers: []string{jsonPtr}, + }) + } + + return nil +} + +// isComplexSchema determines if a schema is complex enough to warrant extraction +func isComplexSchema(schema *oas3.Schema) bool { + if schema == nil { + return false + } + + // Check for complex schema patterns + if len(schema.GetAllOf()) > 0 || + len(schema.GetOneOf()) > 0 || + len(schema.GetAnyOf()) > 0 || + schema.GetNot() != nil { + return true + } + + // Check for enum + if len(schema.GetEnum()) > 0 { + return true + } + + // Check for object schemas with properties + types := schema.GetType() + for _, schemaType := range types { + if schemaType == oas3.SchemaTypeObject { + return true + } + } + + // Check for schemas with multiple types (complex) + // Only consider it complex if there are multiple non-null types + nonNullTypes := 0 + for _, schemaType := range types { + if schemaType != oas3.SchemaTypeNull { + nonNullTypes++ + } + } + if nonNullTypes > 1 { + return true + } + + // Check for schemas with properties (even without explicit object type) + if schema.GetProperties() != nil && schema.GetProperties().Len() > 0 { + return true + } + + // Check for additional complex patterns + if schema.GetAdditionalProperties() != nil || + schema.GetPatternProperties() != nil && schema.GetPatternProperties().Len() > 0 || + schema.GetDependentSchemas() != nil && schema.GetDependentSchemas().Len() > 0 { + return true + } + + // Check for conditional schemas + if schema.GetIf() != nil || schema.GetThen() != nil || schema.GetElse() != nil { + return true + } + + return false +} + +// isTopLevelComponentSchema checks if the location represents a top-level component schema +func isTopLevelComponentSchema(location Locations) bool { + if len(location) < 2 { + return false + } + + // Check if this location is within a top-level component schema + // Pattern: /components/schemas/{name}/... + // Location structure: [components] -> [schemas, key={name}] -> [properties/etc...] + if len(location) >= 2 { + if location[0].ParentField == "components" && + location[1].ParentField == "schemas" && + location[1].ParentKey != nil { + // This is within a top-level component schema + return true + } + } + + return false +} + +// buildJSONPointer builds a JSON pointer string from the location context +func buildJSONPointer(location Locations) string { + var parts []string + + for _, loc := range location { + if loc.ParentField != "" { + parts = append(parts, loc.ParentField) + } + if loc.ParentKey != nil { + parts = append(parts, *loc.ParentKey) + } + if loc.ParentIndex != nil { + parts = append(parts, strconv.Itoa(*loc.ParentIndex)) + } + } + + return string(jsonpointer.PartsToJSONPointer(parts)) +} + +// ensureUniqueName ensures the component name is unique +func ensureUniqueName(baseName string, schemas *sequencedmap.Map[string, *oas3.JSONSchema[oas3.Referenceable]]) string { + name := baseName + counter := 1 + + for { + if _, exists := schemas.Get(name); !exists { + return name + } + name = fmt.Sprintf("%s_%d", baseName, counter) + counter++ + } +} + +// replaceInlineSchema replaces a single inline schema occurrence with a reference +func replaceInlineSchema(_ context.Context, _ *OpenAPI, location schemaLocation, componentName string) error { + // Create the reference + ref := references.Reference("#/components/schemas/" + componentName) + refSchema := oas3.NewJSONSchemaFromReference(ref) + + if location.parent == nil { + return errors.New("parent is nil for location") + } + + // Extract the underlying schema if the parent is a JSONSchema wrapper + parent := location.parent + if jsonSchema, ok := location.parent.(*oas3.JSONSchema[oas3.Referenceable]); ok { + if !jsonSchema.IsLeft() { + return errors.New("expected left side of JSONSchema but got reference") + } + parent = jsonSchema.GetLeft() + } + + // Handle Reference wrapper types that contain inline objects + if refWrapper, ok := parent.(interface { + IsReference() bool + GetObjectAny() any + }); ok { + if !refWrapper.IsReference() { + // This is an inline object, not an actual reference + parent = refWrapper.GetObjectAny() + } + } + + err := walkpkg.SetAtLocation(parent, location.locationCtx, refSchema) + if err != nil { + return fmt.Errorf("failed to set reference at location: %w", err) + } + + return nil +} diff --git a/openapi/optimize_test.go b/openapi/optimize_test.go new file mode 100644 index 00000000..bc54341f --- /dev/null +++ b/openapi/optimize_test.go @@ -0,0 +1,129 @@ +package openapi_test + +import ( + "bytes" + "os" + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOptimize_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/optimize/optimize_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Optimize the document + err = openapi.Optimize(ctx, inputDoc, nil) + require.NoError(t, err) + + // Marshal the optimized document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/optimize/optimize_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Optimized document should match expected output") +} + +func TestOptimize_EmptyDocument_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Test with nil document + err := openapi.Optimize(ctx, nil, nil) + require.NoError(t, err) + + // Test with minimal document (no components) + doc := &openapi.OpenAPI{ + OpenAPI: "3.1.0", + Info: openapi.Info{ + Title: "Empty API", + Version: "1.0.0", + }, + } + + err = openapi.Optimize(ctx, doc, nil) + require.NoError(t, err) +} + +func TestOptimize_WithCallback_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + // Load the input document + inputFile, err := os.Open("testdata/optimize/optimize_input.yaml") + require.NoError(t, err) + defer inputFile.Close() + + inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile) + require.NoError(t, err) + require.Empty(t, validationErrs, "Input document should be valid") + + // Define a mapping of hashes to meaningful names + hashToName := map[string]string{ + "da0c4bbf": "OrderItem", // Order item schema with productId, quantity, price + "22b284ff": "OrderStatus", // Order status enum + "8c71cc44": "EmailNotification", // Email notification schema + "faf3f3c6": "SmsNotification", // SMS notification schema + "09e719fb": "PushNotification", // Push notification schema + "2e1336d2": "Notification", // anyOf notification schema + "4f8aeb0f": "PhysicalProduct", // Physical product schema + "7a715a64": "DigitalProduct", // Digital product schema + "13c3942f": "Product", // oneOf product schema + "a89b7799": "Dimensions", // Dimensions schema + "8054b7a2": "BaseEntity", // Base entity with id, createdAt, updatedAt + "93d337a4": "OrderDetails", // Order details with status and items + "76a1fb01": "Order", // allOf order schema + "5eb90aa8": "Profile", // Profile schema with bio and avatar + } + + // Create a callback that uses our mapping + nameCallback := func(suggestedName, hash string, locations []string, schema *oas3.JSONSchema[oas3.Referenceable]) string { + // Extract the hash from the suggested name (format: "Schema_12345678") + if len(hash) >= 8 { + shortHash := hash[:8] + if customName, exists := hashToName[shortHash]; exists { + return customName + } + } + // Fallback to suggested name if not in our mapping + return suggestedName + } + + // Optimize the document with the callback + err = openapi.Optimize(ctx, inputDoc, nameCallback) + require.NoError(t, err) + + // Marshal the optimized document to YAML + var buf bytes.Buffer + err = openapi.Marshal(ctx, inputDoc, &buf) + require.NoError(t, err) + actualYAML := buf.Bytes() + + // Load the expected output + expectedBytes, err := os.ReadFile("testdata/optimize/optimize_callback_expected.yaml") + require.NoError(t, err) + + // Compare the actual output with expected output + assert.Equal(t, string(expectedBytes), string(actualYAML), "Optimized document should match expected output") +} diff --git a/openapi/reference.go b/openapi/reference.go index 7e5dca27..03a65abb 100644 --- a/openapi/reference.go +++ b/openapi/reference.go @@ -301,6 +301,15 @@ func (r *Reference[T, V, C]) MustGetObject() *T { return obj } +// GetObjectAny returns the referenced object. If this is a reference and its unresolved, this will return nil. +// This is a convenience method for use with the various reflection based utility functions. +func (r *Reference[T, V, C]) GetObjectAny() any { + if r == nil { + return nil + } + return r.GetObject() +} + // GetSummary returns the value of the Summary field. Returns empty string if not set. func (r *Reference[T, V, C]) GetSummary() string { if r == nil || r.Summary == nil { diff --git a/openapi/testdata/clean/clean_empty_expected.yaml b/openapi/testdata/clean/clean_empty_expected.yaml new file mode 100644 index 00000000..03906246 --- /dev/null +++ b/openapi/testdata/clean/clean_empty_expected.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: Empty API + version: 1.0.0 +paths: + /test: + get: + responses: + "200": + description: Success diff --git a/openapi/testdata/clean/clean_empty_input.yaml b/openapi/testdata/clean/clean_empty_input.yaml new file mode 100644 index 00000000..e058eb2e --- /dev/null +++ b/openapi/testdata/clean/clean_empty_input.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Empty API + version: 1.0.0 +paths: + /test: + get: + responses: + "200": + description: Success +components: + schemas: + UnusedSchema: + type: string + parameters: + UnusedParam: + name: unused + in: query + schema: + type: string diff --git a/openapi/testdata/clean/clean_expected.yaml b/openapi/testdata/clean/clean_expected.yaml new file mode 100644 index 00000000..10140a5d --- /dev/null +++ b/openapi/testdata/clean/clean_expected.yaml @@ -0,0 +1,133 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +security: + - ApiKeyAuth: [] +paths: + /users: + get: + security: + - BearerAuth: ["read:users"] + parameters: + - $ref: "#/components/parameters/LimitParam" + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + links: + GetUserByUserId: + $ref: "#/components/links/GetUserByUserId" + "404": + $ref: "#/components/responses/NotFound" + /posts: + post: + requestBody: + $ref: "#/components/requestBodies/PostRequest" + callbacks: + webhook: + $ref: "#/components/callbacks/WebhookCallback" + responses: + "201": + description: Created + headers: + Location: + $ref: "#/components/headers/LocationHeader" + /admin: + $ref: "#/components/pathItems/AdminPathItem" +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + Post: + type: object + properties: + title: + type: string + content: + type: string + Error: + type: object + properties: + message: + type: string + code: + type: integer + responses: + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + parameters: + LimitParam: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + requestBodies: + PostRequest: + description: Post creation request + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + headers: + LocationHeader: + description: Location of the created resource + schema: + type: string + format: uri + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + examples: + UserExample: + summary: Example user + value: + id: 1 + name: John Doe + links: + GetUserByUserId: + description: Link to get user by ID + parameters: + userId: $response.body#/id + callbacks: + WebhookCallback: + "{$request.body#/callbackUrl}": + post: + requestBody: + description: Callback payload + content: + application/json: + schema: + type: object + responses: + "200": + description: Callback received + pathItems: + AdminPathItem: + get: + summary: Admin endpoint + responses: + "200": + description: Admin response diff --git a/openapi/testdata/clean/clean_input.yaml b/openapi/testdata/clean/clean_input.yaml new file mode 100644 index 00000000..d9bfbc26 --- /dev/null +++ b/openapi/testdata/clean/clean_input.yaml @@ -0,0 +1,180 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +security: + - ApiKeyAuth: [] +paths: + /users: + get: + security: + - BearerAuth: ["read:users"] + parameters: + - $ref: "#/components/parameters/LimitParam" + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + user_example: + $ref: "#/components/examples/UserExample" + links: + GetUserByUserId: + $ref: "#/components/links/GetUserByUserId" + "404": + $ref: "#/components/responses/NotFound" + /posts: + post: + requestBody: + $ref: "#/components/requestBodies/PostRequest" + callbacks: + webhook: + $ref: "#/components/callbacks/WebhookCallback" + responses: + "201": + description: Created + headers: + Location: + $ref: "#/components/headers/LocationHeader" + /admin: + $ref: "#/components/pathItems/AdminPathItem" +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + UnusedSchema: + type: string + description: This schema is not referenced anywhere + Post: + type: object + properties: + title: + type: string + content: + type: string + Error: + type: object + properties: + message: + type: string + code: + type: integer + responses: + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + UnusedResponse: + description: This response is not referenced anywhere + content: + text/plain: + schema: + type: string + parameters: + LimitParam: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + UnusedParam: + name: unused + in: query + schema: + type: string + requestBodies: + PostRequest: + description: Post creation request + content: + application/json: + schema: + $ref: "#/components/schemas/Post" + UnusedRequestBody: + description: This request body is not referenced anywhere + content: + application/json: + schema: + type: object + headers: + LocationHeader: + description: Location of the created resource + schema: + type: string + format: uri + UnusedHeader: + description: This header is not referenced anywhere + schema: + type: string + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + UnusedAuth: + type: http + scheme: basic + examples: + UserExample: + summary: Example user + value: + id: 1 + name: John Doe + UnusedExample: + summary: This example is not referenced anywhere + value: + unused: true + links: + GetUserByUserId: + description: Link to get user by ID + parameters: + userId: $response.body#/id + UnusedLink: + description: Unused link + parameters: + id: $response.body#/id + callbacks: + WebhookCallback: + "{$request.body#/callbackUrl}": + post: + requestBody: + description: Callback payload + content: + application/json: + schema: + type: object + responses: + "200": + description: Callback received + UnusedCallback: + "{$request.body#/unusedUrl}": + post: + responses: + "200": + description: Unused callback + pathItems: + AdminPathItem: + get: + summary: Admin endpoint + responses: + "200": + description: Admin response + UnusedPathItem: + get: + summary: This path item is not referenced anywhere + responses: + "200": + description: Unused path item response diff --git a/openapi/testdata/optimize/optimize_actual.yaml b/openapi/testdata/optimize/optimize_actual.yaml new file mode 100644 index 00000000..1b793362 --- /dev/null +++ b/openapi/testdata/optimize/optimize_actual.yaml @@ -0,0 +1,380 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/ExistingUser' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + /products: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + products: + type: array + items: + $ref: '#/components/schemas/Schema_13c3942f' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_13c3942f' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_13c3942f' + /orders: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + $ref: '#/components/schemas/Schema_76a1fb01' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_76a1fb01' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_76a1fb01' + /notifications: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: + $ref: '#/components/schemas/Schema_2e1336d2' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_2e1336d2' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_2e1336d2' + /categories: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + status: + $ref: '#/components/schemas/StatusEnum' + /search: + get: + parameters: + - name: type + in: query + schema: + $ref: '#/components/schemas/StatusEnum' + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + not: + type: object + properties: + deleted: + type: boolean + const: true + /conditional: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + if: + properties: + type: + const: "premium" + then: + properties: + features: + type: array + items: + type: string + maxUsers: + type: integer + else: + properties: + features: + type: array + items: + type: string + maxItems: 3 + maxUsers: + type: integer + maximum: 10 + /mixed-types: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + value: + type: ["string", "number", "boolean"] + metadata: + type: object + properties: + tags: + type: array + items: + type: string + additionalProperties: + type: string + patternProperties: + "^x-": + type: string +components: + schemas: + ExistingUser: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + SimpleString: + type: string + description: A simple string type that should not be optimized + StatusEnum: + type: string + enum: ["active", "inactive", "archived"] + Schema_da0c4bbf: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + Schema_22b284ff: + type: string + enum: + - "pending" + - "processing" + - "shipped" + - "delivered" + - "cancelled" + Schema_a89b7799: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + Schema_7a715a64: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + Schema_8054b7a2: + type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + Schema_93d337a4: + type: object + properties: + status: + $ref: '#/components/schemas/Schema_22b284ff' + items: + type: array + items: + $ref: '#/components/schemas/Schema_da0c4bbf' + Schema_4f8aeb0f: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + $ref: '#/components/schemas/Schema_a89b7799' + Schema_5eb90aa8: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + Schema_8c71cc44: + type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + Schema_faf3f3c6: + type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + Schema_09e719fb: + type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + Schema_13c3942f: + oneOf: + - $ref: '#/components/schemas/Schema_4f8aeb0f' + - $ref: '#/components/schemas/Schema_7a715a64' + Schema_2e1336d2: + anyOf: + - $ref: '#/components/schemas/Schema_8c71cc44' + - $ref: '#/components/schemas/Schema_faf3f3c6' + - $ref: '#/components/schemas/Schema_09e719fb' + Schema_76a1fb01: + allOf: + - $ref: '#/components/schemas/Schema_8054b7a2' + - $ref: '#/components/schemas/Schema_93d337a4' diff --git a/openapi/testdata/optimize/optimize_callback_actual.yaml b/openapi/testdata/optimize/optimize_callback_actual.yaml new file mode 100644 index 00000000..79351aab --- /dev/null +++ b/openapi/testdata/optimize/optimize_callback_actual.yaml @@ -0,0 +1,380 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/ExistingUser' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + /products: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + products: + type: array + items: + $ref: '#/components/schemas/Product' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /orders: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + $ref: '#/components/schemas/Order' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + /notifications: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: + $ref: '#/components/schemas/Notification' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + /categories: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + status: + $ref: '#/components/schemas/StatusEnum' + /search: + get: + parameters: + - name: type + in: query + schema: + $ref: '#/components/schemas/StatusEnum' + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + not: + type: object + properties: + deleted: + type: boolean + const: true + /conditional: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + if: + properties: + type: + const: "premium" + then: + properties: + features: + type: array + items: + type: string + maxUsers: + type: integer + else: + properties: + features: + type: array + items: + type: string + maxItems: 3 + maxUsers: + type: integer + maximum: 10 + /mixed-types: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + value: + type: ["string", "number", "boolean"] + metadata: + type: object + properties: + tags: + type: array + items: + type: string + additionalProperties: + type: string + patternProperties: + "^x-": + type: string +components: + schemas: + ExistingUser: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + SimpleString: + type: string + description: A simple string type that should not be optimized + StatusEnum: + type: string + enum: ["active", "inactive", "archived"] + OrderItem: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + OrderStatus: + type: string + enum: + - "pending" + - "processing" + - "shipped" + - "delivered" + - "cancelled" + Dimensions: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + DigitalProduct: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + BaseEntity: + type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + OrderDetails: + type: object + properties: + status: + $ref: '#/components/schemas/OrderStatus' + items: + type: array + items: + $ref: '#/components/schemas/OrderItem' + PhysicalProduct: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + $ref: '#/components/schemas/Dimensions' + Profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + EmailNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + SmsNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + PushNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + Product: + oneOf: + - $ref: '#/components/schemas/PhysicalProduct' + - $ref: '#/components/schemas/DigitalProduct' + Notification: + anyOf: + - $ref: '#/components/schemas/EmailNotification' + - $ref: '#/components/schemas/SmsNotification' + - $ref: '#/components/schemas/PushNotification' + Order: + allOf: + - $ref: '#/components/schemas/BaseEntity' + - $ref: '#/components/schemas/OrderDetails' diff --git a/openapi/testdata/optimize/optimize_callback_expected.yaml b/openapi/testdata/optimize/optimize_callback_expected.yaml new file mode 100644 index 00000000..79351aab --- /dev/null +++ b/openapi/testdata/optimize/optimize_callback_expected.yaml @@ -0,0 +1,380 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/ExistingUser' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + /products: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + products: + type: array + items: + $ref: '#/components/schemas/Product' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /orders: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + $ref: '#/components/schemas/Order' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + /notifications: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: + $ref: '#/components/schemas/Notification' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + /categories: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + status: + $ref: '#/components/schemas/StatusEnum' + /search: + get: + parameters: + - name: type + in: query + schema: + $ref: '#/components/schemas/StatusEnum' + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + not: + type: object + properties: + deleted: + type: boolean + const: true + /conditional: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + if: + properties: + type: + const: "premium" + then: + properties: + features: + type: array + items: + type: string + maxUsers: + type: integer + else: + properties: + features: + type: array + items: + type: string + maxItems: 3 + maxUsers: + type: integer + maximum: 10 + /mixed-types: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + value: + type: ["string", "number", "boolean"] + metadata: + type: object + properties: + tags: + type: array + items: + type: string + additionalProperties: + type: string + patternProperties: + "^x-": + type: string +components: + schemas: + ExistingUser: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + SimpleString: + type: string + description: A simple string type that should not be optimized + StatusEnum: + type: string + enum: ["active", "inactive", "archived"] + OrderItem: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + OrderStatus: + type: string + enum: + - "pending" + - "processing" + - "shipped" + - "delivered" + - "cancelled" + Dimensions: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + DigitalProduct: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + BaseEntity: + type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + OrderDetails: + type: object + properties: + status: + $ref: '#/components/schemas/OrderStatus' + items: + type: array + items: + $ref: '#/components/schemas/OrderItem' + PhysicalProduct: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + $ref: '#/components/schemas/Dimensions' + Profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + EmailNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + SmsNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + PushNotification: + type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + Product: + oneOf: + - $ref: '#/components/schemas/PhysicalProduct' + - $ref: '#/components/schemas/DigitalProduct' + Notification: + anyOf: + - $ref: '#/components/schemas/EmailNotification' + - $ref: '#/components/schemas/SmsNotification' + - $ref: '#/components/schemas/PushNotification' + Order: + allOf: + - $ref: '#/components/schemas/BaseEntity' + - $ref: '#/components/schemas/OrderDetails' diff --git a/openapi/testdata/optimize/optimize_expected.yaml b/openapi/testdata/optimize/optimize_expected.yaml new file mode 100644 index 00000000..1b793362 --- /dev/null +++ b/openapi/testdata/optimize/optimize_expected.yaml @@ -0,0 +1,380 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/ExistingUser' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/ExistingUser' + /products: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + products: + type: array + items: + $ref: '#/components/schemas/Schema_13c3942f' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_13c3942f' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_13c3942f' + /orders: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + $ref: '#/components/schemas/Schema_76a1fb01' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_76a1fb01' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_76a1fb01' + /notifications: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: + $ref: '#/components/schemas/Schema_2e1336d2' + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_2e1336d2' + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Schema_2e1336d2' + /categories: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + status: + $ref: '#/components/schemas/StatusEnum' + /search: + get: + parameters: + - name: type + in: query + schema: + $ref: '#/components/schemas/StatusEnum' + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + not: + type: object + properties: + deleted: + type: boolean + const: true + /conditional: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + if: + properties: + type: + const: "premium" + then: + properties: + features: + type: array + items: + type: string + maxUsers: + type: integer + else: + properties: + features: + type: array + items: + type: string + maxItems: 3 + maxUsers: + type: integer + maximum: 10 + /mixed-types: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + value: + type: ["string", "number", "boolean"] + metadata: + type: object + properties: + tags: + type: array + items: + type: string + additionalProperties: + type: string + patternProperties: + "^x-": + type: string +components: + schemas: + ExistingUser: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + SimpleString: + type: string + description: A simple string type that should not be optimized + StatusEnum: + type: string + enum: ["active", "inactive", "archived"] + Schema_da0c4bbf: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + Schema_22b284ff: + type: string + enum: + - "pending" + - "processing" + - "shipped" + - "delivered" + - "cancelled" + Schema_a89b7799: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + Schema_7a715a64: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + Schema_8054b7a2: + type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + Schema_93d337a4: + type: object + properties: + status: + $ref: '#/components/schemas/Schema_22b284ff' + items: + type: array + items: + $ref: '#/components/schemas/Schema_da0c4bbf' + Schema_4f8aeb0f: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + $ref: '#/components/schemas/Schema_a89b7799' + Schema_5eb90aa8: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + Schema_8c71cc44: + type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + Schema_faf3f3c6: + type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + Schema_09e719fb: + type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + Schema_13c3942f: + oneOf: + - $ref: '#/components/schemas/Schema_4f8aeb0f' + - $ref: '#/components/schemas/Schema_7a715a64' + Schema_2e1336d2: + anyOf: + - $ref: '#/components/schemas/Schema_8c71cc44' + - $ref: '#/components/schemas/Schema_faf3f3c6' + - $ref: '#/components/schemas/Schema_09e719fb' + Schema_76a1fb01: + allOf: + - $ref: '#/components/schemas/Schema_8054b7a2' + - $ref: '#/components/schemas/Schema_93d337a4' diff --git a/openapi/testdata/optimize/optimize_input.yaml b/openapi/testdata/optimize/optimize_input.yaml new file mode 100644 index 00000000..ff05d63c --- /dev/null +++ b/openapi/testdata/optimize/optimize_input.yaml @@ -0,0 +1,608 @@ +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /users: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + responses: + "201": + description: Created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + /products: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + products: + type: array + items: + oneOf: + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + post: + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + responses: + "201": + description: Created + content: + application/json: + schema: + oneOf: + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "physical" + weight: + type: number + dimensions: + type: object + properties: + length: + type: number + width: + type: number + height: + type: number + - type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + const: "digital" + downloadUrl: + type: string + format: uri + fileSize: + type: integer + /orders: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + orders: + type: array + items: + allOf: + - type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + - type: object + properties: + status: + type: string + enum: + [ + "pending", + "processing", + "shipped", + "delivered", + "cancelled", + ] + items: + type: array + items: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + post: + requestBody: + content: + application/json: + schema: + allOf: + - type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + - type: object + properties: + status: + type: string + enum: + [ + "pending", + "processing", + "shipped", + "delivered", + "cancelled", + ] + items: + type: array + items: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + responses: + "201": + description: Created + content: + application/json: + schema: + allOf: + - type: object + properties: + id: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + - type: object + properties: + status: + type: string + enum: + [ + "pending", + "processing", + "shipped", + "delivered", + "cancelled", + ] + items: + type: array + items: + type: object + properties: + productId: + type: integer + quantity: + type: integer + price: + type: number + /notifications: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: + anyOf: + - type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + post: + requestBody: + content: + application/json: + schema: + anyOf: + - type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + responses: + "201": + description: Created + content: + application/json: + schema: + anyOf: + - type: object + properties: + id: + type: integer + type: + type: string + const: "email" + recipient: + type: string + format: email + subject: + type: string + body: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "sms" + recipient: + type: string + message: + type: string + - type: object + properties: + id: + type: integer + type: + type: string + const: "push" + deviceId: + type: string + title: + type: string + body: + type: string + /categories: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + categories: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + status: + type: string + enum: ["active", "inactive", "archived"] + /search: + get: + parameters: + - name: type + in: query + schema: + type: string + enum: ["active", "inactive", "archived"] + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + not: + type: object + properties: + deleted: + type: boolean + const: true + /conditional: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + if: + properties: + type: + const: "premium" + then: + properties: + features: + type: array + items: + type: string + maxUsers: + type: integer + else: + properties: + features: + type: array + items: + type: string + maxItems: 3 + maxUsers: + type: integer + maximum: 10 + /mixed-types: + get: + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + value: + type: ["string", "number", "boolean"] + metadata: + type: object + properties: + tags: + type: array + items: + type: string + additionalProperties: + type: string + patternProperties: + "^x-": + type: string +components: + schemas: + ExistingUser: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + format: email + profile: + type: object + properties: + bio: + type: string + avatar: + type: string + format: uri + SimpleString: + type: string + description: A simple string type that should not be optimized + StatusEnum: + type: string + enum: ["active", "inactive", "archived"] diff --git a/openapi/walk.go b/openapi/walk.go index 268d238e..bad42ab5 100644 --- a/openapi/walk.go +++ b/openapi/walk.go @@ -127,40 +127,40 @@ func walk(ctx context.Context, openAPI *OpenAPI, yield func(WalkItem) bool) { // Visit each of the top level fields in turn populating their location context with field and any key/index information loc := []LocationContext{} - if !walkInfo(ctx, &openAPI.Info, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "info"}), openAPI, yield) { + if !walkInfo(ctx, &openAPI.Info, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "info"}), openAPI, yield) { return } - if !walkExternalDocs(ctx, openAPI.ExternalDocs, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { + if !walkExternalDocs(ctx, openAPI.ExternalDocs, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { return } - if !walkTags(ctx, openAPI.Tags, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "tags"}), openAPI, yield) { + if !walkTags(ctx, openAPI.Tags, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "tags"}), openAPI, yield) { return } - if !walkServers(ctx, openAPI.Servers, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "servers"}), openAPI, yield) { + if !walkServers(ctx, openAPI.Servers, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "servers"}), openAPI, yield) { return } - if !walkSecurity(ctx, openAPI.Security, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "security"}), openAPI, yield) { + if !walkSecurity(ctx, openAPI.Security, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "security"}), openAPI, yield) { return } - if !walkPaths(ctx, openAPI.Paths, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "paths"}), openAPI, yield) { + if !walkPaths(ctx, openAPI.Paths, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "paths"}), openAPI, yield) { return } - if !walkWebhooks(ctx, openAPI.Webhooks, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "webhooks"}), openAPI, yield) { + if !walkWebhooks(ctx, openAPI.Webhooks, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "webhooks"}), openAPI, yield) { return } - if !walkComponents(ctx, openAPI.Components, append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: "components"}), openAPI, yield) { + if !walkComponents(ctx, openAPI.Components, append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: "components"}), openAPI, yield) { return } // Visit OpenAPI Extensions - yield(WalkItem{Match: getMatchFunc(openAPI.Extensions), Location: append(loc, LocationContext{Parent: openAPIMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + yield(WalkItem{Match: getMatchFunc(openAPI.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: openAPIMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } func walkInfo(_ context.Context, info *Info, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { @@ -179,13 +179,13 @@ func walkInfo(_ context.Context, info *Info, loc []LocationContext, openAPI *Ope contactMatchFunc := getMatchFunc(info.Contact) contactLoc := loc - contactLoc = append(contactLoc, LocationContext{Parent: infoMatchFunc, ParentField: "contact"}) + contactLoc = append(contactLoc, LocationContext{ParentMatchFunc: infoMatchFunc, ParentField: "contact"}) if !yield(WalkItem{Match: contactMatchFunc, Location: contactLoc, OpenAPI: openAPI}) { return false } - if !yield(WalkItem{Match: getMatchFunc(info.Contact.Extensions), Location: append(contactLoc, LocationContext{Parent: contactMatchFunc, ParentField: ""}), OpenAPI: openAPI}) { + if !yield(WalkItem{Match: getMatchFunc(info.Contact.Extensions), Location: append(contactLoc, LocationContext{ParentMatchFunc: contactMatchFunc, ParentField: ""}), OpenAPI: openAPI}) { return false } } @@ -195,19 +195,19 @@ func walkInfo(_ context.Context, info *Info, loc []LocationContext, openAPI *Ope licenseMatchFunc := getMatchFunc(info.License) licenseLoc := loc - licenseLoc = append(licenseLoc, LocationContext{Parent: infoMatchFunc, ParentField: "license"}) + licenseLoc = append(licenseLoc, LocationContext{ParentMatchFunc: infoMatchFunc, ParentField: "license"}) if !yield(WalkItem{Match: licenseMatchFunc, Location: licenseLoc, OpenAPI: openAPI}) { return false } - if !yield(WalkItem{Match: getMatchFunc(info.License.Extensions), Location: append(licenseLoc, LocationContext{Parent: licenseMatchFunc, ParentField: ""}), OpenAPI: openAPI}) { + if !yield(WalkItem{Match: getMatchFunc(info.License.Extensions), Location: append(licenseLoc, LocationContext{ParentMatchFunc: licenseMatchFunc, ParentField: ""}), OpenAPI: openAPI}) { return false } } // Visit Info Extensions - return yield(WalkItem{Match: getMatchFunc(info.Extensions), Location: append(loc, LocationContext{Parent: infoMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(info.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: infoMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkPaths walks through the paths object @@ -223,13 +223,13 @@ func walkPaths(ctx context.Context, paths *Paths, loc []LocationContext, openAPI } for path, pathItem := range paths.All() { - if !walkReferencedPathItem(ctx, pathItem, append(loc, LocationContext{Parent: pathsMatchFunc, ParentKey: pointer.From(path)}), openAPI, yield) { + if !walkReferencedPathItem(ctx, pathItem, append(loc, LocationContext{ParentMatchFunc: pathsMatchFunc, ParentKey: pointer.From(path)}), openAPI, yield) { return false } } // Visit Paths Extensions - return yield(WalkItem{Match: getMatchFunc(paths.Extensions), Location: append(loc, LocationContext{Parent: pathsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(paths.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: pathsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedPathItem walks through a referenced path item @@ -259,24 +259,24 @@ func walkPathItem(ctx context.Context, pathItem *PathItem, parent MatchFunc, loc } // Walk through servers - if !walkServers(ctx, pathItem.Servers, append(loc, LocationContext{Parent: parent, ParentField: "servers"}), openAPI, yield) { + if !walkServers(ctx, pathItem.Servers, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "servers"}), openAPI, yield) { return false } // Walk through parameters - if !walkReferencedParameters(ctx, pathItem.Parameters, append(loc, LocationContext{Parent: parent, ParentField: "parameters"}), openAPI, yield) { + if !walkReferencedParameters(ctx, pathItem.Parameters, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "parameters"}), openAPI, yield) { return false } // Walk through operations for method, operation := range pathItem.All() { - if !walkOperation(ctx, operation, append(loc, LocationContext{Parent: parent, ParentKey: pointer.From(string(method))}), openAPI, yield) { + if !walkOperation(ctx, operation, append(loc, LocationContext{ParentMatchFunc: parent, ParentKey: pointer.From(string(method))}), openAPI, yield) { return false } } // Visit PathItem Extensions - return yield(WalkItem{Match: getMatchFunc(pathItem.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(pathItem.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkOperation walks through an operation @@ -292,42 +292,42 @@ func walkOperation(ctx context.Context, operation *Operation, loc []LocationCont } // Walk through servers - if !walkServers(ctx, operation.Servers, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "servers"}), openAPI, yield) { + if !walkServers(ctx, operation.Servers, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "servers"}), openAPI, yield) { return false } // Walk through security - if !walkSecurity(ctx, operation.Security, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "security"}), openAPI, yield) { + if !walkSecurity(ctx, operation.Security, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "security"}), openAPI, yield) { return false } // Walk through parameters - if !walkReferencedParameters(ctx, operation.Parameters, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "parameters"}), openAPI, yield) { + if !walkReferencedParameters(ctx, operation.Parameters, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "parameters"}), openAPI, yield) { return false } // Walk through request body - if !walkReferencedRequestBody(ctx, operation.RequestBody, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "requestBody"}), openAPI, yield) { + if !walkReferencedRequestBody(ctx, operation.RequestBody, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "requestBody"}), openAPI, yield) { return false } // Walk through responses - if !walkResponses(ctx, operation.Responses, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "responses"}), openAPI, yield) { + if !walkResponses(ctx, operation.Responses, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "responses"}), openAPI, yield) { return false } // Walk through callbacks - if !walkReferencedCallbacks(ctx, operation.Callbacks, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "callbacks"}), openAPI, yield) { + if !walkReferencedCallbacks(ctx, operation.Callbacks, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "callbacks"}), openAPI, yield) { return false } // Walk through external docs - if !walkExternalDocs(ctx, operation.ExternalDocs, append(loc, LocationContext{Parent: operationMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { + if !walkExternalDocs(ctx, operation.ExternalDocs, append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { return false } // Visit Operation Extensions - return yield(WalkItem{Match: getMatchFunc(operation.Extensions), Location: append(loc, LocationContext{Parent: operationMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(operation.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: operationMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedParameters walks through referenced parameters @@ -377,22 +377,22 @@ func walkParameter(ctx context.Context, parameter *Parameter, parent MatchFunc, } // Walk through schema - if !walkSchema(ctx, parameter.Schema, append(loc, LocationContext{Parent: parent, ParentField: "schema"}), openAPI, yield) { + if !walkSchema(ctx, parameter.Schema, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "schema"}), openAPI, yield) { return false } // Walk through content - if !walkMediaTypes(ctx, parameter.Content, append(loc, LocationContext{Parent: parent, ParentField: "content"}), openAPI, yield) { + if !walkMediaTypes(ctx, parameter.Content, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "content"}), openAPI, yield) { return false } // Walk through examples - if !walkReferencedExamples(ctx, parameter.Examples, append(loc, LocationContext{Parent: parent, ParentField: "examples"}), openAPI, yield) { + if !walkReferencedExamples(ctx, parameter.Examples, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "examples"}), openAPI, yield) { return false } // Visit Parameter Extensions - return yield(WalkItem{Match: getMatchFunc(parameter.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(parameter.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedRequestBody walks through a referenced request body @@ -422,12 +422,12 @@ func walkRequestBody(ctx context.Context, requestBody *RequestBody, parent Match } // Walk through content - if !walkMediaTypes(ctx, requestBody.Content, append(loc, LocationContext{Parent: parent, ParentField: "content"}), openAPI, yield) { + if !walkMediaTypes(ctx, requestBody.Content, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "content"}), openAPI, yield) { return false } // Visit RequestBody Extensions - return yield(WalkItem{Match: getMatchFunc(requestBody.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(requestBody.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkResponses walks through responses @@ -443,19 +443,19 @@ func walkResponses(ctx context.Context, responses *Responses, loc []LocationCont } // Walk through default response - if !walkReferencedResponse(ctx, responses.Default, append(loc, LocationContext{Parent: responsesMatchFunc, ParentField: "default"}), openAPI, yield) { + if !walkReferencedResponse(ctx, responses.Default, append(loc, LocationContext{ParentMatchFunc: responsesMatchFunc, ParentField: "default"}), openAPI, yield) { return false } // Walk through status code responses for statusCode, response := range responses.All() { - if !walkReferencedResponse(ctx, response, append(loc, LocationContext{Parent: responsesMatchFunc, ParentKey: pointer.From(statusCode)}), openAPI, yield) { + if !walkReferencedResponse(ctx, response, append(loc, LocationContext{ParentMatchFunc: responsesMatchFunc, ParentKey: pointer.From(statusCode)}), openAPI, yield) { return false } } // Visit Responses Extensions - return yield(WalkItem{Match: getMatchFunc(responses.Extensions), Location: append(loc, LocationContext{Parent: responsesMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(responses.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: responsesMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedResponse walks through a referenced response @@ -485,22 +485,22 @@ func walkResponse(ctx context.Context, response *Response, parent MatchFunc, loc } // Walk through headers - if !walkReferencedHeaders(ctx, response.Headers, append(loc, LocationContext{Parent: parent, ParentField: "headers"}), openAPI, yield) { + if !walkReferencedHeaders(ctx, response.Headers, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "headers"}), openAPI, yield) { return false } // Walk through content - if !walkMediaTypes(ctx, response.Content, append(loc, LocationContext{Parent: parent, ParentField: "content"}), openAPI, yield) { + if !walkMediaTypes(ctx, response.Content, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "content"}), openAPI, yield) { return false } // Walk through links - if !walkReferencedLinks(ctx, response.Links, append(loc, LocationContext{Parent: parent, ParentField: "links"}), openAPI, yield) { + if !walkReferencedLinks(ctx, response.Links, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "links"}), openAPI, yield) { return false } // Visit Response Extensions - return yield(WalkItem{Match: getMatchFunc(response.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(response.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkMediaTypes walks through media types @@ -536,22 +536,22 @@ func walkMediaType(ctx context.Context, mediaType *MediaType, loc []LocationCont } // Walk through schema - if !walkSchema(ctx, mediaType.Schema, append(loc, LocationContext{Parent: mediaTypeMatchFunc, ParentField: "schema"}), openAPI, yield) { + if !walkSchema(ctx, mediaType.Schema, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "schema"}), openAPI, yield) { return false } // Walk through encoding - if !walkEncodings(ctx, mediaType.Encoding, append(loc, LocationContext{Parent: mediaTypeMatchFunc, ParentField: "encoding"}), openAPI, yield) { + if !walkEncodings(ctx, mediaType.Encoding, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "encoding"}), openAPI, yield) { return false } // Walk through examples - if !walkReferencedExamples(ctx, mediaType.Examples, append(loc, LocationContext{Parent: mediaTypeMatchFunc, ParentField: "examples"}), openAPI, yield) { + if !walkReferencedExamples(ctx, mediaType.Examples, append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: "examples"}), openAPI, yield) { return false } // Visit MediaType Extensions - return yield(WalkItem{Match: getMatchFunc(mediaType.Extensions), Location: append(loc, LocationContext{Parent: mediaTypeMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(mediaType.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: mediaTypeMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkEncodings walks through encodings @@ -587,12 +587,12 @@ func walkEncoding(ctx context.Context, encoding *Encoding, loc []LocationContext } // Walk through headers - if !walkReferencedHeaders(ctx, encoding.Headers, append(loc, LocationContext{Parent: encodingMatchFunc, ParentField: "headers"}), openAPI, yield) { + if !walkReferencedHeaders(ctx, encoding.Headers, append(loc, LocationContext{ParentMatchFunc: encodingMatchFunc, ParentField: "headers"}), openAPI, yield) { return false } // Visit Encoding Extensions - return yield(WalkItem{Match: getMatchFunc(encoding.Extensions), Location: append(loc, LocationContext{Parent: encodingMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(encoding.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: encodingMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedHeaders walks through referenced headers @@ -642,22 +642,22 @@ func walkHeader(ctx context.Context, header *Header, parent MatchFunc, loc []Loc } // Walk through schema - if !walkSchema(ctx, header.Schema, append(loc, LocationContext{Parent: parent, ParentField: "schema"}), openAPI, yield) { + if !walkSchema(ctx, header.Schema, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "schema"}), openAPI, yield) { return false } // Walk through content - if !walkMediaTypes(ctx, header.Content, append(loc, LocationContext{Parent: parent, ParentField: "content"}), openAPI, yield) { + if !walkMediaTypes(ctx, header.Content, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "content"}), openAPI, yield) { return false } // Walk through examples - if !walkReferencedExamples(ctx, header.Examples, append(loc, LocationContext{Parent: parent, ParentField: "examples"}), openAPI, yield) { + if !walkReferencedExamples(ctx, header.Examples, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "examples"}), openAPI, yield) { return false } // Visit Header Extensions - return yield(WalkItem{Match: getMatchFunc(header.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(header.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedExamples walks through referenced examples @@ -707,5 +707,5 @@ func walkExample(_ context.Context, example *Example, parent MatchFunc, loc []Lo } // Visit Example Extensions - return yield(WalkItem{Match: getMatchFunc(example.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(example.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } diff --git a/openapi/walk_components.go b/openapi/walk_components.go index eb834777..7a455ab4 100644 --- a/openapi/walk_components.go +++ b/openapi/walk_components.go @@ -21,57 +21,57 @@ func walkComponents(ctx context.Context, components *Components, loc []LocationC } // Walk through schemas - if !walkComponentSchemas(ctx, components.Schemas, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "schemas"}), openAPI, yield) { + if !walkComponentSchemas(ctx, components.Schemas, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "schemas"}), openAPI, yield) { return false } // Walk through responses - if !walkComponentResponses(ctx, components.Responses, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "responses"}), openAPI, yield) { + if !walkComponentResponses(ctx, components.Responses, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "responses"}), openAPI, yield) { return false } // Walk through parameters - if !walkComponentParameters(ctx, components.Parameters, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "parameters"}), openAPI, yield) { + if !walkComponentParameters(ctx, components.Parameters, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "parameters"}), openAPI, yield) { return false } // Walk through examples - if !walkComponentExamples(ctx, components.Examples, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "examples"}), openAPI, yield) { + if !walkComponentExamples(ctx, components.Examples, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "examples"}), openAPI, yield) { return false } // Walk through request bodies - if !walkComponentRequestBodies(ctx, components.RequestBodies, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "requestBodies"}), openAPI, yield) { + if !walkComponentRequestBodies(ctx, components.RequestBodies, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "requestBodies"}), openAPI, yield) { return false } // Walk through headers - if !walkComponentHeaders(ctx, components.Headers, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "headers"}), openAPI, yield) { + if !walkComponentHeaders(ctx, components.Headers, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "headers"}), openAPI, yield) { return false } // Walk through security schemes - if !walkComponentSecuritySchemes(ctx, components.SecuritySchemes, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "securitySchemes"}), openAPI, yield) { + if !walkComponentSecuritySchemes(ctx, components.SecuritySchemes, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "securitySchemes"}), openAPI, yield) { return false } // Walk through links - if !walkComponentLinks(ctx, components.Links, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "links"}), openAPI, yield) { + if !walkComponentLinks(ctx, components.Links, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "links"}), openAPI, yield) { return false } // Walk through callbacks - if !walkComponentCallbacks(ctx, components.Callbacks, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "callbacks"}), openAPI, yield) { + if !walkComponentCallbacks(ctx, components.Callbacks, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "callbacks"}), openAPI, yield) { return false } // Walk through path items - if !walkComponentPathItems(ctx, components.PathItems, append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: "pathItems"}), openAPI, yield) { + if !walkComponentPathItems(ctx, components.PathItems, append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: "pathItems"}), openAPI, yield) { return false } // Visit Components Extensions - return yield(WalkItem{Match: getMatchFunc(components.Extensions), Location: append(loc, LocationContext{Parent: componentsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(components.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: componentsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkComponentSchemas walks through component schemas diff --git a/openapi/walk_operations_test.go b/openapi/walk_operations_test.go index fc9dee68..77436c89 100644 --- a/openapi/walk_operations_test.go +++ b/openapi/walk_operations_test.go @@ -133,7 +133,7 @@ func extractMethodAndPath(locations openapi.Locations) (string, string) { func getParentType(location openapi.LocationContext) string { parentType := "" - _ = location.Parent(openapi.Matcher{ + _ = location.ParentMatchFunc(openapi.Matcher{ Any: func(a any) error { switch a.(type) { case *openapi.Paths: diff --git a/openapi/walk_schema.go b/openapi/walk_schema.go index 2f7a5e6e..15d2618c 100644 --- a/openapi/walk_schema.go +++ b/openapi/walk_schema.go @@ -50,10 +50,10 @@ func convertSchemaLocation(schemaLoc walkpkg.Locations[oas3.SchemaMatchFunc], ba // Convert each oas3 location context to openapi location context for i, schemaLocCtx := range schemaLoc { result[len(baseLoc)+i] = LocationContext{ - Parent: convertSchemaMatchFunc(schemaLocCtx.Parent), - ParentField: schemaLocCtx.ParentField, - ParentKey: schemaLocCtx.ParentKey, - ParentIndex: schemaLocCtx.ParentIndex, + ParentMatchFunc: convertSchemaMatchFunc(schemaLocCtx.ParentMatchFunc), + ParentField: schemaLocCtx.ParentField, + ParentKey: schemaLocCtx.ParentKey, + ParentIndex: schemaLocCtx.ParentIndex, } } diff --git a/openapi/walk_security.go b/openapi/walk_security.go index 2ad460c0..984df2dc 100644 --- a/openapi/walk_security.go +++ b/openapi/walk_security.go @@ -64,12 +64,12 @@ func walkSecurityScheme(ctx context.Context, securityScheme *SecurityScheme, par } // Walk through flows if it's OAuth2 - if !walkOAuthFlows(ctx, securityScheme.Flows, append(loc, LocationContext{Parent: parent, ParentField: "flows"}), openAPI, yield) { + if !walkOAuthFlows(ctx, securityScheme.Flows, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "flows"}), openAPI, yield) { return false } // Visit SecurityScheme Extensions - return yield(WalkItem{Match: getMatchFunc(securityScheme.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(securityScheme.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkOAuthFlows walks through OAuth flows @@ -85,24 +85,24 @@ func walkOAuthFlows(ctx context.Context, flows *OAuthFlows, loc []LocationContex } // Walk through individual flows - if !walkOAuthFlow(ctx, flows.Implicit, append(loc, LocationContext{Parent: flowsMatchFunc, ParentField: "implicit"}), openAPI, yield) { + if !walkOAuthFlow(ctx, flows.Implicit, append(loc, LocationContext{ParentMatchFunc: flowsMatchFunc, ParentField: "implicit"}), openAPI, yield) { return false } - if !walkOAuthFlow(ctx, flows.Password, append(loc, LocationContext{Parent: flowsMatchFunc, ParentField: "password"}), openAPI, yield) { + if !walkOAuthFlow(ctx, flows.Password, append(loc, LocationContext{ParentMatchFunc: flowsMatchFunc, ParentField: "password"}), openAPI, yield) { return false } - if !walkOAuthFlow(ctx, flows.ClientCredentials, append(loc, LocationContext{Parent: flowsMatchFunc, ParentField: "clientCredentials"}), openAPI, yield) { + if !walkOAuthFlow(ctx, flows.ClientCredentials, append(loc, LocationContext{ParentMatchFunc: flowsMatchFunc, ParentField: "clientCredentials"}), openAPI, yield) { return false } - if !walkOAuthFlow(ctx, flows.AuthorizationCode, append(loc, LocationContext{Parent: flowsMatchFunc, ParentField: "authorizationCode"}), openAPI, yield) { + if !walkOAuthFlow(ctx, flows.AuthorizationCode, append(loc, LocationContext{ParentMatchFunc: flowsMatchFunc, ParentField: "authorizationCode"}), openAPI, yield) { return false } // Visit OAuthFlows Extensions - return yield(WalkItem{Match: getMatchFunc(flows.Extensions), Location: append(loc, LocationContext{Parent: flowsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(flows.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: flowsMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } // walkOAuthFlow walks through an OAuth flow @@ -118,5 +118,5 @@ func walkOAuthFlow(_ context.Context, flow *OAuthFlow, loc []LocationContext, op } // Visit OAuthFlow Extensions - return yield(WalkItem{Match: getMatchFunc(flow.Extensions), Location: append(loc, LocationContext{Parent: flowMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(flow.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: flowMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } diff --git a/openapi/walk_tags_servers.go b/openapi/walk_tags_servers.go index 1a1a2254..c2ebbc32 100644 --- a/openapi/walk_tags_servers.go +++ b/openapi/walk_tags_servers.go @@ -33,12 +33,12 @@ func walkTag(ctx context.Context, tag *Tag, loc []LocationContext, openAPI *Open return false } - if !walkExternalDocs(ctx, tag.ExternalDocs, append(loc, LocationContext{Parent: tagMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { + if !walkExternalDocs(ctx, tag.ExternalDocs, append(loc, LocationContext{ParentMatchFunc: tagMatchFunc, ParentField: "externalDocs"}), openAPI, yield) { return false } // Visit Tag Extensions - return yield(WalkItem{Match: getMatchFunc(tag.Extensions), Location: append(loc, LocationContext{Parent: tagMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(tag.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: tagMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } func walkServers(ctx context.Context, servers []*Server, loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { @@ -67,11 +67,11 @@ func walkServer(ctx context.Context, server *Server, loc []LocationContext, open return false } - if !walkVariables(ctx, server.Variables, append(loc, LocationContext{Parent: serverMatchFunc, ParentField: "variables"}), openAPI, yield) { + if !walkVariables(ctx, server.Variables, append(loc, LocationContext{ParentMatchFunc: serverMatchFunc, ParentField: "variables"}), openAPI, yield) { return false } - return yield(WalkItem{Match: getMatchFunc(server.Extensions), Location: append(loc, LocationContext{Parent: serverMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(server.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: serverMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } func walkVariables(ctx context.Context, variables *sequencedmap.Map[string, *ServerVariable], loc []LocationContext, openAPI *OpenAPI, yield func(WalkItem) bool) bool { @@ -95,5 +95,5 @@ func walkVariable(_ context.Context, variable *ServerVariable, loc []LocationCon return false } - return yield(WalkItem{Match: getMatchFunc(variable.Extensions), Location: append(loc, LocationContext{Parent: variableMatchFunc, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(variable.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: variableMatchFunc, ParentField: ""}), OpenAPI: openAPI}) } diff --git a/openapi/walk_webhooks_callbacks.go b/openapi/walk_webhooks_callbacks.go index d9ad6a43..2d7fcd35 100644 --- a/openapi/walk_webhooks_callbacks.go +++ b/openapi/walk_webhooks_callbacks.go @@ -74,12 +74,12 @@ func walkLink(ctx context.Context, link *Link, parent MatchFunc, loc []LocationC } // Walk through server - if !walkServer(ctx, link.Server, append(loc, LocationContext{Parent: parent, ParentField: "server"}), openAPI, yield) { + if !walkServer(ctx, link.Server, append(loc, LocationContext{ParentMatchFunc: parent, ParentField: "server"}), openAPI, yield) { return false } // Visit Link Extensions - return yield(WalkItem{Match: getMatchFunc(link.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(link.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } // walkReferencedCallbacks walks through referenced callbacks @@ -130,11 +130,11 @@ func walkCallback(ctx context.Context, callback *Callback, parent MatchFunc, loc // Walk through callback path items for expression, pathItem := range callback.All() { - if !walkReferencedPathItem(ctx, pathItem, append(loc, LocationContext{Parent: parent, ParentKey: pointer.From(string(expression))}), openAPI, yield) { + if !walkReferencedPathItem(ctx, pathItem, append(loc, LocationContext{ParentMatchFunc: parent, ParentKey: pointer.From(string(expression))}), openAPI, yield) { return false } } // Visit Callback Extensions - return yield(WalkItem{Match: getMatchFunc(callback.Extensions), Location: append(loc, LocationContext{Parent: parent, ParentField: ""}), OpenAPI: openAPI}) + return yield(WalkItem{Match: getMatchFunc(callback.Extensions), Location: append(loc, LocationContext{ParentMatchFunc: parent, ParentField: ""}), OpenAPI: openAPI}) } diff --git a/walk/locations.go b/walk/locations.go index b224d2e3..9f9df2cc 100644 --- a/walk/locations.go +++ b/walk/locations.go @@ -17,10 +17,10 @@ const ( // LocationContext represents the context of where an element is located within its parent. // It uses generics to work with different MatchFunc types from different packages. type LocationContext[T any] struct { - Parent T - ParentField string - ParentKey *string - ParentIndex *int + ParentMatchFunc T + ParentField string + ParentKey *string + ParentIndex *int } // Locations represents a slice of location contexts that can be converted to a JSON pointer. diff --git a/walk/set.go b/walk/set.go new file mode 100644 index 00000000..2408f201 --- /dev/null +++ b/walk/set.go @@ -0,0 +1,198 @@ +package walk + +import ( + "errors" + "fmt" + "reflect" + + "github.com/speakeasy-api/openapi/internal/interfaces" +) + +type model interface { + GetCoreAny() any + SetCoreAny(core any) +} + +func SetAtLocation[T any](parent any, l LocationContext[T], value any) error { + parentVal := reflect.ValueOf(parent) + if parentVal.Kind() != reflect.Ptr { + return errors.New("parent value must be a pointer") + } + originalPtr := parentVal + parentVal = parentVal.Elem() + if parentVal.Kind() == reflect.Interface { + parentVal = parentVal.Elem() + } + + switch parentVal.Kind() { + case reflect.Map: + return setAtMap(parentVal, l, value) + case reflect.Slice: + return setAtSlice(parentVal, l, value) + case reflect.Struct: + return setAtStruct(originalPtr, l, value) + default: + return fmt.Errorf("expected map, slice, or struct, got %s", parentVal.Kind()) + } +} + +func setAtMap[T any](parentVal reflect.Value, l LocationContext[T], value any) error { + if l.ParentKey == nil { + return errors.New("parent key is nil") + } + + parentVal.SetMapIndex(reflect.ValueOf(*l.ParentKey), reflect.ValueOf(value)) + + return nil +} + +func setAtSlice[T any](parentVal reflect.Value, l LocationContext[T], value any) error { + if l.ParentIndex == nil { + return errors.New("parent index is nil") + } + + parentVal.Index(*l.ParentIndex).Set(reflect.ValueOf(value)) + + return nil +} + +func setAtStruct[T any](parentVal reflect.Value, l LocationContext[T], value any) error { + // Ensure we have a model interface + if !parentVal.CanInterface() { + return errors.New("parent value cannot be interfaced") + } + + // Check if this struct implements SequencedMapInterface and we have a ParentKey + // This means we're setting a key in the sequenced map + if l.ParentKey != nil { + if sequencedmap, ok := parentVal.Interface().(interfaces.SequencedMapInterface); ok { + return setAtSequencedMap(sequencedmap, l, value) + } + } + + // Otherwise, check if this is a model interface and try to set a field + modelInterface, isModel := parentVal.Interface().(model) + if isModel { + return setAtField(parentVal, modelInterface, l, value) + } + + return errors.New("expected model interface or sequenced map interface") +} + +func setAtField[T any](parentVal reflect.Value, model model, l LocationContext[T], value any) error { + // Get the core model + coreAny := model.GetCoreAny() + if coreAny == nil { + return errors.New("core model is nil") + } + + coreVal := reflect.ValueOf(coreAny) + if coreVal.Kind() == reflect.Ptr { + if coreVal.IsNil() { + return errors.New("core model pointer is nil") + } + coreVal = coreVal.Elem() + } + + if coreVal.Kind() != reflect.Struct { + return fmt.Errorf("expected core model to be struct, got %s", coreVal.Kind()) + } + + coreType := coreVal.Type() + if coreType.Kind() == reflect.Ptr { + coreType = coreType.Elem() + } + + // Handle case where we have both ParentField and ParentKey/ParentIndex + // This means we need to find the field first, then recursively call SetAtLocation on that field + if l.ParentField != "" && (l.ParentKey != nil || l.ParentIndex != nil) { + // Find the field by ParentField in the core model to get the correct index + coreFieldIndex := -1 + for i := 0; i < coreType.NumField(); i++ { + field := coreType.Field(i) + if !field.IsExported() { + continue + } + + keyTag := field.Tag.Get("key") + if keyTag == l.ParentField { + coreFieldIndex = i + break + } + } + + if coreFieldIndex == -1 { + return fmt.Errorf("field %s not found in core model", l.ParentField) + } + + // Use the same index to get the field from the high-level model + highLevelVal := parentVal.Elem() + field := highLevelVal.Field(coreFieldIndex) + if !field.CanSet() { + return fmt.Errorf("field %s is not settable", l.ParentField) + } + + // Create a new LocationContext with just the key/index and recursively call SetAtLocation + // We need to get a pointer to the actual field, not a copy from Interface() + if !field.CanAddr() { + return fmt.Errorf("field %s cannot be addressed", l.ParentField) + } + + var fieldPtr interface{} + if field.Kind() == reflect.Ptr { + // If the field is already a pointer, use it directly + fieldPtr = field.Interface() + } else { + // If the field is not a pointer, get its address + fieldPtr = field.Addr().Interface() + } + + newLocationCtx := LocationContext[T]{ + ParentKey: l.ParentKey, + ParentIndex: l.ParentIndex, + } + + return SetAtLocation(fieldPtr, newLocationCtx, value) + } + + if l.ParentField == "" { + return errors.New("parent field is unset") + } + + coreFieldIndex := -1 + for i := 0; i < coreType.NumField(); i++ { + field := coreType.Field(i) + if !field.IsExported() { + continue + } + + keyTag := field.Tag.Get("key") + if keyTag == l.ParentField { + coreFieldIndex = i + break + } + } + + if coreFieldIndex == -1 { + return fmt.Errorf("field %s not found in core model", l.ParentField) + } + + field := parentVal.Elem().Field(coreFieldIndex) + if !field.CanSet() { + return fmt.Errorf("field %s is not settable", l.ParentField) + } + + field.Set(reflect.ValueOf(value)) + + return nil +} + +func setAtSequencedMap[T any](sequencedmap interfaces.SequencedMapInterface, l LocationContext[T], value any) error { + if l.ParentKey == nil { + return errors.New("parent key is nil") + } + + sequencedmap.SetAny(*l.ParentKey, value) + + return nil +} diff --git a/walk/set_test.go b/walk/set_test.go new file mode 100644 index 00000000..32f88436 --- /dev/null +++ b/walk/set_test.go @@ -0,0 +1,284 @@ +package walk_test + +import ( + "reflect" + "testing" + + "github.com/speakeasy-api/openapi/marshaller/tests" + "github.com/speakeasy-api/openapi/pointer" + "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/speakeasy-api/openapi/walk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetAtLocation_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parent any + location walk.LocationContext[string] + value any + validate func(t *testing.T, parent any) + }{ + { + name: "set value in native Go map with string key", + parent: map[string]string{"existing": "value"}, + location: walk.LocationContext[string]{ + ParentKey: pointer.From("newKey"), + }, + value: "newValue", + validate: func(t *testing.T, parent any) { + t.Helper() + m := parent.(map[string]string) + assert.Equal(t, "newValue", m["newKey"], "new key should be set") + assert.Equal(t, "value", m["existing"], "existing key should remain") + }, + }, + { + name: "overwrite existing value in native Go map", + parent: map[string]string{"key": "oldValue"}, + location: walk.LocationContext[string]{ + ParentKey: pointer.From("key"), + }, + value: "newValue", + validate: func(t *testing.T, parent any) { + t.Helper() + m := parent.(map[string]string) + assert.Equal(t, "newValue", m["key"], "key should be overwritten") + }, + }, + { + name: "set value in native Go slice", + parent: []string{"first", "second", "third"}, + location: walk.LocationContext[string]{ + ParentIndex: pointer.From(1), + }, + value: "modified", + validate: func(t *testing.T, parent any) { + t.Helper() + s := parent.([]string) + assert.Equal(t, []string{"first", "modified", "third"}, s, "slice should be modified at index") + }, + }, + { + name: "set value in sequencedmap", + parent: sequencedmap.New(sequencedmap.NewElem("key1", "value1")), + location: walk.LocationContext[string]{ + ParentKey: pointer.From("key2"), + }, + value: "value2", + validate: func(t *testing.T, parent any) { + t.Helper() + sm := parent.(*sequencedmap.Map[string, string]) + val, ok := sm.Get("key2") + assert.True(t, ok, "new key should exist") + assert.Equal(t, "value2", val, "new key should have correct value") + + val1, ok1 := sm.Get("key1") + assert.True(t, ok1, "existing key should remain") + assert.Equal(t, "value1", val1, "existing key should have correct value") + }, + }, + { + name: "overwrite value in sequencedmap", + parent: sequencedmap.New(sequencedmap.NewElem("key1", "oldValue")), + location: walk.LocationContext[string]{ + ParentKey: pointer.From("key1"), + }, + value: "newValue", + validate: func(t *testing.T, parent any) { + t.Helper() + sm := parent.(*sequencedmap.Map[string, string]) + val, ok := sm.Get("key1") + assert.True(t, ok, "key should exist") + assert.Equal(t, "newValue", val, "key should be overwritten") + }, + }, + { + name: "set field in normal model", + parent: &tests.TestPrimitiveHighModel{ + StringField: "original", + }, + location: walk.LocationContext[string]{ + ParentField: "stringField", + }, + value: "modified", + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestPrimitiveHighModel) + assert.Equal(t, "modified", model.StringField, "field should be modified") + }, + }, + { + name: "set pointer field in normal model", + parent: &tests.TestPrimitiveHighModel{ + StringPtrField: pointer.From("original"), + }, + location: walk.LocationContext[string]{ + ParentField: "stringPtrField", + }, + value: pointer.From("modified"), + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestPrimitiveHighModel) + require.NotNil(t, model.StringPtrField, "pointer field should not be nil") + assert.Equal(t, "modified", *model.StringPtrField, "pointer field should be modified") + }, + }, + { + name: "set bool field in normal model", + parent: &tests.TestPrimitiveHighModel{ + BoolField: false, + }, + location: walk.LocationContext[string]{ + ParentField: "boolField", + }, + value: true, + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestPrimitiveHighModel) + assert.True(t, model.BoolField, "bool field should be modified") + }, + }, + { + name: "set int field in normal model", + parent: &tests.TestPrimitiveHighModel{ + IntField: 42, + }, + location: walk.LocationContext[string]{ + ParentField: "intField", + }, + value: 100, + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestPrimitiveHighModel) + assert.Equal(t, 100, model.IntField, "int field should be modified") + }, + }, + { + name: "set float field in normal model", + parent: &tests.TestPrimitiveHighModel{ + Float64Field: 3.14, + }, + location: walk.LocationContext[string]{ + ParentField: "float64Field", + }, + value: 2.71, + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestPrimitiveHighModel) + assert.InDelta(t, 2.71, model.Float64Field, 0.001, "float field should be modified") + }, + }, + { + name: "set value in embedded sequencedmap model", + parent: &tests.TestEmbeddedMapHighModel{ + Map: *sequencedmap.New(sequencedmap.NewElem("existing", "value")), + }, + location: walk.LocationContext[string]{ + ParentKey: pointer.From("newKey"), + }, + value: "newValue", + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestEmbeddedMapHighModel) + val, ok := model.Get("newKey") + assert.True(t, ok, "new key should exist in embedded map") + assert.Equal(t, "newValue", val, "new key should have correct value") + + existingVal, existingOk := model.Get("existing") + assert.True(t, existingOk, "existing key should remain") + assert.Equal(t, "value", existingVal, "existing key should have correct value") + }, + }, + { + name: "set field in embedded sequencedmap model with fields", + parent: &tests.TestEmbeddedMapWithFieldsHighModel{ + Map: *sequencedmap.New[string, *tests.TestPrimitiveHighModel](), + NameField: "original", + }, + location: walk.LocationContext[string]{ + ParentField: "name", + }, + value: "modified", + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestEmbeddedMapWithFieldsHighModel) + assert.Equal(t, "modified", model.NameField, "name field should be modified") + }, + }, + { + name: "set map entry in embedded sequencedmap model with fields", + parent: &tests.TestEmbeddedMapWithFieldsHighModel{ + Map: *sequencedmap.New[string, *tests.TestPrimitiveHighModel](), + NameField: "test", + }, + location: walk.LocationContext[string]{ + ParentKey: pointer.From("mapKey"), + }, + value: &tests.TestPrimitiveHighModel{StringField: "mapValue"}, + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestEmbeddedMapWithFieldsHighModel) + val, ok := model.Get("mapKey") + assert.True(t, ok, "map key should exist") + require.NotNil(t, val, "map value should not be nil") + assert.Equal(t, "mapValue", val.StringField, "map value should be correct") + }, + }, + { + name: "set value in field map using both ParentField and ParentKey", + parent: &tests.TestComplexHighModel{ + MapPrimitiveField: sequencedmap.New(sequencedmap.NewElem("existing", "value")), + }, + location: walk.LocationContext[string]{ + ParentField: "mapField", + ParentKey: pointer.From("newKey"), + }, + value: "newValue", + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestComplexHighModel) + val, ok := model.MapPrimitiveField.Get("newKey") + assert.True(t, ok, "new key should exist in map field") + assert.Equal(t, "newValue", val, "new key should be set in map field") + + existingVal, existingOk := model.MapPrimitiveField.Get("existing") + assert.True(t, existingOk, "existing key should remain in map field") + assert.Equal(t, "value", existingVal, "existing key should have correct value") + }, + }, + { + name: "set value in field slice using both ParentField and ParentIndex", + parent: &tests.TestComplexHighModel{ + ArrayField: []string{"first", "second", "third"}, + }, + location: walk.LocationContext[string]{ + ParentField: "arrayField", + ParentIndex: pointer.From(1), + }, + value: "modified", + validate: func(t *testing.T, parent any) { + t.Helper() + model := parent.(*tests.TestComplexHighModel) + assert.Equal(t, []string{"first", "modified", "third"}, model.ArrayField, "slice element should be modified at index in field") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + parent := tt.parent + if reflect.TypeOf(parent).Kind() != reflect.Ptr { + parent = &tt.parent + } + + err := walk.SetAtLocation(parent, tt.location, tt.value) + require.NoError(t, err, "SetAtLocation should not return error") + tt.validate(t, tt.parent) + }) + } +}