From 26e7b60334bc52be248a9f0da7d93c409549fcb8 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Sun, 1 Nov 2026 05:17:55 -0700 Subject: [PATCH] fix(sdk-generator): deserialize ref-or-object union responses in Python Python generation threw for GET /orgs/{org}/task_epics, whose 200 is a union of an inline summary object and a $ref collection (selected by the representation query param). Add a model_union response shape validated as `Model | dict[str, Any]`; Go and Swift keep treating it as untyped JSON. Pure $ref unions still fail generation. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/backends/python.test.ts | 29 ++++++++++- .../backends/contract-tests/python-emitter.ts | 9 +++- .../src/backends/go/response-type.ts | 6 ++- .../src/backends/python/response-type.ts | 49 ++++++++++++++++--- .../src/backends/swift/response-type.ts | 6 ++- 5 files changed, 85 insertions(+), 14 deletions(-) diff --git a/packages/sdk-generator/__tests__/backends/python.test.ts b/packages/sdk-generator/__tests__/backends/python.test.ts index c76517b..a7221a3 100644 --- a/packages/sdk-generator/__tests__/backends/python.test.ts +++ b/packages/sdk-generator/__tests__/backends/python.test.ts @@ -16,7 +16,10 @@ import { } from "../../src/backends/python/typeddict-emitter.js"; import { CodeBuilder } from "../../src/utils/codegen.js"; import { emitPythonContractTests } from "../../src/backends/contract-tests/python-emitter.js"; -import { pythonResponseShape } from "../../src/backends/python/response-type.js"; +import { + pythonResponseShape, + pythonResponseTypeExpr, +} from "../../src/backends/python/response-type.js"; import { emitPythonChannelContractTestFile } from "../../src/backends/contract-tests/channel-emitter-python.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -776,6 +779,30 @@ describe("Python resource emitter passes response_type for typed responses", () ).toThrow(/not deserialized/); }); + it("classifies a union of a $ref and a plain object as model_union", () => { + const op = { + name: "list", + operationId: "get_api_v1_epics", + method: "GET", + path: "/api/v1/epics", + deprecated: false, + pathParams: [], + queryParams: [], + returnType: { + kind: "union" as const, + variants: [ + { kind: "object" as const, fields: [] }, + { kind: "ref" as const, schema: "EpicCollection" }, + ], + }, + errors: [], + }; + expect(pythonResponseShape(op)).toBe("model_union"); + expect(pythonResponseTypeExpr(op, undefined)).toBe( + "EpicCollection | dict[str, Any]" + ); + }); + it("classifies a nullable schema-ref response as a deserialized model", () => { expect( pythonResponseShape({ diff --git a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts index bd29163..592cbd7 100644 --- a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts +++ b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts @@ -197,7 +197,7 @@ function emitResourceTestFile( cb.line("from archastro.platform import AsyncPlatformClient, PlatformClient"); cb.line("from archastro.platform.runtime.http_client import ApiError"); const needsBaseModel = calls.some((call) => - ["model", "model_list"].includes(pythonResponseShape(call.operation)) + ["model", "model_list", "model_union"].includes(pythonResponseShape(call.operation)) ); if (needsBaseModel) { cb.line("from pydantic import BaseModel"); @@ -382,6 +382,13 @@ function emitResultAssertions( } break; } + case "model_union": + cb.line( + responseAllowsNull(call.operation.returnType) + ? "assert result is None or isinstance(result, (BaseModel, dict))" + : "assert isinstance(result, (BaseModel, dict))" + ); + break; case "untyped": cb.line("assert result is not None"); break; diff --git a/packages/sdk-generator/src/backends/go/response-type.ts b/packages/sdk-generator/src/backends/go/response-type.ts index b7d6865..7b113b0 100644 --- a/packages/sdk-generator/src/backends/go/response-type.ts +++ b/packages/sdk-generator/src/backends/go/response-type.ts @@ -17,10 +17,12 @@ import { * - "model_list" → `[]Model` * - "untyped" → `JSONValue` */ -export type GoResponseShape = PythonResponseShape; +export type GoResponseShape = Exclude; export function goResponseShape(op: OperationDef): GoResponseShape { - return pythonResponseShape(op); + const shape = pythonResponseShape(op); + // Unions of models decode as untyped JSON here. + return shape === "model_union" ? "untyped" : shape; } /** diff --git a/packages/sdk-generator/src/backends/python/response-type.ts b/packages/sdk-generator/src/backends/python/response-type.ts index 75037cf..2163a60 100644 --- a/packages/sdk-generator/src/backends/python/response-type.ts +++ b/packages/sdk-generator/src/backends/python/response-type.ts @@ -8,6 +8,8 @@ import { pascalCase } from "../../utils/naming.js"; * - "raw" — bytes via request_raw; stays a {content, mime_type} dict * - "model" — single Pydantic model (named $ref or hoisted inline object) * - "model_list" — list of Pydantic models + * - "model_union" — union of $ref models and plain objects (e.g. a + * `representation` switch); validated against the union * - "untyped" — anything else (scalars, bare dicts); stays raw JSON * * This classifier is shared by the resource emitter (which decides whether to @@ -20,6 +22,7 @@ export type PythonResponseShape = | "raw" | "model" | "model_list" + | "model_union" | "untyped"; /** Peel a single outer `nullable` / `optional` wrapper. */ @@ -50,23 +53,41 @@ export function pythonResponseShape(op: OperationDef): PythonResponseShape { return ret.fields.length > 0 ? "model" : "untyped"; case "array": return ret.items.kind === "ref" ? "model_list" : "untyped"; + case "union": + return isModelUnion(ret) ? "model_union" : unionRefError(op, ret); default: // A model buried in a shape we don't deserialize (union, optional, // nested array) would mean the return annotation promises a model the // runtime never constructs — the exact bug response_type fixes. Fail // generation so spec evolution cannot reintroduce it silently. - if (containsRef(ret)) { - throw new Error( - `[sdk-generator] ${op.operationId}: response shape "${ret.kind}" ` + - `contains a schema ref but is not deserialized (no response_type ` + - `emitted); the Python return annotation would not match runtime ` + - `behavior. Extend pythonResponseShape to cover this shape.` - ); - } + if (containsRef(ret)) return unionRefError(op, ret); return "untyped"; } } +/** + * A union of $ref models and plain objects (at least one of each), such as a + * list endpoint that returns summaries or full entities depending on a query + * parameter. Pure $ref unions are still rejected below. + */ +function isModelUnion(ref: TypeRef & { kind: "union" }): boolean { + return ( + ref.variants.some((v) => v.kind === "ref") && + ref.variants.some((v) => v.kind === "object") && + ref.variants.every((v) => v.kind === "ref" || v.kind === "object") + ); +} + +function unionRefError(op: OperationDef, ret: TypeRef): "untyped" { + if (!containsRef(ret)) return "untyped"; + throw new Error( + `[sdk-generator] ${op.operationId}: response shape "${ret.kind}" ` + + `contains a schema ref but is not deserialized (no response_type ` + + `emitted); the Python return annotation would not match runtime ` + + `behavior. Extend pythonResponseShape to cover this shape.` + ); +} + /** * Class name for the BaseModel hoisted from an operation's inline object * response. Single source of truth for the naming rule — the resource @@ -134,6 +155,18 @@ export function pythonResponseTypeExpr( } return undefined; } + case "model_union": { + const inner = unwrapNullability(op.returnType); + if (inner.kind !== "union") return undefined; + // $ref variants first so the model wins ties; plain objects stay dicts. + const refs = inner.variants.filter((v) => v.kind === "ref"); + const objects = inner.variants.some((v) => v.kind === "object") + ? ["dict[str, Any]"] + : []; + const names = [...new Set([...refs.map((v) => (v as { schema: string }).schema), ...objects])]; + if (responseAllowsNull(op.returnType)) names.push("None"); + return names.join(" | "); + } default: return undefined; } diff --git a/packages/sdk-generator/src/backends/swift/response-type.ts b/packages/sdk-generator/src/backends/swift/response-type.ts index d868ba5..22e6ba6 100644 --- a/packages/sdk-generator/src/backends/swift/response-type.ts +++ b/packages/sdk-generator/src/backends/swift/response-type.ts @@ -17,10 +17,12 @@ import { * - "model_list" → `[Model]` * - "untyped" → `JSONValue` */ -export type SwiftResponseShape = PythonResponseShape; +export type SwiftResponseShape = Exclude; export function swiftResponseShape(op: OperationDef): SwiftResponseShape { - return pythonResponseShape(op); + const shape = pythonResponseShape(op); + // Unions of models decode as untyped JSON here. + return shape === "model_union" ? "untyped" : shape; } /**