opencode/packages/httpapi-codegen/test/generate.test.ts

1274 lines
50 KiB
TypeScript

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 as compileContract,
emitEffect,
emitEffectImported,
emitEffectShape,
emitPromise,
generate,
GenerationError,
} from "../src"
import { it } from "./effect"
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("allows Promise outputs to use an authoritative imported wire type", () => {
const contract = compileContract(
api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
)
const output = emitPromise(contract, {
outputTypes: {
"session.events": {
name: "EventWire",
import: 'import type { EventWire } from "./event-wire"',
},
},
})
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('import type { EventWire } from "./event-wire"')
expect(types).toContain("export type SessionEventsOutput = EventWire")
})
test("emits an Effect client against an imported authoritative API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", api: "Api" },
)
expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'import { Api } from "@example/api"',
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
"HttpApiClient.ForApi<typeof Api>",
)
})
test("projects imported endpoint constants into a generated API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", endpoints: { "session.get": "SessionGet" } },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGet } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
})
test("imports an authoritative group without reconstructing it", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
),
{ module: "@example/api", group: "SessionGroup" },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGroup } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
expect(client).not.toContain("HttpApiGroup")
})
test("separates hosted and consumer group names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session").add(
HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
),
)
const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
expect(contract.groups[0]?.identifier).toBe("sessions")
expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "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("supports explicit nested endpoint paths while string aliases remain flat", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session")
.add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })),
)
const contract = compileContract(source, {
groupNames: { "server.session": "session" },
endpointNames: {
"session.instructions.list": ["instructions", "list"],
"session.instructions.put": ["instructions", "put"],
"session.instructions.remove": ["instructions", "remove"],
"session.messages": "instructions.flat",
},
})
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
["instructions", "list"],
["instructions", "put"],
["instructions", "remove"],
["instructions.flat"],
])
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
"instructions.list",
"instructions.put",
"instructions.remove",
"instructions.flat",
])
const promise = emitPromise(contract, {
outputTypes: {
"session.instructions.list": {
name: "InstructionListWire",
import: 'import type { InstructionListWire } from "./instruction-list-wire"',
},
},
})
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
const promiseTypes = promise.files.find((file) => file.path === "types.ts")?.content
expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)')
expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"')
expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire")
expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string")
expect(promiseTypes).toContain("export type SessionInstructionsRemoveOutput = string")
const effect = emitEffect(contract)
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)',
)
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
'"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)',
)
const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
const apiShape = shape.files.find((file) => file.path === "api.ts")?.content
expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation<E>')
expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>')
expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation<E>')
expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation<E>')
})
test("executes nested Promise endpoint aliases", async () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })),
)
const output = emitPromise(
compileContract(source, {
endpointNames: {
"instructions.list": ["instructions", "list"],
"instructions.put": ["instructions", "put"],
"instructions.remove": ["instructions", "remove"],
},
}),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const methods: Array<string> = []
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 (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
)
expect(() =>
compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }),
).toThrow("Client endpoint name collision: session.instructions.list")
expect(() =>
compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }),
).toThrow("Client endpoint name collision: session.instructions.list")
})
test("rejects nested root collisions across top-level groups", () => {
const source = HttpApi.make("test")
.add(
HttpApiGroup.make("first", { topLevel: true }).add(
HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }),
),
)
.add(
HttpApiGroup.make("second", { topLevel: true }).add(
HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }),
),
)
expect(() =>
compileContract(source, {
endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] },
}),
).toThrow("Client name collision: instructions")
})
test("rejects nested paths that collide after type-name normalization", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
)
expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow(
"Client endpoint type collision: SessionFooBar",
)
})
test("rejects ambiguous and prototype-mutating nested path segments", () => {
const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))
expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow(
"Nested client endpoint path segments cannot contain dots",
)
expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow(
"Client endpoint path cannot contain __proto__",
)
})
test("rejects normalized group, operation-key, and group prototype collisions", () => {
const normalized = HttpApi.make("test")
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
const endpointType = HttpApi.make("test")
.add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })))
expect(() =>
compileContract(endpointType, {
endpointNames: { first: ["bar", "baz"], second: ["baz"] },
}),
).toThrow("Client endpoint type collision: FooBarBaz")
const operationKey = HttpApi.make("test")
.add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String })))
expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow(
"Client operation key collision: a.b.c",
)
const prototype = HttpApi.make("test").add(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })),
)
expect(() => compileContract(prototype, { groupNames: { session: "__proto__" } })).toThrow(
"Client group name cannot be __proto__",
)
})
test("omits custom transport endpoints", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.pty")
.add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String }))
.add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })),
)
const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) })
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.name)).toEqual(["pty.get"])
})
test("uses bracket access for input field names", () => {
const source = api(
HttpApiEndpoint.post("token", "/token", {
headers: { "x-example-token": Schema.Literal("1") },
success: Schema.String,
}),
)
const contract = compileContract(source)
const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
const effect = emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.token": "Token" },
}).files.find((file) => file.path === "client.ts")?.content
expect(promise).toContain('"x-example-token": input["x-example-token"]')
expect(effect).toContain('"x-example-token": input["x-example-token"]')
})
test("rejects consumer group name collisions", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
.add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
"Client group name collision: same",
)
})
test("uses the unqualified endpoint name for the public client", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("session.get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
)
const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
const effect = emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.session.get": "SessionGet" },
}).files.find((file) => file.path === "client.ts")?.content
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
expect(effect).toContain('raw["session.get"]')
})
test("preserves optional keys in Promise error types", () => {
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
"OptionalError",
{ message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
{ httpApiStatus: 400 },
) {}
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "message": string; readonly "detail"?: string | undefined',
)
})
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("preserves reflected default error statuses", () => {
class MissingStatus extends Schema.TaggedErrorClass<MissingStatus>()("MissingStatus", {
message: Schema.String,
}) {}
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))),
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]")
})
test("erases brands from Promise wire types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "sessionID": string')
expect(types).not.toContain("Brand")
})
test("inlines non-recursive references in Promise wire types", () => {
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Struct({ data: Referenced }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
)
})
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(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Json,
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain("export type JsonValue =")
expect(types).toContain("{ readonly [key: string]: JsonValue }")
expect(types).not.toContain("Schema.Json")
})
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("read", "/file/*/tail", { success: Schema.String })))),
).toThrow("Unsupported Promise path wildcard: /file/*/tail")
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("executes an emitted binary wildcard GET through fetch", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("read", "/file/*", {
query: { token: Schema.optional(Schema.String) },
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()}`)
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 new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} 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("serializes explicit null query values", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("list", "/session", {
query: { parentID: Schema.optional(Schema.NullOr(Schema.String)) },
success: Schema.Struct({ data: Schema.Array(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: [] })
},
})
await client.session.list({ parentID: null })
expect(request?.url).toBe("https://example.com/session?parentID=null")
} 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, count: Schema.NumberFromString }),
}),
}),
),
),
)
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","count":"1"}\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", count: "1" }])
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(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
),
)
expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
})
test("emits one client module per HttpApi group", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
const output = compile(source)
expect(output.files.map((file) => file.path)).toEqual([
"session.ts",
"tool.ts",
"client-error.ts",
"client.ts",
"index.ts",
])
})
test("emits syntactically valid TypeScript modules", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const transpiler = new Bun.Transpiler({ loader: "ts" })
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
)
expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
)
yield* Effect.forEach(output.files, (file) =>
Effect.tryPromise(() =>
Promise.all([
Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
]),
).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
)
}),
)
test("flattens transport input channels into one domain input", () => {
const output = compile(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.String },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.input).toEqual([
{ name: "sessionID", source: "params" },
{ name: "resume", source: "query" },
{ name: "traceID", source: "headers" },
{ name: "prompt", source: "payload" },
])
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("uses no argument when an operation has no input fields", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.inputMode).toBe("none")
})
test("uses an optional object when every input field is optional", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.String) },
success: Schema.Array(Schema.String),
}),
),
)
expect(output.operations[0]?.inputMode).toBe("optional")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
})
test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { archived: Schema.optional(Schema.Boolean) },
success: Schema.String,
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
})
test("uses a required object when any input field is required", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { includeArchived: Schema.optional(Schema.String) },
success: Schema.String,
}),
),
)
expect(output.operations[0]?.inputMode).toBe("required")
})
test("rejects colliding input names across transport channels", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session/:id", {
params: { id: Schema.String },
payload: Schema.Struct({ id: Schema.String }),
success: Schema.Void,
}),
),
),
).toThrow("Input field collision: id")
})
test("rejects multiple payload alternatives until selection semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session", {
payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
success: Schema.String,
}),
),
),
).toThrow("Multiple payload schemas: session.prompt")
})
test("unwraps an exact data success envelope", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.success).toBe("value")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"Effect.map((value) => value.data)",
)
})
test("maps no-content success to void", () => {
const output = compile(
api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
)
expect(output.operations[0]?.success).toBe("void")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
})
test("preserves non-default empty response statuses", () => {
const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
})
test("returns a non-envelope success unchanged", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.success).toBe("value")
})
test("rejects multiple success shapes until their public semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: [Schema.String, Schema.Number],
}),
),
),
).toThrow("Multiple success schemas: session.get")
})
test("models an SSE success as a direct stream", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
)
expect(output.operations[0]?.success).toBe("stream")
})
test("preserves annotated stream response statuses", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
".pipe(HttpApiSchema.status(202))",
)
})
test("rejects schemas whose semantics cannot be emitted exactly", () => {
const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "yes"),
encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
}),
)
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
query: { archived: QueryBoolean },
success: Schema.String,
}),
),
),
).toThrow("Effect schema requires authoritative import: session.get")
})
test("rejects custom validation checks without portable metadata", () => {
const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects spoofed and aborted validation checks", () => {
const Spoofed = Schema.Number.check(
Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
)
const Aborted = Schema.Number.check(Schema.isFinite().abort())
expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
"Unportable schema: session.spoofed.success",
)
expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
"Unportable schema: session.aborted.success",
)
})
test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
const JsonNumber = Schema.toCodecJson(Schema.Number)
const link = JsonNumber.ast.encoding?.[0]
if (link === undefined) throw new Error("Expected JSON number encoding")
// This helper is present at runtime but omitted from the public declaration surface.
const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
const ast: unknown = replaceEncoding(JsonNumber.ast, [
new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
])
if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
const Altered = Schema.make(ast)
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
"Effect schema requires authoritative import: session.get",
)
})
test("rejects lexical generation and annotation values", () => {
const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
generation: { runtime: "LocalOnly", Type: "string" },
})
const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
custom: () => "local",
})
expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
"Unportable schema: session.generated.success",
)
expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
"Unportable schema: session.annotated.success",
)
})
test("preserves errors from server-only middleware", () => {
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
error: Unauthorized,
}) {}
const output = compile(
api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
)
expect(output.operations[0]).toBeDefined()
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
)
})
test("preserves tagged error response statuses", () => {
class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
)
})
test("supports every HttpApi method through the generic constructor", () => {
const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
})
test("uses safe unique module paths without changing public group identifiers", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
})
test("reserves support module names case-insensitively", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
})
test("keeps searching when a reserved-name fallback is also occupied", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
})
test("rejects collisions in the flattened client namespace", () => {
expect(() =>
compile(
HttpApi.make("test")
.add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
.add(
HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
),
),
),
).toThrow("Client name collision: status")
})
test("emits a usable raw type for top-level groups", () => {
const output = compile(
HttpApi.make("test").add(
HttpApiGroup.make("health", { topLevel: true }).add(
HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
),
),
)
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
})
it.effect("reports compiler failures in the generate Effect", () =>
Effect.gen(function* () {
const error = yield* generate(
api(
HttpApiEndpoint.get("get", "/url", {
success: Schema.declare((input): input is URL => input instanceof URL),
}),
),
{
directory: "/generated",
},
).pipe(Effect.flip)
expect(error).toBeInstanceOf(GenerationError)
if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
}).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
)
test("rejects required client middleware without an adapter", () => {
class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
requiredForClient: true,
}) {}
expect(() =>
compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
).toThrow("Client middleware requires adapter: SignedRequest")
})
test("maps transport and decode failures to one stable client error", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
}),
),
)
expect(output.operations[0]?.errors).toContain("ClientError")
expect(output.operations[0]?.errors).not.toContain("HttpClientError")
expect(output.operations[0]?.errors).not.toContain("SchemaError")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"new ClientError({ cause: error })",
)
})
})