From bef6cfbffe97d9b5f9f155e609cc2a08b5a36f45 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:08:26 +0000 Subject: [PATCH] feat(client): support opaque payload schemas (#37773) Co-authored-by: Aiden Cline --- packages/httpapi-codegen/README.md | 4 +- packages/httpapi-codegen/src/index.ts | 113 +++++++++++++----- packages/httpapi-codegen/test/fixture.ts | 12 ++ .../httpapi-codegen/test/generate.test.ts | 104 ++++++++++++++++ .../test/generated-consumer.ts | 9 +- .../httpapi-codegen/test/generated/session.ts | 45 +++++++ 6 files changed, 255 insertions(+), 32 deletions(-) diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md index 0d31b129b7..9f45ebc31f 100644 --- a/packages/httpapi-codegen/README.md +++ b/packages/httpapi-codegen/README.md @@ -14,7 +14,9 @@ The package is private while its API is explored. Its tests are the executable s - Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`. - Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically. -- Flatten path, query, header, and payload fields into one input object. +- Flatten path, query, header, and struct payload fields into one input object. +- Represent every non-struct payload (including unions, arrays, primitives, and records with index signatures) as one required `payload` input field and send that value as the request body directly. +- Reject an opaque `payload` field when its name collides with a path, query, or header field. - Reject duplicate field names across input channels. - Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required. - Unwrap exact `{ data: A }` success envelopes. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 5cefabd59f..337593117c 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -126,10 +126,18 @@ export function compile inputFields(item.schema, "payload", name)), ] - const names = new Set() + const names = new Map() for (const field of inputs) { - if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` }) - names.add(field.name) + const existing = names.get(field.name) + if (existing !== undefined) { + if (field.source === "payload" && payloads[0] !== undefined && !isFlattenableStruct(payloads[0].schema)) { + throw new GenerationError({ + reason: `Opaque payload field collision: ${name}.payload conflicts with ${existing}.${field.name}`, + }) + } + throw new GenerationError({ reason: `Input field collision: ${field.name}` }) + } + names.set(field.name, field.source) } const schemaPaths: Array = [ @@ -336,7 +344,7 @@ function renderEffectShape(groups: ReadonlyArray, options: { readonly mod const input = endpoint.input .map( (field) => - `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}]${isOpaquePayload(endpoint) && field.source === "payload" ? "" : `[${JSON.stringify(field.name)}]`}`, ) .join("; ") const inputType = endpoint.operation.inputMode === "none" ? "" : `export type ${prefix}Input = { ${input} }` @@ -474,26 +482,37 @@ function renderImportedEffectFiles( const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]` const methods = group.endpoints.map((item, endpointIndex) => { const prefix = `Endpoint${groupIndex}_${endpointIndex}` + const schemaBySource = { + params: item.params, + query: item.query, + headers: item.headers, + payload: item.payloads[0], + } const request = (["params", "query", "headers", "payload"] as const) - .flatMap((source) => { - const fields = item.input.filter((field) => field.source === source) - if (fields.length === 0) return [] - return [ - `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`, - ] - }) + .map((source) => + renderEffectRequestPart( + item.input, + item.operation.inputMode, + source, + isOpaquePayload(item), + schemaBySource[source] !== undefined, + ), + ) + .filter((part): part is string => part !== undefined) .join(", ") const input = item.input .map( (field) => - `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`, + `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}]${isOpaquePayload(item) && field.source === "payload" ? "" : `[${JSON.stringify(field.name)}]`}`, ) .join("; ") const argument = item.operation.inputMode === "none" ? "" : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` - const rawCall = `raw[${JSON.stringify(item.endpoint.identifier)}]({ ${request} })` + // HttpApiClient distributes union payloads into union request objects, while rebuilding a flattened input + // produces one object containing a union value. The shapes are equivalent but TypeScript cannot correlate them. + const rawCall = `raw[${JSON.stringify(item.endpoint.identifier)}]({ ${request} }${isOpaquePayload(item) ? ` as ${prefix}Request` : ""})` const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.identifier)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` }) @@ -623,7 +642,7 @@ function renderPromiseTypes( const schema = schemas[field.source] if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${isOpaquePayload(endpoint) && field.source === "payload" ? typeOf(schema) : `(${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`}` }) .join("; ") const successSchema = endpoint.successes[0] @@ -693,8 +712,12 @@ function renderPromiseClient(groups: ReadonlyArray) { const part = (source: InputField["source"]) => { const inputs = endpoint.input.filter((field) => field.source === source) return inputs.length === 0 - ? undefined - : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` + ? source === "payload" && endpoint.payloads.length > 0 + ? "{ }" + : undefined + : isOpaquePayload(endpoint) && source === "payload" + ? access(inputs[0].name) + : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` } const parts = [ endpoint.query === undefined ? undefined : `query: ${part("query")}`, @@ -1146,10 +1169,21 @@ export function generate write(output, options.directory))) } +function isFlattenableStruct(schema: Schema.Top) { + const ast = Schema.toType(schema).ast + return SchemaAST.isObjects(ast) && ast.indexSignatures.length === 0 +} + +function isOpaquePayload(endpoint: Endpoint) { + const payload = endpoint.payloads[0] + return payload !== undefined && !isFlattenableStruct(payload) +} + function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) { if (schema === undefined) return [] const ast = Schema.toType(schema).ast if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) { + if (source === "payload") return [{ name: "payload", source, optional: false }] as const throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` }) } return ast.propertySignatures.map((field) => { @@ -1416,7 +1450,7 @@ function renderGroup(group: Group, groupIndex: number) { if (slot === undefined) { throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.identifier}` }) } - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]` + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${isOpaquePayload(operation) && field.source === "payload" ? `typeof ${slot.name}.Type` : `(typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`}` }) .join("; ") const argument = @@ -1424,24 +1458,29 @@ function renderGroup(group: Group, groupIndex: number) { ? "" : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input` const request = (["params", "query", "headers", "payload"] as const) - .flatMap((source) => { - const slot = schemaBySource[source] - if (slot === undefined) return [] - const fields = operation.input - .filter((field) => field.source === source) - .map( - (field) => - `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`, - ) - return [`${source}: { ${fields.join(", ")} }`] - }) + .map((source) => + renderEffectRequestPart( + operation.input, + operation.operation.inputMode, + source, + isOpaquePayload(operation), + schemaBySource[source] !== undefined, + ), + ) + .filter((part): part is string => part !== undefined) .join(", ") const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])] const declaredSchema = declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])` - const rawCall = `raw[${JSON.stringify(endpoint.identifier)}]({ ${request} })` + const opaquePayload = isOpaquePayload(operation) + // HttpApiClient distributes union payloads into union request objects, while rebuilding a flattened input + // produces one object containing a union value. The shapes are equivalent but TypeScript cannot correlate them. + const rawCall = `raw[${JSON.stringify(endpoint.identifier)}]({ ${request} }${opaquePayload ? ` as ${prefix}Request` : ""})` const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})` - const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n` + const inputDeclaration = + operation.operation.inputMode === "none" + ? "" + : `${opaquePayload ? `type ${prefix}Request = Parameters[0]\n` : ""}type ${prefix}Input = { ${inputType} }\n` adapters.push( `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`, ) @@ -1491,6 +1530,20 @@ function renderGroup(group: Group, groupIndex: number) { return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n` } +function renderEffectRequestPart( + input: Endpoint["input"], + mode: Operation["inputMode"], + source: InputField["source"], + opaquePayload: boolean, + present = false, +) { + const fields = input.filter((field) => field.source === source) + if (fields.length === 0) return present ? `${source}: { }` : undefined + const access = (name: string) => `input${mode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + if (opaquePayload && source === "payload") return `${source}: ${access(fields[0].name)}` + return `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }` +} + function renderSchemas(slots: ReadonlyArray) { if (slots.length === 0) return "" const classes = new Map( diff --git a/packages/httpapi-codegen/test/fixture.ts b/packages/httpapi-codegen/test/fixture.ts index 9fb7fedda1..3dc3cc694d 100644 --- a/packages/httpapi-codegen/test/fixture.ts +++ b/packages/httpapi-codegen/test/fixture.ts @@ -27,6 +27,18 @@ export const Api = HttpApi.make("fixture") params: { sessionID: Schema.String }, success: HttpApiSchema.NoContent, }), + ) + .add( + HttpApiEndpoint.post("configure", "/session/:sessionID/configure", { + params: { sessionID: Schema.String }, + query: { dryRun: Schema.optional(Schema.Boolean) }, + headers: { traceID: Schema.String }, + payload: Schema.Union([ + Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }), + Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }), + ]), + success: Schema.String, + }), ), ) .add( diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 7797b50953..0322157cbd 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -773,6 +773,42 @@ describe("HttpApiCodegen.generate", () => { } }) + test("serializes an opaque union payload as the direct JSON body", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.post("configure", "/session", { + payload: Schema.Union([ + Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }), + Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }), + ]), + success: HttpApiSchema.NoContent, + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return new Response(null, { status: 204 }) + }, + }) + + await client.session.configure({ payload: { type: "local", command: ["opencode"] } }) + + expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("serializes explicit null query values", async () => { const output = emitPromise( compileContract( @@ -971,6 +1007,74 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("uses one opaque field for non-struct payloads across emitters", () => { + const source = api( + HttpApiEndpoint.post("configure", "/session/configure", { + payload: Schema.Union([ + Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }), + Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }), + ]), + success: Schema.String, + }), + ) + const contract = compileContract(source) + const effect = emitEffect(contract) + const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" }) + const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" }) + const promise = emitPromise(contract) + + expect(effect.operations[0]).toMatchObject({ + input: [{ name: "payload", source: "payload" }], + inputMode: "required", + }) + expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain('payload: input["payload"]') + expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain('payload: input["payload"]') + expect(shape.files[0]?.content).toContain('Endpoint0_0Request["payload"]') + expect(promise.files.find((file) => file.path === "types.ts")?.content).toContain( + 'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray } | { readonly "type": "remote", readonly "url": string }', + ) + expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain('body: input["payload"]') + }) + + test("routes arrays, primitives, and index-signature records through the opaque payload path", () => { + for (const payload of [Schema.Array(Schema.String), Schema.String, Schema.Record(Schema.String, Schema.Number)]) { + expect( + compileContract(api(HttpApiEndpoint.post("set", "/session", { payload, success: HttpApiSchema.NoContent }))) + .groups[0]?.endpoints[0]?.operation.input, + ).toEqual([{ name: "payload", source: "payload" }]) + } + }) + + test("rejects an opaque payload field that collides with another input channel", () => { + expect(() => + compileContract( + api( + HttpApiEndpoint.post("configure", "/session", { + query: { payload: Schema.String }, + payload: Schema.Union([Schema.String, Schema.Number]), + success: Schema.String, + }), + ), + ), + ).toThrow("Opaque payload field collision: session.configure.payload conflicts with query.payload") + }) + + test("preserves required empty struct payloads in imported Effect adapters", () => { + const contract = compileContract( + api( + HttpApiEndpoint.post("empty", "/session", { + payload: Schema.Struct({}), + success: Schema.String, + }), + ), + ) + const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" }) + const promise = emitPromise(contract) + + expect(effect.files.find((file) => file.path === "client.ts")?.content).toContain("payload: { }") + expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain("body: { }") + }) + test("uses no argument when an operation has no input fields", () => { const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String }))) diff --git a/packages/httpapi-codegen/test/generated-consumer.ts b/packages/httpapi-codegen/test/generated-consumer.ts index 448db01be8..4b72c3ff6b 100644 --- a/packages/httpapi-codegen/test/generated-consumer.ts +++ b/packages/httpapi-codegen/test/generated-consumer.ts @@ -10,6 +10,12 @@ export const program = OpenCode.make().pipe( const filtered = client.session.list({ archived: true }) const get = client.session.get({ sessionID: "session" }) const interrupt = client.session.interrupt({ sessionID: "session" }) + const configure = client.session.configure({ + sessionID: "session", + dryRun: true, + traceID: "trace", + payload: { type: "local", command: ["opencode"] }, + }) const status = client.status() const subscribe = client.event.subscribe() @@ -18,10 +24,11 @@ export const program = OpenCode.make().pipe( const _filtered: Effect.Effect, ClientError> = filtered const _get: Effect.Effect = get const _interrupt: Effect.Effect = interrupt + const _configure: Effect.Effect = configure const _status: Effect.Effect = status const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe - return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe } + return { _health, _list, _filtered, _get, _interrupt, _configure, _status, _subscribe } }), ) diff --git a/packages/httpapi-codegen/test/generated/session.ts b/packages/httpapi-codegen/test/generated/session.ts index 7217d5fd18..fed4a1cc3e 100644 --- a/packages/httpapi-codegen/test/generated/session.ts +++ b/packages/httpapi-codegen/test/generated/session.ts @@ -24,6 +24,19 @@ const Endpoint3Params = Schema.Struct({ sessionID: Schema.String }) const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 }) +const Endpoint4Params = Schema.Struct({ sessionID: Schema.String }) + +const Endpoint4Query = Schema.Struct({ dryRun: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) }) + +const Endpoint4Headers = Schema.Struct({ traceID: Schema.String }) + +const Endpoint4Payload0 = Schema.Union([ + Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }), + Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }), +]) + +const Endpoint4Success = Schema.String + export const Group0 = HttpApiGroup.make("session", { topLevel: false }) .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success })) .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success })) @@ -40,6 +53,15 @@ export const Group0 = HttpApiGroup.make("session", { topLevel: false }) success: Endpoint3Success, }), ) + .add( + HttpApiEndpoint.make("POST")("configure", "/session/:sessionID/configure", { + params: Endpoint4Params, + query: Endpoint4Query, + headers: Endpoint4Headers, + payload: Endpoint4Payload0, + success: Endpoint4Success, + }), + ) type RawGroup = HttpApiClient.Client.Group @@ -88,9 +110,32 @@ const mapEndpoint3Error = (error: unknown) => const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) => raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error)) +type Endpoint4Request = Parameters[0] +type Endpoint4Input = { + readonly sessionID: (typeof Endpoint4Params.Type)["sessionID"] + readonly dryRun?: (typeof Endpoint4Query.Type)["dryRun"] + readonly traceID: (typeof Endpoint4Headers.Type)["traceID"] + readonly payload: typeof Endpoint4Payload0.Type +} +const Endpoint4DeclaredError = Schema.Never +const mapEndpoint4Error = (error: unknown) => + HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) + ? new ClientError({ cause: error }) + : Schema.is(Endpoint4DeclaredError)(error) + ? error + : new ClientError({ cause: error }) +const Endpoint4 = (raw: RawGroup) => (input: Endpoint4Input) => + raw["configure"]({ + params: { sessionID: input["sessionID"] }, + query: { dryRun: input["dryRun"] }, + headers: { traceID: input["traceID"] }, + payload: input["payload"], + } as Endpoint4Request).pipe(Effect.mapError(mapEndpoint4Error)) + export const adaptGroup0 = (raw: RawGroup) => ({ health: Endpoint0(raw), list: Endpoint1(raw), get: Endpoint2(raw), interrupt: Endpoint3(raw), + configure: Endpoint4(raw), })