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
58 changes: 58 additions & 0 deletions packages/sdk-generator/__tests__/backends/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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])'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down
61 changes: 61 additions & 0 deletions packages/sdk-generator/__tests__/backends/swift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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"]]]'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { pythonParameterName, uniquePythonParameterNames } from "../python/ident
import {
pythonInlineResponseName,
pythonResponseShape,
responseAllowsNull,
unwrapNullability,
} from "../python/response-type.js";
import {
buildMethodCalls,
Expand Down Expand Up @@ -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":
Expand All @@ -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 ` +
Expand Down
9 changes: 6 additions & 3 deletions packages/sdk-generator/src/backends/go/resource-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
goParamsStructName,
goResponseShape,
} from "./response-type.js";
import { unwrapNullability } from "../python/response-type.js";
import {
goFieldType,
goQueryStringExpr,
Expand Down Expand Up @@ -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)}`;
}
Expand Down
40 changes: 32 additions & 8 deletions packages/sdk-generator/src/backends/python/response-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,22 +108,32 @@ 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; ` +
`the inline-response collection predicate has drifted from ` +
`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;
}
Expand Down
20 changes: 15 additions & 5 deletions packages/sdk-generator/src/backends/swift/resource-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
swiftInlineResponseName,
swiftResponseShape,
} from "./response-type.js";
import {
responseAllowsNull,
unwrapNullability,
} from "../python/response-type.js";
import {
swiftQueryStringExpr,
typeRefToSwift,
Expand Down Expand Up @@ -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";
}
Expand Down
Loading