Skip to content

Commit 97f49bf

Browse files
authored
feat(openapi): add sorted format style (#2118)
## Summary - add an opt-in sorted formatting style to the OpenAPI transform command - support `format: { style: sorted }` in workflows while preserving readable/default behavior - accept JSON or YAML input and emit deterministic JSON with selected array ordering - use the released OpenAPI and sdk-gen-config implementations ## Usage ```sh speakeasy openapi transform format --style sorted --schema openapi.yaml --out openapi.json ``` ```yaml transformations: - format: style: sorted ``` Sorted workflow formatting requires JSON output and must be the final transformation. ## Validation - `go test -timeout=30m ./...` - `go build ./...` - GolangCI-Lint v2.12.2: 0 issues - exact byte parity with the reference implementation on a representative OAD <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds an opt-in sorted formatting style to the OpenAPI transform for deterministic JSON output. Previously only a readable format existed; now you can choose `readable` (default, unchanged) or `sorted` (deterministic object key and array ordering) with JSON-only output for `sorted`. - CLI: `speakeasy openapi transform format` accepts `--style readable|sorted` (default `readable`). `sorted` reads JSON or YAML but only writes JSON; requesting YAML output fails with “sorted formatting only supports JSON output.” Unknown styles are rejected before any files are created. YAML output detection for `readable` is case-insensitive (e.g., `.YAML`, `.YML`). - Workflows: support `format: { style: sorted }`. Use JSON output and place the sorted formatting as the final transformation. - Sorting specifics: normalizes object keys and reorders arrays under `required`, `parameters`, `oneOf`, `anyOf`, and `allOf`, which can affect SDK method signatures and union ordering. - Internals: adds `FormatSortedDocument` and `FormatSortedFromReader`; the transform runner switches on the selected style and surfaces clear substep messaging. - Dependencies: bumps `github.com/speakeasy-api/openapi` to v1.25.0 and `github.com/speakeasy-api/sdk-gen-config` to v1.58.0. - Tests: unit tests for style validation, case-insensitive YAML extension handling, and YAML→JSON path; integration tests verify deterministic output for both JSON and YAML inputs. <sup>Written for commit a8758e0. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/speakeasy-api/speakeasy/pull/2118?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 64bc4c2 commit 97f49bf

10 files changed

Lines changed: 489 additions & 18 deletions

File tree

cmd/openapi/transform.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package openapi
22

33
import (
44
"context"
5+
"fmt"
56
"os"
7+
"strings"
68

9+
"github.com/speakeasy-api/sdk-gen-config/workflow"
710
charm_internal "github.com/speakeasy-api/speakeasy/internal/charm"
811
"github.com/speakeasy-api/speakeasy/internal/model"
912
"github.com/speakeasy-api/speakeasy/internal/model/flag"
@@ -89,12 +92,25 @@ var cleanupCmd = &model.ExecutableCommand[basicFlagsI]{
8992
Flags: basicFlags,
9093
}
9194

92-
var formatCmd = &model.ExecutableCommand[basicFlagsI]{
95+
type formatFlags struct {
96+
Schema string `json:"schema"`
97+
Out string `json:"out"`
98+
Style string `json:"style"`
99+
}
100+
101+
var formatCmd = &model.ExecutableCommand[formatFlags]{
93102
Usage: "format",
94-
Short: "Format an OpenAPI document to be more human-readable",
95-
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",
96-
Run: runFormat,
97-
Flags: basicFlags,
103+
Short: "Format an OpenAPI document using a selected output style",
104+
Long: "Format an OpenAPI document using either the readable style or the sorted style. " +
105+
"The sorted style accepts JSON or YAML, emits deterministic JSON, and reorders arrays under required, parameters, oneOf, anyOf, and allOf; " +
106+
"those array orders can affect generated method signatures, union ordering, or order-sensitive tooling.",
107+
Run: runFormat,
108+
Flags: append(basicFlags, flag.EnumFlag{
109+
Name: "style",
110+
Description: "formatting style to apply (readable or sorted)",
111+
DefaultValue: string(workflow.FormatStyleReadable),
112+
AllowedValues: []string{string(workflow.FormatStyleReadable), string(workflow.FormatStyleSorted)},
113+
}),
98114
}
99115

100116
var normalizeCmd = &model.ExecutableCommand[normalizeFlags]{
@@ -166,18 +182,30 @@ func runCleanup(ctx context.Context, flags basicFlagsI) error {
166182
return transform.CleanupDocument(ctx, flags.Schema, yamlOut, out)
167183
}
168184

169-
func runFormat(ctx context.Context, flags basicFlagsI) error {
185+
func runFormat(ctx context.Context, flags formatFlags) error {
186+
style := workflow.FormatStyle(flags.Style)
187+
if style != workflow.FormatStyleReadable && style != workflow.FormatStyleSorted {
188+
return fmt.Errorf("unsupported format style %q", flags.Style)
189+
}
190+
if style == workflow.FormatStyleSorted && utils.HasYAMLExt(strings.ToLower(flags.Out)) {
191+
return fmt.Errorf("sorted formatting only supports JSON output")
192+
}
193+
170194
out, yamlOut, err := setupOutput(ctx, flags.Out)
171195
if err != nil {
172196
return err
173197
}
174198
defer out.Close()
175199

176-
return transform.FormatDocument(ctx, flags.Schema, yamlOut, out)
200+
if style == workflow.FormatStyleReadable {
201+
return transform.FormatDocument(ctx, flags.Schema, yamlOut, out)
202+
}
203+
204+
return transform.FormatSortedDocument(flags.Schema, yamlOut, out)
177205
}
178206

179207
func setupOutput(_ context.Context, out string) (*os.File, bool, error) {
180-
yamlOut := utils.HasYAMLExt(out)
208+
yamlOut := utils.HasYAMLExt(strings.ToLower(out))
181209

182210
if out != "" {
183211
file, err := os.Create(out)

cmd/openapi/transform_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package openapi
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestRunFormat_ReadableStyle(t *testing.T) {
14+
t.Parallel()
15+
16+
tempDir := t.TempDir()
17+
inputPath := filepath.Join(tempDir, "input.json")
18+
outputPath := filepath.Join(tempDir, "output.json")
19+
input := `{"paths":{},"openapi":"3.0.0","info":{"version":"1","title":"T"}}`
20+
require.NoError(t, os.WriteFile(inputPath, []byte(input), 0o600))
21+
22+
err := runFormat(context.Background(), formatFlags{
23+
Schema: inputPath,
24+
Out: outputPath,
25+
Style: "readable",
26+
})
27+
require.NoError(t, err)
28+
29+
actual, err := os.ReadFile(outputPath)
30+
require.NoError(t, err)
31+
assert.JSONEq(t, input, string(actual), "readable formatting should preserve the document")
32+
}
33+
34+
func TestRunFormat_ReadableUppercaseYAMLOutput(t *testing.T) {
35+
t.Parallel()
36+
37+
tempDir := t.TempDir()
38+
inputPath := filepath.Join(tempDir, "input.json")
39+
input := `{"paths":{},"openapi":"3.0.0","info":{"version":"1","title":"T"}}`
40+
require.NoError(t, os.WriteFile(inputPath, []byte(input), 0o600))
41+
42+
for _, outputName := range []string{"output.YML", "output.YAML"} {
43+
outputPath := filepath.Join(tempDir, outputName)
44+
err := runFormat(context.Background(), formatFlags{
45+
Schema: inputPath,
46+
Out: outputPath,
47+
Style: "readable",
48+
})
49+
require.NoError(t, err)
50+
51+
actual, err := os.ReadFile(outputPath)
52+
require.NoError(t, err)
53+
assert.Contains(t, string(actual), "openapi: 3.0.0", "uppercase YAML extensions should emit YAML")
54+
}
55+
}
56+
57+
func TestRunFormat_SortedYAMLInput(t *testing.T) {
58+
t.Parallel()
59+
60+
tempDir := t.TempDir()
61+
inputPath := filepath.Join(tempDir, "input.yaml")
62+
outputPath := filepath.Join(tempDir, "output.json")
63+
require.NoError(t, os.WriteFile(inputPath, []byte("openapi: 3.0.0\npaths:\n /z: {}\n /a: {}\n"), 0o600))
64+
65+
err := runFormat(context.Background(), formatFlags{
66+
Schema: inputPath,
67+
Out: outputPath,
68+
Style: "sorted",
69+
})
70+
require.NoError(t, err)
71+
72+
actual, err := os.ReadFile(outputPath)
73+
require.NoError(t, err)
74+
assert.Equal(t, `{
75+
"openapi": "3.0.0",
76+
"paths": {
77+
"/a": {},
78+
"/z": {}
79+
}
80+
}
81+
`, string(actual))
82+
}
83+
84+
func TestRunFormat_SortedRejectsYAMLOutputBeforeCreate(t *testing.T) {
85+
t.Parallel()
86+
87+
tempDir := t.TempDir()
88+
inputPath := filepath.Join(tempDir, "input.json")
89+
require.NoError(t, os.WriteFile(inputPath, []byte(`{"openapi":"3.0.0"}`), 0o600))
90+
91+
for _, outputName := range []string{"output.yaml", "output.YML"} {
92+
outputPath := filepath.Join(tempDir, outputName)
93+
err := runFormat(context.Background(), formatFlags{
94+
Schema: inputPath,
95+
Out: outputPath,
96+
Style: "sorted",
97+
})
98+
require.EqualError(t, err, "sorted formatting only supports JSON output")
99+
_, statErr := os.Stat(outputPath)
100+
require.ErrorIs(t, statErr, os.ErrNotExist, "rejected output should not be created")
101+
}
102+
}
103+
104+
func TestRunFormat_RejectsUnknownStyleBeforeCreate(t *testing.T) {
105+
t.Parallel()
106+
107+
outputPath := filepath.Join(t.TempDir(), "output.json")
108+
err := runFormat(context.Background(), formatFlags{
109+
Schema: "unused.json",
110+
Out: outputPath,
111+
Style: "unknown",
112+
})
113+
require.EqualError(t, err, `unsupported format style "unknown"`)
114+
_, statErr := os.Stat(outputPath)
115+
require.ErrorIs(t, statErr, os.ErrNotExist, "rejected output should not be created")
116+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ require (
4646
github.com/speakeasy-api/gram v0.0.0-20260121234743-5a36906a8929
4747
github.com/speakeasy-api/huh v1.1.2
4848
github.com/speakeasy-api/jq v0.1.1-0.20251107233444-84d7e49e84a4
49-
github.com/speakeasy-api/openapi v1.24.1
49+
github.com/speakeasy-api/openapi v1.25.0
5050
github.com/speakeasy-api/openapi-generation/v2 v2.932.0
51-
github.com/speakeasy-api/sdk-gen-config v1.57.1
51+
github.com/speakeasy-api/sdk-gen-config v1.58.0
5252
github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12
5353
github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7
5454
github.com/speakeasy-api/speakeasy-core v0.22.2

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -546,14 +546,14 @@ github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xh
546546
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
547547
github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed h1:ZtuakKtG6x73crANhspQ2CGyZZaBmAHmKUA+Hn2JnpI=
548548
github.com/speakeasy-api/libopenapi v0.21.10-fixhiddencomps-fixed/go.mod h1:Gc8oQkjr2InxwumK0zOBtKN9gIlv9L2VmSVIUk2YxcU=
549-
github.com/speakeasy-api/openapi v1.24.1 h1:e8rkoiq1q8vJ/Ru1pjdlABlgkbG581M4XRadhaHtT3c=
550-
github.com/speakeasy-api/openapi v1.24.1/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ=
549+
github.com/speakeasy-api/openapi v1.25.0 h1:xyJ5ZzW4YwStT2ICo0o7v9M890J7VpsR7AqhQSZnwl4=
550+
github.com/speakeasy-api/openapi v1.25.0/go.mod h1:9gGkzi9jNspEbcB08zta+IjzAi5Zy4QgSEQfF/LSrbQ=
551551
github.com/speakeasy-api/openapi-generation/v2 v2.932.0 h1:C46dgO+qN2Ew1exQyQFS76KxumKBArwMVHtsRdvK/Fo=
552552
github.com/speakeasy-api/openapi-generation/v2 v2.932.0/go.mod h1:QgU7//EsEdpiCMcpiM983QYlXXsp1YhE8TQi8OJEXyQ=
553553
github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4 h1:gV+lYeVNNJG9X3Sl9Su3cRh1iF/oNqzvb5Ijq2QR8jY=
554554
github.com/speakeasy-api/openapi/openapi/linter/customrules v0.0.0-20260206023826-2483fb8e98b4/go.mod h1:1zQpVio7X6QJDtyNdUguCgZ+IC7CzKhhjvNgJdvGVF0=
555-
github.com/speakeasy-api/sdk-gen-config v1.57.1 h1:s0r+utcuSnJqbWxor7wYMGVzj7lRxp+HGYCGRBO0/uk=
556-
github.com/speakeasy-api/sdk-gen-config v1.57.1/go.mod h1:kD0NPNX5yaG4j+dcCpLL0hHKQbFk6X93obp+v1XlK5E=
555+
github.com/speakeasy-api/sdk-gen-config v1.58.0 h1:JrDgDU3XBIidv+TXFqYBvIomfeGEQ0zN+OnHyUc+kNw=
556+
github.com/speakeasy-api/sdk-gen-config v1.58.0/go.mod h1:kD0NPNX5yaG4j+dcCpLL0hHKQbFk6X93obp+v1XlK5E=
557557
github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12 h1:dGONbW8WLNc4uSox1/k8O4JFggHoQy1v6R9cLqbTf5M=
558558
github.com/speakeasy-api/speakeasy-agent-mode-content v0.2.12/go.mod h1:AiZRZLL+sv9uwtTHIECc1dcTgfJrXrEB5QxcAGifMkI=
559559
github.com/speakeasy-api/speakeasy-client-sdk-go/v3 v3.26.7 h1:SoWZkRlpFlv8qibCfXWrBZay1JeLS9uqJ+1cu+DFgXo=

integration/resources/sorted.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}}}}

integration/resources/sorted.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
paths:
2+
/z:
3+
get:
4+
responses:
5+
"200":
6+
description: ok
7+
operationId: z
8+
/a:
9+
get:
10+
responses:
11+
"200":
12+
description: ok
13+
operationId: a
14+
openapi: 3.0.0
15+
info:
16+
version: "1"
17+
title: T
18+
components:
19+
schemas:
20+
Z:
21+
type: object
22+
required:
23+
- z
24+
- a
25+
properties:
26+
z:
27+
type: string
28+
a:
29+
type: string
30+
A:
31+
type: string

integration/workflow_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,59 @@ func (c *cmdRunner) Run() error { //nolint:unused // Reserved for executeI debug
336336
return c.rootCmd.Execute()
337337
}
338338

339+
const sortedFormattedJSON = `{
340+
"openapi": "3.0.0",
341+
"info": {
342+
"title": "T",
343+
"version": "1"
344+
},
345+
"paths": {
346+
"/a": {
347+
"get": {
348+
"operationId": "a",
349+
"responses": {
350+
"200": {
351+
"description": "ok"
352+
}
353+
}
354+
}
355+
},
356+
"/z": {
357+
"get": {
358+
"operationId": "z",
359+
"responses": {
360+
"200": {
361+
"description": "ok"
362+
}
363+
}
364+
}
365+
}
366+
},
367+
"components": {
368+
"schemas": {
369+
"A": {
370+
"type": "string"
371+
},
372+
"Z": {
373+
"type": "object",
374+
"properties": {
375+
"a": {
376+
"type": "string"
377+
},
378+
"z": {
379+
"type": "string"
380+
}
381+
},
382+
"required": [
383+
"a",
384+
"z"
385+
]
386+
}
387+
}
388+
}
389+
}
390+
`
391+
339392
func TestSpecWorkflows(t *testing.T) {
340393
t.Parallel()
341394

@@ -345,6 +398,7 @@ func TestSpecWorkflows(t *testing.T) {
345398
overlays []string
346399
transformations []workflow.Transformation
347400
out string
401+
expectedContent string
348402
expectedPaths []string
349403
unexpectedPaths []string
350404
}{
@@ -470,6 +524,28 @@ func TestSpecWorkflows(t *testing.T) {
470524
"/pet/findByTags",
471525
},
472526
},
527+
{
528+
name: "test sorted JSON formatting",
529+
inputDocs: []string{"sorted.json"},
530+
transformations: []workflow.Transformation{
531+
{
532+
Format: &workflow.FormatOptions{Style: workflow.FormatStyleSorted},
533+
},
534+
},
535+
out: "output.json",
536+
expectedContent: sortedFormattedJSON,
537+
},
538+
{
539+
name: "test sorted YAML formatting",
540+
inputDocs: []string{"sorted.yaml"},
541+
transformations: []workflow.Transformation{
542+
{
543+
Format: &workflow.FormatOptions{Style: workflow.FormatStyleSorted},
544+
},
545+
},
546+
out: "output.json",
547+
expectedContent: sortedFormattedJSON,
548+
},
473549
{
474550
name: "test json conversion with overlay",
475551
inputDocs: []string{"part1.yaml"},
@@ -550,6 +626,9 @@ func TestSpecWorkflows(t *testing.T) {
550626

551627
content, err := os.ReadFile(filepath.Join(temp, tt.out))
552628
require.NoError(t, err, "No readable file %s exists", filepath.Join(temp, tt.out))
629+
if tt.expectedContent != "" {
630+
require.Equal(t, tt.expectedContent, string(content), "output should match expected formatting")
631+
}
553632

554633
if len(tt.overlays) > 0 {
555634
if !strings.Contains(string(content), "x-codeSamples") {

internal/run/transform.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,21 @@ func (t Transform) Do(ctx context.Context, inputPath string) (string, error) {
8484
return "", err
8585
}
8686
case transformation.Format != nil:
87-
transformStep.NewSubstep("Formatting document")
88-
89-
if err := transform.FormatFromReader(ctx, in, inputPath, out, yamlOut); err != nil {
90-
return "", err
87+
switch transformation.Format.GetStyle() {
88+
case workflow.FormatStyleReadable:
89+
transformStep.NewSubstep("Formatting document")
90+
91+
if err := transform.FormatFromReader(ctx, in, inputPath, out, yamlOut); err != nil {
92+
return "", err
93+
}
94+
case workflow.FormatStyleSorted:
95+
transformStep.NewSubstep("Applying sorted formatting")
96+
97+
if err := transform.FormatSortedFromReader(in, out, yamlOut); err != nil {
98+
return "", err
99+
}
100+
default:
101+
return "", fmt.Errorf("unsupported format style %q", transformation.Format.GetStyle())
91102
}
92103
case transformation.Normalize != nil:
93104
transformStep.NewSubstep("Normalizing document")

0 commit comments

Comments
 (0)