feat(client): support opaque payload schemas (#37773)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-07-19 16:08:26 +00:00
committed by GitHub
co-authored by Aiden Cline
parent fe9a936867
commit bef6cfbffe
6 changed files with 255 additions and 32 deletions
+3 -1
View File
@@ -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.
+83 -30
View File
@@ -126,10 +126,18 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Constrain
...inputFields(headers?.schema, "headers", name),
...payloads.flatMap((item) => inputFields(item.schema, "payload", name)),
]
const names = new Set<string>()
const names = new Map<string, InputField["source"]>()
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<readonly [string, Schema.Top]> = [
@@ -336,7 +344,7 @@ function renderEffectShape(groups: ReadonlyArray<Group>, 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<Group>) {
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<Id extends string, Groups extends HttpApiGroup.Constrai
}).pipe(Effect.flatMap((output) => 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<RawGroup[${JSON.stringify(endpoint.identifier)}]>[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<Slot>) {
if (slots.length === 0) return ""
const classes = new Map(
+12
View File
@@ -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(
@@ -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<string> } | { 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 })))
@@ -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<ReadonlyArray<string>, ClientError> = filtered
const _get: Effect.Effect<string, Missing | ClientError> = get
const _interrupt: Effect.Effect<void, ClientError> = interrupt
const _configure: Effect.Effect<string, ClientError> = configure
const _status: Effect.Effect<string, ClientError> = 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 }
}),
)
@@ -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<typeof Group0, never, never>
@@ -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<RawGroup["configure"]>[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),
})