Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions cmd/openapi/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]{
Expand Down Expand Up @@ -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)) {

@cubic-dev-ai cubic-dev-ai Bot Aug 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new sorted-style check lowercases the output path (strings.ToLower(flags.Out)) and so rejects out.YML/out.YAML as YAML, but the shared setupOutput computes yamlOut := utils.HasYAMLExt(out) on the raw path without normalizing case. As a result, the two format styles make opposite decisions for the same uppercase extension: --style sorted --out out.YML is rejected, while --style readable --out out.YML is treated as JSON and writes JSON into a .YML file. Normalize case in setupOutput (or a shared helper) so both styles handle uppercase YAML extensions consistently.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/openapi/transform.go, line 190:

<comment>The new sorted-style check lowercases the output path (`strings.ToLower(flags.Out)`) and so rejects `out.YML`/`out.YAML` as YAML, but the shared `setupOutput` computes `yamlOut := utils.HasYAMLExt(out)` on the raw path without normalizing case. As a result, the two format styles make opposite decisions for the same uppercase extension: `--style sorted --out out.YML` is rejected, while `--style readable --out out.YML` is treated as JSON and writes JSON into a `.YML` file. Normalize case in `setupOutput` (or a shared helper) so both styles handle uppercase YAML extensions consistently.</comment>

<file context>
@@ -166,14 +182,26 @@ func runCleanup(ctx context.Context, flags basicFlagsI) error {
+	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")
+	}
</file context>
Suggested change
if style == workflow.FormatStyleSorted && utils.HasYAMLExt(strings.ToLower(flags.Out)) {
func setupOutput(_ context.Context, out string) (*os.File, bool, error) {
yamlOut := utils.HasYAMLExt(strings.ToLower(out))
if out != "" {
file, err := os.Create(out)
if err != nil {
return nil, yamlOut, err
}
return file, yamlOut, nil
}
return os.Stdout, yamlOut, nil
}
Fix with cubic

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)
Expand Down
116 changes: 116 additions & 0 deletions cmd/openapi/transform_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions integration/resources/sorted.json
Original file line number Diff line number Diff line change
@@ -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"}}}}
31 changes: 31 additions & 0 deletions integration/resources/sorted.yaml
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions integration/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -345,6 +398,7 @@ func TestSpecWorkflows(t *testing.T) {
overlays []string
transformations []workflow.Transformation
out string
expectedContent string
expectedPaths []string
unexpectedPaths []string
}{
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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") {
Expand Down
19 changes: 15 additions & 4 deletions internal/run/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading