diff --git a/cmd/openapi/transform.go b/cmd/openapi/transform.go index 4cd3b891..721c19f1 100644 --- a/cmd/openapi/transform.go +++ b/cmd/openapi/transform.go @@ -14,7 +14,7 @@ import ( var transformCmd = &model.CommandGroup{ Usage: "transform", Short: "Transform an OpenAPI spec using a well-defined function", - Commands: []model.Command{removeUnusedCmd, filterOperationsCmd, cleanupCmd, formatCmd, convertSwaggerCmd}, + Commands: []model.Command{removeUnusedCmd, filterOperationsCmd, cleanupCmd, formatCmd, convertSwaggerCmd, normalizeCmd}, } type basicFlagsI struct { @@ -97,6 +97,37 @@ var formatCmd = &model.ExecutableCommand[basicFlagsI]{ Flags: basicFlags, } +var normalizeCmd = &model.ExecutableCommand[normalizeFlags]{ + Usage: "normalize", + Short: "Normalize an OpenAPI document to be more human-readable", + Run: runNormalize, + Flags: append(basicFlags, []flag.Flag{ + flag.BooleanFlag{ + Name: "prefixItems", + Description: "Normalize prefixItems to be a simple string", + DefaultValue: false, + }, + }...), +} + +type normalizeFlags struct { + Schema string `json:"schema"` + Out string `json:"out"` + PrefixItems bool `json:"prefixItems"` +} + +func runNormalize(ctx context.Context, flags normalizeFlags) error { + out, yamlOut, err := setupOutput(ctx, flags.Out) + defer out.Close() + if err != nil { + return err + } + + return transform.NormalizeDocument(ctx, flags.Schema, transform.NormalizeOptions{ + PrefixItems: flags.PrefixItems, + }, yamlOut, out) +} + func runRemoveUnused(ctx context.Context, flags basicFlagsI) error { out, yamlOut, err := setupOutput(ctx, flags.Out) defer out.Close() diff --git a/integration/resources/normalize-input.yaml b/integration/resources/normalize-input.yaml new file mode 100644 index 00000000..62ac7998 --- /dev/null +++ b/integration/resources/normalize-input.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Normalize-Test + version: 1.0.0 +paths: + /test: + get: + responses: + "200": + content: + application/json: + schema: + prefixItems: + - type: string + - type: string + minItems: 1 + maxItems: 1 + type: array +components: + schemas: + test: + prefixItems: + - type: string + - type: string + minItems: 1 + maxItems: 1 + type: array diff --git a/integration/resources/normalize-output.yaml b/integration/resources/normalize-output.yaml new file mode 100644 index 00000000..b36ed79e --- /dev/null +++ b/integration/resources/normalize-output.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: + title: Normalize-Test + version: 1.0.0 +paths: + /test: + get: + responses: + "200": + content: + application/json: + schema: + type: string +components: + schemas: + test: + type: string \ No newline at end of file diff --git a/internal/transform/normalize.go b/internal/transform/normalize.go new file mode 100644 index 00000000..48a33dca --- /dev/null +++ b/internal/transform/normalize.go @@ -0,0 +1,130 @@ +package transform + +import ( + "context" + "fmt" + "io" + + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/speakeasy-api/speakeasy-core/openapi" + "gopkg.in/yaml.v3" +) + +type normalizeArgs struct { + schemaPath string + normalizeOptions NormalizeOptions +} + +func NormalizeDocument(ctx context.Context, schemaPath string, normalizeOptions NormalizeOptions, yamlOut bool, w io.Writer) error { + return transformer[normalizeArgs]{ + schemaPath: schemaPath, + transformFn: Normalize, + w: w, + jsonOut: !yamlOut, + args: normalizeArgs{ + schemaPath: schemaPath, + normalizeOptions: normalizeOptions, + }, + }.Do(ctx) +} + +func NormalizeFromReader(ctx context.Context, schema io.Reader, schemaPath string, normalizeOptions NormalizeOptions, w io.Writer, yamlOut bool) error { + return transformer[normalizeArgs]{ + r: schema, + schemaPath: schemaPath, + transformFn: Normalize, + w: w, + jsonOut: !yamlOut, + args: normalizeArgs{ + schemaPath: schemaPath, + normalizeOptions: normalizeOptions, + }, + }.Do(ctx) +} + +func Normalize(ctx context.Context, doc libopenapi.Document, model *libopenapi.DocumentModel[v3.Document], args normalizeArgs) (libopenapi.Document, *libopenapi.DocumentModel[v3.Document], error) { + root := model.Index.GetRootNode() + + walkAndNormalizeDocument(root, args.normalizeOptions) + + updatedDoc, err := yaml.Marshal(root) + if err != nil { + return doc, model, fmt.Errorf("failed to marshal document: %w", err) + } + + docNew, model, err := openapi.Load(updatedDoc, doc.GetConfiguration().BasePath) + if err != nil { + return doc, model, fmt.Errorf("failed to reload document: %w", err) + } + + return *docNew, model, nil +} + +type NormalizeOptions struct { + PrefixItems bool +} + +func walkAndNormalizeDocument(node *yaml.Node, options NormalizeOptions) { + + switch node.Kind { + case yaml.MappingNode, yaml.DocumentNode: + for _, child := range node.Content { + if options.PrefixItems && child.Value == "prefixItems" { + normalizePrefixItems(node) + break + } + walkAndNormalizeDocument(child, options) + } + case yaml.SequenceNode: + for _, child := range node.Content { + walkAndNormalizeDocument(child, options) + } + } +} + +func removeKeys(mapNode *yaml.Node, keys ...string) { + keysToRemove := make(map[string]struct{}) + for _, k := range keys { + keysToRemove[k] = struct{}{} + } + + newContent := make([]*yaml.Node, 0, len(mapNode.Content)) + for i := 0; i < len(mapNode.Content); i += 2 { + kNode := mapNode.Content[i] + vNode := mapNode.Content[i+1] + if _, found := keysToRemove[kNode.Value]; !found { + // Keep this pair + newContent = append(newContent, kNode, vNode) + } + } + mapNode.Content = newContent +} + +func addKeyValue(mapNode *yaml.Node, key, value string) { + // Create a key node + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + + // Create a value node + valNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } + + // Append them to the map content + mapNode.Content = append(mapNode.Content, keyNode, valNode) +} + +// Take the given yaml OpenAPI node, remove the prefixItems key change the type key to string, remove the minItems and maxItems keys +func normalizePrefixItems(node *yaml.Node) { + + keysToRemove := []string{"prefixItems", "minItems", "maxItems", "type"} + removeKeys(node, keysToRemove...) + + addKeyValue(node, "type", "string") +} diff --git a/internal/transform/normalize_test.go b/internal/transform/normalize_test.go new file mode 100644 index 00000000..77b25b31 --- /dev/null +++ b/internal/transform/normalize_test.go @@ -0,0 +1,99 @@ +package transform + +import ( + "bufio" + "bytes" + "context" + "os" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestNormalize(t *testing.T) { + + // Create a buffer to store the normalized spec + var testInput bytes.Buffer + var testOutput bytes.Buffer + + // Call FormatDocument to format the spec + err := NormalizeDocument(context.Background(), "../../integration/resources/normalize-input.yaml", NormalizeOptions{ + PrefixItems: true, + }, true, &testInput) + require.NoError(t, err) + + // Parse the normalized spec + normalizedDoc, err := libopenapi.NewDocument(testInput.Bytes()) + require.NoError(t, err) + + // Check that the normalized spec is valid + _, errors := normalizedDoc.BuildV3Model() + require.Empty(t, errors) + + // Open the spec we expect to see to compare + file, err := os.Open("../../integration/resources/normalize-output.yaml") + require.NoError(t, err) + defer file.Close() + + // Read the expected spec into a buffer + reader := bufio.NewReader(file) + testOutput.ReadFrom(reader) + require.NoError(t, err) + + var actual yaml.Node + var expected yaml.Node + + err = yaml.Unmarshal(testInput.Bytes(), &actual) + require.NoError(t, err) + + err = yaml.Unmarshal(testOutput.Bytes(), &expected) + require.NoError(t, err) + + // Require the pre-normalized spec matches the expected spec + require.Equal(t, expected, actual) +} + +func TestNormalizeNoPrefixItems(t *testing.T) { + + // Create a buffer to store the normalized spec + var testInput bytes.Buffer + var testOutput bytes.Buffer + + // Call FormatDocument to format the spec + err := NormalizeDocument(context.Background(), "../../integration/resources/normalize-input.yaml", NormalizeOptions{ + PrefixItems: false, + }, true, &testInput) + require.NoError(t, err) + + // Parse the normalized spec + normalizedDoc, err := libopenapi.NewDocument(testInput.Bytes()) + require.NoError(t, err) + + // Check that the normalized spec is valid + _, errors := normalizedDoc.BuildV3Model() + require.Empty(t, errors) + + // Open the spec we expect to see to compare + file, err := os.Open("../../integration/resources/normalize-input.yaml") + require.NoError(t, err) + defer file.Close() + + // Read the expected spec into a buffer + reader := bufio.NewReader(file) + testOutput.ReadFrom(reader) + require.NoError(t, err) + + var actual yaml.Node + var expected yaml.Node + + err = yaml.Unmarshal(testInput.Bytes(), &actual) + require.NoError(t, err) + + err = yaml.Unmarshal(testOutput.Bytes(), &expected) + require.NoError(t, err) + + // Require the pre-normalized spec matches the expected spec + require.Equal(t, expected, actual) +}