feat(client): generate complete protocol client

This commit is contained in:
Kit Langton 2026-06-26 22:54:23 -04:00
commit 7399d57aee
10 changed files with 3932 additions and 138 deletions

View file

@ -75,7 +75,10 @@ const manifestName = ".httpapi-codegen.json"
export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
api: HttpApi.HttpApi<Id, Groups>,
options?: { readonly groupNames?: Readonly<Record<string, string>> },
options?: {
readonly groupNames?: Readonly<Record<string, string>>
readonly endpointNames?: Readonly<Record<string, string>>
},
): Contract {
const endpoints: Array<Endpoint> = []
const portable = new Map<SchemaAST.AST, boolean>()
@ -150,7 +153,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
effectPortable,
operation: {
group: groupName,
name: clientEndpointName(endpoint.name),
name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name),
input: inputs.map(({ name, source }) => ({ name, source })),
inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required",
success: isStreamSchema(success.schema)
@ -245,7 +248,13 @@ export function emitPromise(contract: Contract): Output {
},
{
path: "client.ts",
content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
content: renderPromiseClient(groups)
.replace("readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary: boolean\n}")
.replace(
"return await json(response) as A",
'if (descriptor.binary) {\n try {\n return new Uint8Array(await response.arrayBuffer()) as A\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n return await json(response) as A',
)
.replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
},
{
path: "index.ts",
@ -277,13 +286,13 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
}
} else if (
!HttpApiSchema.isNoContent(success.ast) &&
(resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json"
!["Json", "Uint8Array"].includes(resolveHttpApiEncoding(success.ast)?._tag ?? "Json")
) {
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
}
for (const error of endpoint.errors) {
if (taggedErrorFields(error) === undefined) {
throw new GenerationError({ reason: `Promise error must be tagged: ${name}` })
if (declaredErrorFields(error) === undefined) {
throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
}
if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") {
throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` })
@ -422,7 +431,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) =>
endpoint.errors.flatMap((schema) => {
const tagged = taggedErrorFields(schema)
const tagged = declaredErrorFields(schema)
return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
}),
),
@ -432,7 +441,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
const fields = error.fields
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
.join("; ")
return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}`
return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}`
})
const operations = groups
.flatMap((group) =>
@ -505,7 +514,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined),
),
]
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }`
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}, binary: ${resolveHttpApiEncoding(endpoint.successes[0].ast)?._tag === "Uint8Array"} }`
if (endpoint.operation.success === "stream") {
const success = endpoint.successes[0]
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
@ -556,9 +565,12 @@ function structuralType(schema: Schema.Top) {
)
const expand = (type: string, seen = new Set<string>()): string => {
for (const [reference, value] of references) {
if (!type.includes(reference)) continue
if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" })
type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`)
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
if (!new RegExp(pattern).test(type)) continue
if (seen.has(reference)) {
throw new GenerationError({ reason: `Recursive Promise types are not implemented: ${reference}` })
}
type = type.replace(new RegExp(pattern, "g"), `(${expand(value, new Set([...seen, reference]))})`)
}
return type
}
@ -921,18 +933,26 @@ function serializable(value: unknown): boolean {
}
function taggedErrorFields(schema: Schema.Top) {
const fields = declaredErrorFields(schema)
return fields?.key === "_tag" ? fields : undefined
}
function declaredErrorFields(schema: Schema.Top) {
if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) {
return undefined
}
const fields = schema.ast.typeParameters[0]
if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined
const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type
const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name
if (key !== "_tag" && key !== "name") return undefined
const tag = fields.propertySignatures.find((field) => field.name === key)?.type
if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined
return {
key,
tag: tag.literal,
identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal,
fields: fields.propertySignatures.flatMap((field) =>
field.name === "_tag" || typeof field.name !== "string"
field.name === key || typeof field.name !== "string"
? []
: [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const],
),

View file

@ -151,6 +151,19 @@ describe("HttpApiCodegen.generate", () => {
expect(effect).toContain('raw["session.get"]')
})
test("supports explicit public endpoint names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.permission")
.add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String }))
.add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })),
)
const contract = compileContract(source, {
endpointNames: { "permission.request.list": "listRequests" },
})
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
})
test("preserves optional keys in Promise error types", () => {
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
"OptionalError",
@ -166,6 +179,22 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("supports name-discriminated Promise errors", () => {
class NamedError extends Schema.ErrorClass<NamedError>("NamedError")(
{ name: Schema.Literal("NamedError"), message: Schema.String },
{ httpApiStatus: 400 },
) {}
const output = emitPromise(
compileContract(
api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "name": "NamedError"')
expect(types).toContain('"name" in value && value["name"] === "NamedError"')
})
test("erases brands from Promise wire types", () => {
const output = emitPromise(
compileContract(
@ -200,6 +229,26 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("expands Promise references only at identifier boundaries", () => {
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
identifier: "Session",
})
const SessionID = Schema.String.annotate({ identifier: "SessionID" })
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Struct({ session: Session, sessionID: SessionID }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
)
})
test("emits Effect Json schemas as standalone Promise types", () => {
const output = emitPromise(
compileContract(
@ -260,6 +309,32 @@ describe("HttpApiCodegen.generate", () => {
).toThrow("Unsupported Promise stream: session.events")
})
test("executes an emitted binary Promise response", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("read", "/file", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
),
),
)
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()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(new Uint8Array([1, 2, 3])),
})
expect(await client.session.read()).toEqual(new Uint8Array([1, 2, 3]))
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("executes an emitted Promise GET through fetch", async () => {
const output = emitPromise(
compileContract(