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
29 changes: 28 additions & 1 deletion packages/sdk-generator/__tests__/backends/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk-generator/src/backends/go/response-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {
* - "model_list" → `[]Model`
* - "untyped" → `JSONValue`
*/
export type GoResponseShape = PythonResponseShape;
export type GoResponseShape = Exclude<PythonResponseShape, "model_union">;

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;
}

/**
Expand Down
49 changes: 41 additions & 8 deletions packages/sdk-generator/src/backends/python/response-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +22,7 @@ export type PythonResponseShape =
| "raw"
| "model"
| "model_list"
| "model_union"
| "untyped";

/** Peel a single outer `nullable` / `optional` wrapper. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk-generator/src/backends/swift/response-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {
* - "model_list" → `[Model]`
* - "untyped" → `JSONValue`
*/
export type SwiftResponseShape = PythonResponseShape;
export type SwiftResponseShape = Exclude<PythonResponseShape, "model_union">;

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;
}

/**
Expand Down
Loading