feat(sdk): add Promise client codegen

This commit is contained in:
Kit Langton
2026-06-22 22:10:48 -04:00
parent b241e38cc8
commit 66bcddd059
13 changed files with 632 additions and 29 deletions
+11 -9
View File
@@ -1,11 +1,19 @@
# @opencode-ai/httpapi-codegen
Build-time source generation for domain-oriented Effect APIs derived from `HttpApi` and Effect Schema contracts.
Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts.
The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures.
## Settled rules
- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`.
- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`.
- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source.
- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`.
- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation.
- 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.
- 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.
@@ -16,22 +24,16 @@ The package is private while its API is explored. Its tests are the executable s
- Expose streaming success as `Stream`, not `Effect<Stream>`.
- Reject schemas whose wire/domain transformation cannot be generated exactly.
- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`.
- Generate only the Effect API initially; Promise runtime ownership, cancellation, and stream adaptation are deferred.
- Commit generated source for review; CI regenerates and fails when the worktree changes.
- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator.
## Boundary
This package generates only the remote API derived from `HttpApi`. It does not generate embedded implementations or embedded-only capabilities. The OpenCode integration composes two distinct total objects:
- A remote object containing the generated HTTP capabilities.
- An embedded object implementing the shared shape against local services and adding embedded-only capabilities.
The embedded object may be a structural superset of the remote object, but the constructors and concrete result types remain distinct.
This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities.
Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy.
The public `generate(Api, { directory })` operation is an Effect requiring `FileSystem`. Internally it composes a pure `compile(Api)` phase with `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links.
File diff suppressed because one or more lines are too long
+253 -2
View File
@@ -1,16 +1,267 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
import { format } from "prettier"
import { compile, generate, GenerationError } from "../src"
import { compile as compileContract, emitEffect, emitPromise, generate, GenerationError } from "../src"
import { it } from "./effect"
import { Api as FixtureApi } from "./fixture"
import { Api as FixtureApi, Missing } from "./fixture"
function api(endpoint: HttpApiEndpoint.Any) {
return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
}
function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
return emitEffect(compileContract(source))
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const promise = emitPromise(contract)
const effect = emitEffect(contract)
expect(promise.operations).toEqual(effect.operations)
expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("emits an optional Promise input when every field is optional", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.Number) },
success: Schema.Array(Schema.String),
}),
),
),
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
)
})
test("rejects Promise transports that are not implemented", () => {
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("text", "/text", {
success: Schema.String.pipe(HttpApiSchema.asText()),
}),
),
),
),
).toThrow("Unsupported Promise success encoding: session.text")
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("events", "/events", {
success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
}),
),
),
),
).toThrow("Unsupported Promise stream: session.events")
})
test("executes an emitted Promise GET through fetch", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
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) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("maps an emitted no-content response to undefined", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
params: { sessionID: 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()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.optional(Schema.Boolean) },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
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 Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects with declared tagged errors and exports a type guard", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
),
)
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 () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("subscribe", "/event", {
query: { after: Schema.optional(Schema.Number) },
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
),
)
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 requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("preserves public group and endpoint identifiers exactly", () => {
const output = compile(
HttpApi.make("test").add(
@@ -1,9 +1,9 @@
import { Effect, Stream } from "effect"
import { HttpClient } from "effect/unstable/http"
import { ClientError, make } from "./generated"
import { ClientError, OpenCode } from "./generated"
import { Missing } from "./fixture"
export const program = make().pipe(
export const program = OpenCode.make().pipe(
Effect.map((client) => {
const health = client.session.health()
const list = client.session.list()
@@ -1,2 +1,2 @@
export { ClientError } from "./client-error"
export { make } from "./client"
export * as OpenCode from "./client"