From a8758e0254bac910e2164d36e94b49e0add77a22 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Tue, 18 Aug 2026 17:58:13 +0100 Subject: [PATCH] feat(openapi): add sorted format style --- cmd/openapi/transform.go | 44 ++++++-- cmd/openapi/transform_test.go | 116 +++++++++++++++++++++ go.mod | 4 +- go.sum | 8 +- integration/resources/sorted.json | 1 + integration/resources/sorted.yaml | 31 ++++++ integration/workflow_test.go | 79 ++++++++++++++ internal/run/transform.go | 19 +++- pkg/transform/format_sorted.go | 52 ++++++++++ pkg/transform/format_sorted_test.go | 153 ++++++++++++++++++++++++++++ 10 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 cmd/openapi/transform_test.go create mode 100644 integration/resources/sorted.json create mode 100644 integration/resources/sorted.yaml create mode 100644 pkg/transform/format_sorted.go create mode 100644 pkg/transform/format_sorted_test.go diff --git a/cmd/openapi/transform.go b/cmd/openapi/transform.go index bcb2e59df..7e579cf24 100644 --- a/cmd/openapi/transform.go +++ b/cmd/openapi/transform.go @@ -2,8 +2,11 @@ package openapi import ( "context" + "fmt" "os" + "strings" + "github.com/speakeasy-api/sdk-gen-config/workflow" charm_internal "github.com/speakeasy-api/speakeasy/internal/charm" "github.com/speakeasy-api/speakeasy/internal/model" "github.com/speakeasy-api/speakeasy/internal/model/flag" @@ -89,12 +92,25 @@ var cleanupCmd = &model.ExecutableCommand[basicFlagsI]{ Flags: basicFlags, } -var formatCmd = &model.ExecutableCommand[basicFlagsI]{ +type formatFlags struct { + Schema string `json:"schema"` + Out string `json:"out"` + Style string `json:"style"` +} + +var formatCmd = &model.ExecutableCommand[formatFlags]{ Usage: "format", - Short: "Format an OpenAPI document to be more human-readable", - Long: "Format an OpenAPI document to be more human-readable by sorting the keys in a specific order best suited for each level in the OpenAPI specification", - Run: runFormat, - Flags: basicFlags, + Short: "Format an OpenAPI document using a selected output style", + Long: "Format an OpenAPI document using either the readable style or the sorted style. " + + "The sorted style accepts JSON or YAML, emits deterministic JSON, and reorders arrays under required, parameters, oneOf, anyOf, and allOf; " + + "those array orders can affect generated method signatures, union ordering, or order-sensitive tooling.", + Run: runFormat, + Flags: append(basicFlags, flag.EnumFlag{ + Name: "style", + Description: "formatting style to apply (readable or sorted)", + DefaultValue: string(workflow.FormatStyleReadable), + AllowedValues: []string{string(workflow.FormatStyleReadable), string(workflow.FormatStyleSorted)}, + }), } var normalizeCmd = &model.ExecutableCommand[normalizeFlags]{ @@ -166,18 +182,30 @@ func runCleanup(ctx context.Context, flags basicFlagsI) error { return transform.CleanupDocument(ctx, flags.Schema, yamlOut, out) } -func runFormat(ctx context.Context, flags basicFlagsI) error { +func runFormat(ctx context.Context, flags formatFlags) error { + style := workflow.FormatStyle(flags.Style) + if style != workflow.FormatStyleReadable && style != workflow.FormatStyleSorted { + return fmt.Errorf("unsupported format style %q", flags.Style) + } + if style == workflow.FormatStyleSorted && utils.HasYAMLExt(strings.ToLower(flags.Out)) { + return fmt.Errorf("sorted formatting only supports JSON output") + } + out, yamlOut, err := setupOutput(ctx, flags.Out) if err != nil { return err } defer out.Close() - return transform.FormatDocument(ctx, flags.Schema, yamlOut, out) + if style == workflow.FormatStyleReadable { + return transform.FormatDocument(ctx, flags.Schema, yamlOut, out) + } + + return transform.FormatSortedDocument(flags.Schema, yamlOut, out) } func setupOutput(_ context.Context, out string) (*os.File, bool, error) { - yamlOut := utils.HasYAMLExt(out) + yamlOut := utils.HasYAMLExt(strings.ToLower(out)) if out != "" { file, err := os.Create(out) diff --git a/cmd/openapi/transform_test.go b/cmd/openapi/transform_test.go new file mode 100644 index 000000000..7f6b6d953 --- /dev/null +++ b/cmd/openapi/transform_test.go @@ -0,0 +1,116 @@ +package openapi + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunFormat_ReadableStyle(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + inputPath := filepath.Join(tempDir, "input.json") + outputPath := filepath.Join(tempDir, "output.json") + input := `{"paths":{},"openapi":"3.0.0","info":{"version":"1","title":"T"}}` + require.NoError(t, os.WriteFile(inputPath, []byte(input), 0o600)) + + err := runFormat(context.Background(), formatFlags{ + Schema: inputPath, + Out: outputPath, + Style: "readable", + }) + require.NoError(t, err) + + actual, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.JSONEq(t, input, string(actual), "readable formatting should preserve the document") +} + +func TestRunFormat_ReadableUppercaseYAMLOutput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + inputPath := filepath.Join(tempDir, "input.json") + input := `{"paths":{},"openapi":"3.0.0","info":{"version":"1","title":"T"}}` + require.NoError(t, os.WriteFile(inputPath, []byte(input), 0o600)) + + for _, outputName := range []string{"output.YML", "output.YAML"} { + outputPath := filepath.Join(tempDir, outputName) + err := runFormat(context.Background(), formatFlags{ + Schema: inputPath, + Out: outputPath, + Style: "readable", + }) + require.NoError(t, err) + + actual, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Contains(t, string(actual), "openapi: 3.0.0", "uppercase YAML extensions should emit YAML") + } +} + +func TestRunFormat_SortedYAMLInput(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + inputPath := filepath.Join(tempDir, "input.yaml") + outputPath := filepath.Join(tempDir, "output.json") + require.NoError(t, os.WriteFile(inputPath, []byte("openapi: 3.0.0\npaths:\n /z: {}\n /a: {}\n"), 0o600)) + + err := runFormat(context.Background(), formatFlags{ + Schema: inputPath, + Out: outputPath, + Style: "sorted", + }) + require.NoError(t, err) + + actual, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, `{ + "openapi": "3.0.0", + "paths": { + "/a": {}, + "/z": {} + } +} +`, string(actual)) +} + +func TestRunFormat_SortedRejectsYAMLOutputBeforeCreate(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + inputPath := filepath.Join(tempDir, "input.json") + require.NoError(t, os.WriteFile(inputPath, []byte(`{"openapi":"3.0.0"}`), 0o600)) + + for _, outputName := range []string{"output.yaml", "output.YML"} { + outputPath := filepath.Join(tempDir, outputName) + err := runFormat(context.Background(), formatFlags{ + Schema: inputPath, + Out: outputPath, + Style: "sorted", + }) + require.EqualError(t, err, "sorted formatting only supports JSON output") + _, statErr := os.Stat(outputPath) + require.ErrorIs(t, statErr, os.ErrNotExist, "rejected output should not be created") + } +} + +func TestRunFormat_RejectsUnknownStyleBeforeCreate(t *testing.T) { + t.Parallel() + + outputPath := filepath.Join(t.TempDir(), "output.json") + err := runFormat(context.Background(), formatFlags{ + Schema: "unused.json", + Out: outputPath, + Style: "unknown", + }) + require.EqualError(t, err, `unsupported format style "unknown"`) + _, statErr := os.Stat(outputPath) + require.ErrorIs(t, statErr, os.ErrNotExist, "rejected output should not be created") +} diff --git a/go.mod b/go.mod index 218759ca4..79f56dc26 100644 --- a/go.mod +++ b/go.mod @@ -46,9 +46,9 @@ require ( github.com/speakeasy-api/gram v0.0.0-20260121234743-5a36906a8929 github.com/speakeasy-api/huh v1.1.2 github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4 - github.com/speakeasy-api/openapi v1.24.1 + github.com/speakeasy-api/openapi v1.25.0 github.com/speakeasy-api/openapi-generation/v2 v2.932.0 - github.com/speakeasy-api/sdk-gen-config v1.57.1 + github.com/speakeasy-api/sdk-gen-config v1.58.0 github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12 github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7 github.com/speakeasy-api/speakeasy-core v0.22.2 diff --git a/go.sum b/go.sum index fafc06143..d626d83d5 100644 --- a/go.sum +++ b/go.sum @@ -546,14 +546,14 @@ github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xh github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed h1:ZtuakKtG6x73crANhspQ2CGyZZaBmAHmKUA+Hn2JnpI= github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU= -github.com/speakeasy-api/openapi v1.24.1 h1:e8rkoiq1q8vJ/Ru1pjdlABlgkbG581M4XRadhaHtT3c= -github.com/speakeasy-api/openapi v1.24.1/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ= +github.com/speakeasy-api/openapi v1.25.0 h1:xyJ5ZzW4YwStT2ICo0o7v9M890J7VpsR7AqhQSZnwl4= +github.com/speakeasy-api/openapi v1.25.0/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ= github.com/speakeasy-api/openapi-generation/v2 v2.932.0 h1:C46dgO+qN2Ew1exQyQFS76KxumKBArwMVHtsRdvK/Fo= github.com/speakeasy-api/openapi-generation/v2 v2.932.0/go.mod h1:QgU7//EsEdpiCMcpiM983QYlXXsp1YhE8TQi8OJEXyQ= github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4 h1:gV+lYeVNNJG9X3Sl9Su3cRh1iF/oNqzvb5Ijq2QR8jY= github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4/go.mod h1:1zQpVio7X6QJDtyNdUguCgZ+IC7CzKhhjvNgJdvGVF0= -github.com/speakeasy-api/sdk-gen-config v1.57.1 h1:s0r+utcuSnJqbWxor7wYMGVzj7lRxp+HGYCGRBO0/uk= -github.com/speakeasy-api/sdk-gen-config v1.57.1/go.mod h1:kD0NPNX5yaG4j+dcCpLL0hHKQbFk6X93obp+v1XlK5E= +github.com/speakeasy-api/sdk-gen-config v1.58.0 h1:JrDgDU3XBIidv+TXFqYBvIomfeGEQ0zN+OnHyUc+kNw= +github.com/speakeasy-api/sdk-gen-config v1.58.0/go.mod h1:kD0NPNX5yaG4j+dcCpLL0hHKQbFk6X93obp+v1XlK5E= github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12 h1:dGONbW8WLNc4uSox1/k8O4JFggHoQy1v6R9cLqbTf5M= github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12/go.mod h1:AiZRZLL+sv9uwtTHIECc1dcTgfJrXrEB5QxcAGifMkI= github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7 h1:SoWZkRlpFlv8qibCfXWrBZay1JeLS9uqJ+1cu+DFgXo= diff --git a/integration/resources/sorted.json b/integration/resources/sorted.json new file mode 100644 index 000000000..2be75c763 --- /dev/null +++ b/integration/resources/sorted.json @@ -0,0 +1 @@ +{"paths":{"/z":{"get":{"operationId":"z","responses":{"200":{"description":"ok"}}}},"/a":{"get":{"operationId":"a","responses":{"200":{"description":"ok"}}}}},"openapi":"3.0.0","info":{"version":"1","title":"T"},"components":{"schemas":{"Z":{"type":"object","required":["z","a"],"properties":{"z":{"type":"string"},"a":{"type":"string"}}},"A":{"type":"string"}}}} diff --git a/integration/resources/sorted.yaml b/integration/resources/sorted.yaml new file mode 100644 index 000000000..983aeb4de --- /dev/null +++ b/integration/resources/sorted.yaml @@ -0,0 +1,31 @@ +paths: + /z: + get: + responses: + "200": + description: ok + operationId: z + /a: + get: + responses: + "200": + description: ok + operationId: a +openapi: 3.0.0 +info: + version: "1" + title: T +components: + schemas: + Z: + type: object + required: + - z + - a + properties: + z: + type: string + a: + type: string + A: + type: string diff --git a/integration/workflow_test.go b/integration/workflow_test.go index 1d0ceb8e5..0c549b6bb 100644 --- a/integration/workflow_test.go +++ b/integration/workflow_test.go @@ -336,6 +336,59 @@ func (c *cmdRunner) Run() error { //nolint:unused // Reserved for executeI debug return c.rootCmd.Execute() } +const sortedFormattedJSON = `{ + "openapi": "3.0.0", + "info": { + "title": "T", + "version": "1" + }, + "paths": { + "/a": { + "get": { + "operationId": "a", + "responses": { + "200": { + "description": "ok" + } + } + } + }, + "/z": { + "get": { + "operationId": "z", + "responses": { + "200": { + "description": "ok" + } + } + } + } + }, + "components": { + "schemas": { + "A": { + "type": "string" + }, + "Z": { + "type": "object", + "properties": { + "a": { + "type": "string" + }, + "z": { + "type": "string" + } + }, + "required": [ + "a", + "z" + ] + } + } + } +} +` + func TestSpecWorkflows(t *testing.T) { t.Parallel() @@ -345,6 +398,7 @@ func TestSpecWorkflows(t *testing.T) { overlays []string transformations []workflow.Transformation out string + expectedContent string expectedPaths []string unexpectedPaths []string }{ @@ -470,6 +524,28 @@ func TestSpecWorkflows(t *testing.T) { "/pet/findByTags", }, }, + { + name: "test sorted JSON formatting", + inputDocs: []string{"sorted.json"}, + transformations: []workflow.Transformation{ + { + Format: &workflow.FormatOptions{Style: workflow.FormatStyleSorted}, + }, + }, + out: "output.json", + expectedContent: sortedFormattedJSON, + }, + { + name: "test sorted YAML formatting", + inputDocs: []string{"sorted.yaml"}, + transformations: []workflow.Transformation{ + { + Format: &workflow.FormatOptions{Style: workflow.FormatStyleSorted}, + }, + }, + out: "output.json", + expectedContent: sortedFormattedJSON, + }, { name: "test json conversion with overlay", inputDocs: []string{"part1.yaml"}, @@ -550,6 +626,9 @@ func TestSpecWorkflows(t *testing.T) { content, err := os.ReadFile(filepath.Join(temp, tt.out)) require.NoError(t, err, "No readable file %s exists", filepath.Join(temp, tt.out)) + if tt.expectedContent != "" { + require.Equal(t, tt.expectedContent, string(content), "output should match expected formatting") + } if len(tt.overlays) > 0 { if !strings.Contains(string(content), "x-codeSamples") { diff --git a/internal/run/transform.go b/internal/run/transform.go index e4097539d..1ddb0aec4 100644 --- a/internal/run/transform.go +++ b/internal/run/transform.go @@ -84,10 +84,21 @@ func (t Transform) Do(ctx context.Context, inputPath string) (string, error) { return "", err } case transformation.Format != nil: - transformStep.NewSubstep("Formatting document") - - if err := transform.FormatFromReader(ctx, in, inputPath, out, yamlOut); err != nil { - return "", err + switch transformation.Format.GetStyle() { + case workflow.FormatStyleReadable: + transformStep.NewSubstep("Formatting document") + + if err := transform.FormatFromReader(ctx, in, inputPath, out, yamlOut); err != nil { + return "", err + } + case workflow.FormatStyleSorted: + transformStep.NewSubstep("Applying sorted formatting") + + if err := transform.FormatSortedFromReader(in, out, yamlOut); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported format style %q", transformation.Format.GetStyle()) } case transformation.Normalize != nil: transformStep.NewSubstep("Normalizing document") diff --git a/pkg/transform/format_sorted.go b/pkg/transform/format_sorted.go new file mode 100644 index 000000000..f6851a1dd --- /dev/null +++ b/pkg/transform/format_sorted.go @@ -0,0 +1,52 @@ +package transform + +import ( + "bytes" + stdjson "encoding/json" + "fmt" + "io" + "os" + + openapijson "github.com/speakeasy-api/openapi/json" + "github.com/speakeasy-api/openapi/sortfmt" + "gopkg.in/yaml.v3" +) + +// FormatSortedDocument formats a JSON or YAML document from a file using deterministic ordering. +func FormatSortedDocument(schemaPath string, yamlOut bool, w io.Writer) error { + file, err := os.Open(schemaPath) + if err != nil { + return err + } + defer file.Close() + + return FormatSortedFromReader(file, w, yamlOut) +} + +// FormatSortedFromReader formats JSON or YAML from a reader using deterministic ordering. +func FormatSortedFromReader(schema io.Reader, w io.Writer, yamlOut bool) error { + if yamlOut { + return fmt.Errorf("sorted formatting only supports JSON output") + } + + input, err := io.ReadAll(schema) + if err != nil { + return fmt.Errorf("read document: %w", err) + } + + if stdjson.Valid(input) { + return sortfmt.Format(bytes.NewReader(input), w) + } + + var document yaml.Node + if err := yaml.Unmarshal(input, &document); err != nil { + return fmt.Errorf("parse YAML document: %w", err) + } + + var jsonInput bytes.Buffer + if err := openapijson.YAMLToJSON(&document, 2, &jsonInput); err != nil { + return fmt.Errorf("convert YAML document to JSON: %w", err) + } + + return sortfmt.Format(&jsonInput, w) +} diff --git a/pkg/transform/format_sorted_test.go b/pkg/transform/format_sorted_test.go new file mode 100644 index 000000000..63380a02d --- /dev/null +++ b/pkg/transform/format_sorted_test.go @@ -0,0 +1,153 @@ +package transform + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatSortedDocument(t *testing.T) { + t.Parallel() + + inputPath := filepath.Join(t.TempDir(), "input.json") + require.NoError(t, os.WriteFile(inputPath, []byte(`{"z":1,"openapi":"3.0.0"}`), 0o600)) + + var output bytes.Buffer + require.NoError(t, FormatSortedDocument(inputPath, false, &output)) + assert.Equal(t, "{\n \"openapi\": \"3.0.0\",\n \"z\": 1\n}\n", output.String()) +} + +func TestFormatSortedDocument_MissingInputReturnsError(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + err := FormatSortedDocument(filepath.Join(t.TempDir(), "missing.json"), false, &output) + require.Error(t, err) + assert.Empty(t, output.String()) +} + +func TestFormatSortedFromReader_Success(t *testing.T) { + t.Parallel() + + input := `{"paths":{"/z":{},"/a":{}},"openapi":"3.0.0","parameters":[{"name":"z"},{"name":"a"}]}` + expected := `{ + "openapi": "3.0.0", + "paths": { + "/a": {}, + "/z": {} + }, + "parameters": [ + { + "name": "a" + }, + { + "name": "z" + } + ] +} +` + + var output bytes.Buffer + err := FormatSortedFromReader(strings.NewReader(input), &output, false) + require.NoError(t, err, "sorted formatting should succeed") + assert.Equal(t, expected, output.String(), "sorted output should match") +} + +func TestFormatSortedFromReader_YAMLInput(t *testing.T) { + t.Parallel() + + input := `openapi: 3.0.0 +paths: + /z: {} + /a: {} +components: + schemas: + Example: + required: [z, a] + properties: + z: + type: string + a: + type: string +responses: + 200: + description: ok +` + expected := `{ + "openapi": "3.0.0", + "paths": { + "/a": {}, + "/z": {} + }, + "components": { + "schemas": { + "Example": { + "properties": { + "a": { + "type": "string" + }, + "z": { + "type": "string" + } + }, + "required": [ + "a", + "z" + ] + } + } + }, + "responses": { + "200": { + "description": "ok" + } + } +} +` + + var output bytes.Buffer + err := FormatSortedFromReader(strings.NewReader(input), &output, false) + require.NoError(t, err, "YAML input should be converted and sorted") + assert.Equal(t, expected, output.String(), "YAML input should produce sorted JSON") +} + +func TestFormatSortedFromReader_InvalidInputReturnsError(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + err := FormatSortedFromReader(strings.NewReader("{not valid"), &output, false) + require.Error(t, err, "invalid JSON and YAML should fail") + assert.Empty(t, output.String(), "invalid input should not produce output") +} + +func TestFormatSortedFromReader_ReadError(t *testing.T) { + t.Parallel() + + readErr := errors.New("read failed") + var output bytes.Buffer + err := FormatSortedFromReader(errorReader{err: readErr}, &output, false) + require.ErrorIs(t, err, readErr) + assert.Empty(t, output.String()) +} + +func TestFormatSortedFromReader_YAMLOutputReturnsError(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + err := FormatSortedFromReader(strings.NewReader(`{"openapi":"3.0.0"}`), &output, true) + require.EqualError(t, err, "sorted formatting only supports JSON output", "YAML output should fail clearly") +} + +type errorReader struct { + err error +} + +func (r errorReader) Read([]byte) (int, error) { + return 0, r.err +}