From bdc19128df04d38d1ab7f07e80f2c330fb05e425 Mon Sep 17 00:00:00 2001 From: David Alberto Adler Date: Fri, 23 Oct 2026 18:08:43 +0100 Subject: [PATCH 1/2] fix(all): report server-sent event operations and field deprecations in SDK diffs The SDK diff resolved both ASTs with a context that carried no generation access state. Feature-gated constructs such as text/event-stream responses then failed the access check, and the generator skipped the whole operation, so any operation with a server-sent event response was missing from the diff: never reported as added, removed or changed. resolveAST now attaches direct generation access state when the caller provides none, matching internal/generate.Generate. Field comparisons also ignored deprecation, so marking a request or response field deprecated produced no change. A field that becomes deprecated is now reported with a new field_deprecated reason, rendered as **Deprecated**. [skip changelog] --- .../changes_field_deprecated_test.snap | 83 +++++++++++ .../changes_server_sent_events_test.snap | 136 ++++++++++++++++++ pkg/changes/changes.go | 6 + pkg/changes/changes_field_deprecated_test.go | 84 +++++++++++ .../changes_server_sent_events_test.go | 135 +++++++++++++++++ pkg/changes/diff_typedefs.go | 13 +- pkg/changes/markdown.go | 40 +++--- pkg/changes/types.go | 1 + 8 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 pkg/changes/__snapshots__/changes_field_deprecated_test.snap create mode 100644 pkg/changes/__snapshots__/changes_server_sent_events_test.snap create mode 100644 pkg/changes/changes_field_deprecated_test.go create mode 100644 pkg/changes/changes_server_sent_events_test.go diff --git a/pkg/changes/__snapshots__/changes_field_deprecated_test.snap b/pkg/changes/__snapshots__/changes_field_deprecated_test.snap new file mode 100644 index 000000000..93b99e676 --- /dev/null +++ b/pkg/changes/__snapshots__/changes_field_deprecated_test.snap @@ -0,0 +1,83 @@ + +[TestChanges_FieldChanges_FieldDeprecated - 1] +# Test: field_deprecated + +## Old Spec: +```yaml +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /widgets: + post: + operationId: updateWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + color: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + nickname: + type: string +``` + +## New Spec: +```yaml +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /widgets: + post: + operationId: updateWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + color: + type: string + deprecated: true + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + nickname: + type: string + deprecated: true +``` + +## Compact Mode: +## Typescript SDK Changes: +* `sdk.updateWidget()`: + * `request.color` **Deprecated** + * `response.nickname` **Deprecated** + +## Full Mode: +## Typescript SDK Changes: +* `sdk.updateWidget()`: + * `request.color` **Deprecated** + * `response.nickname` **Deprecated** + +--- diff --git a/pkg/changes/__snapshots__/changes_server_sent_events_test.snap b/pkg/changes/__snapshots__/changes_server_sent_events_test.snap new file mode 100644 index 000000000..ac4633ba7 --- /dev/null +++ b/pkg/changes/__snapshots__/changes_server_sent_events_test.snap @@ -0,0 +1,136 @@ + +[TestChanges_MethodChanges_ServerSentEvents - 1] +# Test: server_sent_events + +## Old Spec: +```yaml +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /reports: + post: + operationId: generateReport + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReportRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' +components: + schemas: + ReportRequest: + type: object + required: [format] + properties: + format: + type: string + enum: [pdf, csv] + Report: + type: object + properties: + id: + type: string + ReportEvent: + type: object + properties: + data: + type: string +``` + +## New Spec: +```yaml +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /reports: + post: + operationId: generateReport + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReportRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' + /widgets: + post: + operationId: createWidget + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' + /events: + get: + operationId: tailEvents + responses: + '200': + description: Success + content: + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' +components: + schemas: + ReportRequest: + type: object + required: [format] + properties: + format: + type: string + enum: [pdf, csv, html] + Report: + type: object + properties: + id: + type: string + ReportEvent: + type: object + properties: + data: + type: string +``` + +## Compact Mode: +## Python SDK Changes: +* `sdk.create_widget()`: **Added** +* `sdk.tail_events()`: **Added** +* `sdk.generate_report()`: + * `request.format.enum(html)` **Added** + +## Full Mode: +## Python SDK Changes: +* `sdk.create_widget()`: **Added** +* `sdk.tail_events()`: **Added** +* `sdk.generate_report()`: + * `request.format.enum(html)` **Added** + +--- diff --git a/pkg/changes/changes.go b/pkg/changes/changes.go index b5bf4ff04..f65e9bd62 100644 --- a/pkg/changes/changes.go +++ b/pkg/changes/changes.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + generationaccess "github.com/speakeasy-api/generation-context/access" "github.com/speakeasy-api/openapi-generation/v2/internal/analytics" "github.com/speakeasy-api/openapi-generation/v2/internal/ast" "github.com/speakeasy-api/openapi-generation/v2/internal/document" @@ -107,6 +108,11 @@ func resolveAST(ctx context.Context, config GenerateOptions) (*ast.AST, *subsyst if config.Logger == nil { config.Logger = logging.NewLogger(zapcore.DebugLevel) } + // The generator skips operations that use feature-gated constructs, such as + // server-sent event responses, when no generation access state is present. + if _, ok := generationaccess.StateFromContext(ctx); !ok { + ctx = generationaccess.WithDirect(ctx) + } // Check if schema file exists if _, err := os.Stat(config.SchemaPath); err != nil { // If file doesn't exist, return nil AST and nil Subsystem diff --git a/pkg/changes/changes_field_deprecated_test.go b/pkg/changes/changes_field_deprecated_test.go new file mode 100644 index 000000000..bed878e6b --- /dev/null +++ b/pkg/changes/changes_field_deprecated_test.go @@ -0,0 +1,84 @@ +package changes + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestChanges_FieldChanges_FieldDeprecated(t *testing.T) { + oldSpec := `openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /widgets: + post: + operationId: updateWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + color: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + nickname: + type: string` + + newSpec := `openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /widgets: + post: + operationId: updateWidget + requestBody: + content: + application/json: + schema: + type: object + properties: + color: + type: string + deprecated: true + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + id: + type: string + nickname: + type: string + deprecated: true` + + lang := "typescript" + oldConfig, newConfig := createTestConfigs(t, testSpecPair{ + oldSpec: oldSpec, + newSpec: newSpec, + lang: lang, + }) + + diff, err := Changes(t.Context(), oldConfig, newConfig) + if err != nil { + t.Fatalf("failed to get changes. error: %s", err.Error()) + } + + snapshot := formatTestSnapshotWithDiff("field_deprecated", oldSpec, newSpec, diff) + snaps.MatchSnapshot(t, snapshot) +} diff --git a/pkg/changes/changes_server_sent_events_test.go b/pkg/changes/changes_server_sent_events_test.go new file mode 100644 index 000000000..6d0b759a8 --- /dev/null +++ b/pkg/changes/changes_server_sent_events_test.go @@ -0,0 +1,135 @@ +package changes + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestChanges_MethodChanges_ServerSentEvents(t *testing.T) { + oldSpec := `openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /reports: + post: + operationId: generateReport + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReportRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' +components: + schemas: + ReportRequest: + type: object + required: [format] + properties: + format: + type: string + enum: [pdf, csv] + Report: + type: object + properties: + id: + type: string + ReportEvent: + type: object + properties: + data: + type: string` + + newSpec := `openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /reports: + post: + operationId: generateReport + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReportRequest' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' + /widgets: + post: + operationId: createWidget + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' + /events: + get: + operationId: tailEvents + responses: + '200': + description: Success + content: + text/event-stream: + schema: + $ref: '#/components/schemas/ReportEvent' +components: + schemas: + ReportRequest: + type: object + required: [format] + properties: + format: + type: string + enum: [pdf, csv, html] + Report: + type: object + properties: + id: + type: string + ReportEvent: + type: object + properties: + data: + type: string` + + lang := "python" + oldConfig, newConfig := createTestConfigs(t, testSpecPair{ + oldSpec: oldSpec, + newSpec: newSpec, + lang: lang, + }) + + diff, err := Changes(t.Context(), oldConfig, newConfig) + if err != nil { + t.Fatalf("failed to get changes. error: %s", err.Error()) + } + + snapshot := formatTestSnapshotWithDiff("server_sent_events", oldSpec, newSpec, diff) + snaps.MatchSnapshot(t, snapshot) +} diff --git a/pkg/changes/diff_typedefs.go b/pkg/changes/diff_typedefs.go index f951e51d4..2f1df43b1 100644 --- a/pkg/changes/diff_typedefs.go +++ b/pkg/changes/diff_typedefs.go @@ -132,13 +132,20 @@ func diffFieldsShallow(params *DiffTypeDefsParams) []shallowDiffResult { // Check field metadata changes equal, breaking := diffFieldDefs(aField, bField, params) diff := TypeDefDiffResult{Equal: true} - if !equal { + switch { + case !equal: diff = TypeDefDiffResult{ Equal: false, Path: childPath, Reason: DiffReasonFieldChanged, IsBreaking: breaking, } + case !isFieldDeprecated(aField) && isFieldDeprecated(bField): + diff = TypeDefDiffResult{ + Equal: false, + Path: childPath, + Reason: DiffReasonFieldDeprecated, + } } // Always create child params for recursion @@ -347,6 +354,10 @@ func diffEnumsShallow(params *DiffTypeDefsParams) []shallowDiffResult { return results } +func isFieldDeprecated(field *ast.FieldDef) bool { + return field.Comments != nil && field.Comments.Deprecated +} + // diffFieldDefs compares two field definitions and returns whether they're equal and if any change is breaking func diffFieldDefs(a, b *ast.FieldDef, params *DiffTypeDefsParams) (equal bool, isBreaking bool) { if a == nil || b == nil { diff --git a/pkg/changes/markdown.go b/pkg/changes/markdown.go index a5a9ef2b0..6e26fd50f 100644 --- a/pkg/changes/markdown.go +++ b/pkg/changes/markdown.go @@ -297,7 +297,7 @@ func formatDiffCompact(result TypeDefDiffResult, prefix string, lang string, det // This is a leaf node - report it if it's a meaningful change if diff.Reason == DiffReasonFieldAdded || diff.Reason == DiffReasonFieldRemoved || - diff.Reason == DiffReasonFieldChanged || diff.Reason == DiffReasonKind || + diff.Reason == DiffReasonFieldChanged || diff.Reason == DiffReasonFieldDeprecated || diff.Reason == DiffReasonKind || diff.Reason == DiffReasonUnionOptionAdded || diff.Reason == DiffReasonUnionOptionRemoved || diff.Reason == DiffReasonUnionOptionChanged || diff.Reason == DiffReasonUnionChanged || diff.Reason == DiffReasonUnionDiscriminatorAdded || @@ -310,17 +310,7 @@ func formatDiffCompact(result TypeDefDiffResult, prefix string, lang string, det } leafPathStr := formatDiffPath(relativePath, lang) - var action string - switch diff.Reason { - case DiffReasonFieldAdded, DiffReasonUnionOptionAdded, DiffReasonEnumValueAdded: - action = "**Added**" - case DiffReasonFieldRemoved, DiffReasonUnionOptionRemoved, DiffReasonEnumValueRemoved: - action = "**Removed**" - default: - action = "**Changed**" - } - - partStr := "`" + leafPathStr + "` " + action + partStr := "`" + leafPathStr + "` " + formatDiffAction(diff.Reason) if diff.IsBreaking { partStr += " (Breaking ⚠️)" } @@ -354,19 +344,8 @@ func formatDiffCompact(result TypeDefDiffResult, prefix string, lang string, det } } - // Generate action based on reason - var action string - switch result.Reason { - case DiffReasonFieldAdded, DiffReasonUnionOptionAdded, DiffReasonEnumValueAdded: - action = "**Added**" - case DiffReasonFieldRemoved, DiffReasonUnionOptionRemoved, DiffReasonEnumValueRemoved: - action = "**Removed**" - default: - action = "**Changed**" - } - result_str := " " + "`" + pathStr + "`" - result_str += " " + action + result_str += " " + formatDiffAction(result.Reason) if result.IsBreaking { result_str += " (Breaking ⚠️)" } @@ -374,6 +353,19 @@ func formatDiffCompact(result TypeDefDiffResult, prefix string, lang string, det return result_str } +func formatDiffAction(reason DiffReason) string { + switch reason { + case DiffReasonFieldAdded, DiffReasonUnionOptionAdded, DiffReasonEnumValueAdded: + return "**Added**" + case DiffReasonFieldRemoved, DiffReasonUnionOptionRemoved, DiffReasonEnumValueRemoved: + return "**Removed**" + case DiffReasonFieldDeprecated: + return "**Deprecated**" + default: + return "**Changed**" + } +} + // formatDiffPath formats a path into a human-readable string, language-specific func formatDiffPath(path []PathSegment, lang string) string { if len(path) == 0 { diff --git a/pkg/changes/types.go b/pkg/changes/types.go index 29ad698e9..b17395eaf 100644 --- a/pkg/changes/types.go +++ b/pkg/changes/types.go @@ -44,6 +44,7 @@ const ( DiffReasonFieldAdded DiffReason = "field_added" // Field was added DiffReasonFieldRemoved DiffReason = "field_removed" // Field was removed DiffReasonFieldChanged DiffReason = "field_changed" // Single field changed + DiffReasonFieldDeprecated DiffReason = "field_deprecated" // Field was marked deprecated DiffReasonFieldsChanged DiffReason = "fields_changed" // Multiple fields changed DiffReasonUnionOptionAdded DiffReason = "union_option_added" // Union option was added DiffReasonUnionOptionRemoved DiffReason = "union_option_removed" // Union option was removed From 3993b62a2053edd2b56259aee87cc702190578a9 Mon Sep 17 00:00:00 2001 From: David Alberto Adler Date: Fri, 23 Oct 2026 18:13:41 +0100 Subject: [PATCH 2/2] fix(all): cover deprecation precedence in field diff test --- .../__snapshots__/changes_field_deprecated_test.snap | 12 ++++++++++-- pkg/changes/changes_field_deprecated_test.go | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/changes/__snapshots__/changes_field_deprecated_test.snap b/pkg/changes/__snapshots__/changes_field_deprecated_test.snap index 93b99e676..823df6120 100644 --- a/pkg/changes/__snapshots__/changes_field_deprecated_test.snap +++ b/pkg/changes/__snapshots__/changes_field_deprecated_test.snap @@ -32,6 +32,8 @@ paths: type: string nickname: type: string + label: + type: string ``` ## New Spec: @@ -66,18 +68,24 @@ paths: nickname: type: string deprecated: true + label: + type: string + nullable: true + deprecated: true ``` ## Compact Mode: ## Typescript SDK Changes: * `sdk.updateWidget()`: * `request.color` **Deprecated** - * `response.nickname` **Deprecated** + * `response` **Changed** (Breaking ⚠️) ## Full Mode: ## Typescript SDK Changes: * `sdk.updateWidget()`: * `request.color` **Deprecated** - * `response.nickname` **Deprecated** + * `response` **Changed** (Breaking ⚠️) + - `label` **Changed** (Breaking ⚠️) + - `nickname` **Deprecated** --- diff --git a/pkg/changes/changes_field_deprecated_test.go b/pkg/changes/changes_field_deprecated_test.go index bed878e6b..0da8fe007 100644 --- a/pkg/changes/changes_field_deprecated_test.go +++ b/pkg/changes/changes_field_deprecated_test.go @@ -34,6 +34,8 @@ paths: id: type: string nickname: + type: string + label: type: string` newSpec := `openapi: 3.0.0 @@ -65,6 +67,10 @@ paths: type: string nickname: type: string + deprecated: true + label: + type: string + nullable: true deprecated: true` lang := "typescript"