From 5b7d01b36481a413e487032e36986a102ad7f56b Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Mon, 17 Aug 2026 16:50:23 -0700 Subject: [PATCH 1/2] fix(sdk-generator): deserialize nullable $ref responses GET /tasks/{task}/lease is allOf+$ref+nullable. Python/Go/Swift classified that as an undeserialized shape and aborted generation. Unwrap outer nullability so the inner model still deserializes, and accept JSON null at runtime. --- .../__tests__/backends/python.test.ts | 58 +++++++++++++++++++ .../backends/contract-tests/python-emitter.ts | 40 +++++++++---- .../src/backends/go/resource-emitter.ts | 9 ++- .../src/backends/python/response-type.ts | 40 ++++++++++--- .../src/backends/swift/resource-emitter.ts | 20 +++++-- 5 files changed, 141 insertions(+), 26 deletions(-) diff --git a/packages/sdk-generator/__tests__/backends/python.test.ts b/packages/sdk-generator/__tests__/backends/python.test.ts index a007167..519ca3d 100644 --- a/packages/sdk-generator/__tests__/backends/python.test.ts +++ b/packages/sdk-generator/__tests__/backends/python.test.ts @@ -495,6 +495,27 @@ describe("Python resource emitter passes response_type for typed responses", () }, }, }, + "/api/v1/widgets/{widget}/lease": { + get: { + operationId: "get_api_v1_widgets_widget_lease", + parameters: [ + { name: "widget", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + description: "Live lease summary, or null.", + content: { + "application/json": { + schema: { + allOf: [{ $ref: "#/components/schemas/Widget" }], + nullable: true, + }, + }, + }, + }, + }, + }, + }, "/api/v1/widgets/paged": { get: { operationId: "get_api_v1_widgets_paged", @@ -550,6 +571,15 @@ describe("Python resource emitter passes response_type for typed responses", () ); }); + it("deserializes nullable $ref responses as Model | None", () => { + expect(output).toContain( + "async def lease(self, widget: str) -> Optional[Widget]:" + ); + expect(output).toContain( + "response_type=Widget | None" + ); + }); + it("passes list[Model] for array-of-$ref responses", () => { expect(output).toContain( 'return await self._http.request(f"/api/v1/widgets", response_type=list[Widget])' @@ -594,6 +624,15 @@ describe("Python resource emitter passes response_type for typed responses", () ); }); + it("accepts None or the model for nullable $ref responses", () => { + expect(content).toContain( + "assert result is None or isinstance(result, BaseModel)" + ); + expect(content).toContain( + 'assert result is None or type(result).__name__ == "Widget"' + ); + }); + it("asserts lists of concrete model instances for list responses", () => { expect(content).toContain("assert isinstance(result, list)"); expect(content).toContain( @@ -635,6 +674,25 @@ describe("Python resource emitter passes response_type for typed responses", () }) ).toThrow(/not deserialized/); }); + + it("classifies a nullable schema-ref response as a deserialized model", () => { + expect( + pythonResponseShape({ + name: "get_lease", + operationId: "get_api_v1_tasks__task_lease", + method: "GET", + path: "/api/v1/tasks/{task}/lease", + deprecated: false, + pathParams: [], + queryParams: [], + returnType: { + kind: "nullable", + inner: { kind: "ref", schema: "TaskSessionLeaseSummary" }, + }, + errors: [], + }) + ).toBe("model"); + }); }); describe("Python contract tests include raw response operations", () => { 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 2293aac..bd29163 100644 --- a/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts +++ b/packages/sdk-generator/src/backends/contract-tests/python-emitter.ts @@ -5,6 +5,8 @@ import { pythonParameterName, uniquePythonParameterNames } from "../python/ident import { pythonInlineResponseName, pythonResponseShape, + responseAllowsNull, + unwrapNullability, } from "../python/response-type.js"; import { buildMethodCalls, @@ -347,19 +349,37 @@ function emitResultAssertions( cb.line('assert result["mime_type"]'); break; case "model": { - cb.line("assert isinstance(result, BaseModel)"); - cb.line(`assert type(result).__name__ == "${modelClassName(call)}"`); - if (returnTypeHasDataArray(call.operation.returnType)) { - cb.line("assert isinstance(result.data, list)"); + if (responseAllowsNull(call.operation.returnType)) { + cb.line("assert result is None or isinstance(result, BaseModel)"); + cb.line( + `assert result is None or type(result).__name__ == "${modelClassName(call)}"` + ); + } else { + cb.line("assert isinstance(result, BaseModel)"); + cb.line(`assert type(result).__name__ == "${modelClassName(call)}"`); + } + if (returnTypeHasDataArray(unwrapNullability(call.operation.returnType))) { + cb.line( + responseAllowsNull(call.operation.returnType) + ? "assert result is None or isinstance(result.data, list)" + : "assert isinstance(result.data, list)" + ); } break; } case "model_list": { const itemName = listItemClassName(call); - cb.line("assert isinstance(result, list)"); - cb.line( - `assert all(type(item).__name__ == "${itemName}" for item in result)` - ); + if (responseAllowsNull(call.operation.returnType)) { + cb.line("assert result is None or isinstance(result, list)"); + cb.line( + `assert result is None or all(type(item).__name__ == "${itemName}" for item in result)` + ); + } else { + cb.line("assert isinstance(result, list)"); + cb.line( + `assert all(type(item).__name__ == "${itemName}" for item in result)` + ); + } break; } case "untyped": @@ -369,13 +389,13 @@ function emitResultAssertions( } function modelClassName(call: MethodCallInfo): string { - const ret = call.operation.returnType; + const ret = unwrapNullability(call.operation.returnType); if (ret.kind === "ref") return ret.schema; return pythonInlineResponseName(call.resource.className, call.operation.name); } function listItemClassName(call: MethodCallInfo): string { - const ret = call.operation.returnType; + const ret = unwrapNullability(call.operation.returnType); if (ret.kind === "array" && ret.items.kind === "ref") return ret.items.schema; throw new Error( `[sdk-generator] ${call.operation.operationId}: model_list response ` + diff --git a/packages/sdk-generator/src/backends/go/resource-emitter.ts b/packages/sdk-generator/src/backends/go/resource-emitter.ts index 5d0d35c..1700b54 100644 --- a/packages/sdk-generator/src/backends/go/resource-emitter.ts +++ b/packages/sdk-generator/src/backends/go/resource-emitter.ts @@ -26,6 +26,7 @@ import { goParamsStructName, goResponseShape, } from "./response-type.js"; +import { unwrapNullability } from "../python/response-type.js"; import { goFieldType, goQueryStringExpr, @@ -389,11 +390,13 @@ export function goReturnType( return ""; case "raw": return "*RawResponse"; - case "model": - if (op.returnType.kind === "ref") return `*${resolveRef(op.returnType.schema)}`; + case "model": { + const inner = unwrapNullability(op.returnType); + if (inner.kind === "ref") return `*${resolveRef(inner.schema)}`; return `*${registry.lookup(responseKey(op))}`; + } case "model_list": { - const ret = op.returnType; + const ret = unwrapNullability(op.returnType); if (ret.kind === "array" && ret.items.kind === "ref") { return `[]${resolveRef(ret.items.schema)}`; } diff --git a/packages/sdk-generator/src/backends/python/response-type.ts b/packages/sdk-generator/src/backends/python/response-type.ts index 02b1b69..75037cf 100644 --- a/packages/sdk-generator/src/backends/python/response-type.ts +++ b/packages/sdk-generator/src/backends/python/response-type.ts @@ -22,9 +22,23 @@ export type PythonResponseShape = | "model_list" | "untyped"; +/** Peel a single outer `nullable` / `optional` wrapper. */ +export function unwrapNullability(ref: TypeRef): TypeRef { + let current = ref; + while (current.kind === "nullable" || current.kind === "optional") { + current = current.inner; + } + return current; +} + +/** True when the operation may legally return JSON null. */ +export function responseAllowsNull(ref: TypeRef): boolean { + return ref.kind === "nullable" || ref.kind === "optional"; +} + export function pythonResponseShape(op: OperationDef): PythonResponseShape { if (op.rawResponse) return "raw"; - const ret = op.returnType; + const ret = unwrapNullability(op.returnType); switch (ret.kind) { case "void": return "void"; @@ -94,9 +108,13 @@ export function pythonResponseTypeExpr( inlineResponseName: string | undefined ): string | undefined { switch (pythonResponseShape(op)) { - case "model": - if (op.returnType.kind === "ref") return op.returnType.schema; - if (!inlineResponseName) { + case "model": { + const inner = unwrapNullability(op.returnType); + const name = + inner.kind === "ref" + ? inner.schema + : inlineResponseName; + if (!name) { throw new Error( `[sdk-generator] ${op.operationId}: classified as "model" (inline ` + `object response) but no hoisted BaseModel name was provided; ` + @@ -104,12 +122,18 @@ export function pythonResponseTypeExpr( `pythonResponseShape.` ); } - return inlineResponseName; - case "model_list": - if (op.returnType.kind === "array" && op.returnType.items.kind === "ref") { - return `list[${op.returnType.items.schema}]`; + // TypeAdapter(Model) rejects JSON null; the union is what the runtime + // actually deserializes for OAS nullable $ref responses. + return responseAllowsNull(op.returnType) ? `${name} | None` : name; + } + case "model_list": { + const inner = unwrapNullability(op.returnType); + if (inner.kind === "array" && inner.items.kind === "ref") { + const listType = `list[${inner.items.schema}]`; + return responseAllowsNull(op.returnType) ? `${listType} | None` : listType; } return undefined; + } default: return undefined; } diff --git a/packages/sdk-generator/src/backends/swift/resource-emitter.ts b/packages/sdk-generator/src/backends/swift/resource-emitter.ts index b981437..7bb8d10 100644 --- a/packages/sdk-generator/src/backends/swift/resource-emitter.ts +++ b/packages/sdk-generator/src/backends/swift/resource-emitter.ts @@ -19,6 +19,10 @@ import { swiftInlineResponseName, swiftResponseShape, } from "./response-type.js"; +import { + responseAllowsNull, + unwrapNullability, +} from "../python/response-type.js"; import { swiftQueryStringExpr, typeRefToSwift, @@ -255,13 +259,19 @@ export function swiftReturnType( return undefined; case "raw": return "RawResponse"; - case "model": - if (op.returnType.kind === "ref") return resolveRef(op.returnType.schema); - return registry.lookup(responseKey(op)); + case "model": { + const inner = unwrapNullability(op.returnType); + const name = + inner.kind === "ref" + ? resolveRef(inner.schema) + : registry.lookup(responseKey(op)); + return responseAllowsNull(op.returnType) ? `${name}?` : name; + } case "model_list": { - const ret = op.returnType; + const ret = unwrapNullability(op.returnType); if (ret.kind === "array" && ret.items.kind === "ref") { - return `[${resolveRef(ret.items.schema)}]`; + const list = `[${resolveRef(ret.items.schema)}]`; + return responseAllowsNull(op.returnType) ? `${list}?` : list; } return "JSONValue"; } From 653f2f94c218971f501fe88f8cd710b890e4fb66 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Mon, 17 Aug 2026 17:03:11 -0700 Subject: [PATCH 2/2] fix(sdk-generator): emit JSON dicts for Swift channel join objects Join methods type inline objects as [String: JSONValue]. Contract tests were constructing hoisted LocalToolsItem structs that do not exist. --- .../__tests__/backends/swift.test.ts | 61 +++++++++++++++++++ .../contract-tests/channel-emitter-swift.ts | 4 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/sdk-generator/__tests__/backends/swift.test.ts b/packages/sdk-generator/__tests__/backends/swift.test.ts index 14f7aa7..0fa33b3 100644 --- a/packages/sdk-generator/__tests__/backends/swift.test.ts +++ b/packages/sdk-generator/__tests__/backends/swift.test.ts @@ -13,6 +13,7 @@ import { swiftQueryStringExpr, } from "../../src/backends/swift/type-map.js"; import { emitSwiftContractTests } from "../../src/backends/contract-tests/swift-emitter.js"; +import { emitSwiftChannelContractTestFile } from "../../src/backends/contract-tests/channel-emitter-swift.js"; import type { SchemaDef } from "../../src/ast/types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -267,4 +268,64 @@ describe("swift contract tests emitter", () => { const hasErrorTest = /error_\d{3}\(\) async throws/.test(combined); expect(hasErrorTest).toBe(true); }); + + it("uses JSON dictionaries for inline channel join objects", () => { + const output = emitSwiftChannelContractTestFile( + { + name: "api_chat", + className: "ApiChatChannel", + joins: [ + { + topicPattern: "api:chat:user:thread:{thread_id}", + params: [ + { + name: "thread_id", + type: { kind: "primitive", type: "string" }, + required: true, + }, + { + name: "local_tools", + type: { + kind: "array", + items: { + kind: "object", + fields: [ + { + name: "type", + type: { kind: "primitive", type: "string" }, + required: true, + }, + { + name: "function", + type: { + kind: "object", + fields: [ + { + name: "name", + type: { kind: "primitive", type: "string" }, + required: true, + }, + ], + }, + required: true, + }, + ], + }, + }, + required: false, + }, + ], + returnType: { kind: "unknown" }, + }, + ], + messages: [], + pushes: [], + }, + new SwiftNameRegistry() + ); + expect(output).not.toContain("LocalToolsItem"); + expect(output).toContain( + 'localTools: [["type": "test", "function": ["name": "test-name"]]]' + ); + }); }); diff --git a/packages/sdk-generator/src/backends/contract-tests/channel-emitter-swift.ts b/packages/sdk-generator/src/backends/contract-tests/channel-emitter-swift.ts index 1586056..bdb04fa 100644 --- a/packages/sdk-generator/src/backends/contract-tests/channel-emitter-swift.ts +++ b/packages/sdk-generator/src/backends/contract-tests/channel-emitter-swift.ts @@ -118,7 +118,9 @@ function joinCall( /** Typed value for a join payload parameter (typed method signature). */ function swiftPayloadValue(param: ParamDef): string { - return swiftTypedValue(param.type, param.name, pascalCase(param.name), []); + // Join methods type inline objects as `[String: JSONValue]`, not hoisted + // structs. Dummy values must be JSON dictionaries or they will not compile. + return swiftTypedValue(param.type, param.name, pascalCase(param.name), [], "json"); } function testPrefix(channel: ChannelDef, suffix: string): string {