| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471 |
- 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,
- OpenApi,
- } 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.Constraint) {
- return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
- }
- function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(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("derives nested paths from OpenAPI operation IDs", () => {
- const source = HttpApi.make("test").add(
- HttpApiGroup.make("server.session").add(
- HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "v2.session.revert.stage" }),
- ),
- ),
- )
- const contract = compileContract(source, { groupNames: { "server.session": "session" } })
- expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"])
- expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage")
- })
- test("uses nested OpenAPI operation IDs across emitters", () => {
- const source = HttpApi.make("test").add(
- HttpApiGroup.make("server.session")
- .add(
- HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "v2.session.instructions.list" }),
- ),
- )
- .add(
- HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "v2.session.instructions.put" }),
- ),
- )
- .add(
- HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "v2.session.instructions.remove" }),
- ),
- ),
- )
- const contract = compileContract(source, { groupNames: { "server.session": "session" } })
- expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
- ["instructions", "list"],
- ["instructions", "put"],
- ["instructions", "remove"],
- ])
- expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
- "instructions.list",
- "instructions.put",
- "instructions.remove",
- ])
- 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(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) }',
- )
- 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) }',
- )
- 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>')
- })
- test("executes nested Promise operation IDs", async () => {
- const source = HttpApi.make("test").add(
- HttpApiGroup.make("session")
- .add(
- HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "session.instructions.list" }),
- ),
- )
- .add(
- HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "session.instructions.put" }),
- ),
- )
- .add(
- HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "session.instructions.remove" }),
- ),
- ),
- )
- const output = emitPromise(compileContract(source))
- 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 }).annotateMerge(
- OpenApi.annotations({ identifier: "session.instructions.list" }),
- ),
- )
- .add(
- HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "session.instructions.list" }),
- ),
- ),
- )
- expect(() => compileContract(source)).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 }).annotateMerge(
- OpenApi.annotations({ identifier: "instructions.list" }),
- ),
- ),
- )
- .add(
- HttpApiGroup.make("second", { topLevel: true }).add(
- HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "instructions.put" }),
- ),
- ),
- )
- expect(() => compileContract(source)).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 }).annotateMerge(
- OpenApi.annotations({ identifier: "session.foo.bar" }),
- ),
- )
- .add(
- HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "session.foo-bar" }),
- ),
- ),
- )
- expect(() => compileContract(source)).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 }).annotateMerge(
- OpenApi.annotations({ identifier: "session.__proto__.get" }),
- ),
- )
- expect(() => compileContract(source)).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 }).annotateMerge(
- OpenApi.annotations({ identifier: "foo.bar.baz" }),
- ),
- ),
- )
- .add(
- HttpApiGroup.make("fooBar").add(
- HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "fooBar.baz" }),
- ),
- ),
- )
- expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz")
- const operationKey = HttpApi.make("test")
- .add(
- HttpApiGroup.make("a.b").add(
- HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "a.b.c" }),
- ),
- ),
- )
- .add(
- HttpApiGroup.make("a").add(
- HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge(
- OpenApi.annotations({ identifier: "a.b.c" }),
- ),
- ),
- )
- expect(() => compileContract(operationKey)).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.identifier)).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("retains 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 }),
- }),
- ),
- ),
- )
- const types = output.files.find((file) => file.path === "types.ts")?.content
- expect(types).toContain('export type Referenced = { readonly "value": string }')
- expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]')
- })
- test("emits mutable Promise outputs without restricting inputs", () => {
- const output = emitPromise(
- compileContract(
- api(
- HttpApiEndpoint.post("create", "/session", {
- payload: Schema.Struct({ values: Schema.Array(Schema.String) }),
- success: Schema.Struct({ data: Schema.Array(Schema.Struct({ values: Schema.Array(Schema.String) })) }),
- }),
- ),
- ),
- { mutableOutputs: true },
- )
- const types = output.files.find((file) => file.path === "types.ts")?.content
- expect(types).toContain('readonly "values": ReadonlyArray<string>')
- expect(types).toContain(
- 'export type SessionCreateOutput = ({ "data": Array<{ "values": Array<string> }> })["data"]',
- )
- })
- test("retains distinct Promise references 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 }),
- }),
- ),
- ),
- )
- const types = output.files.find((file) => file.path === "types.ts")?.content
- expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }')
- expect(types).toContain("export type SessionID = string")
- expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID')
- })
- test("disambiguates flattened Promise reference names", () => {
- const First = Schema.String.annotate({ identifier: "ExampleName" })
- const Second = Schema.String.annotate({ identifier: "Example_Name" })
- const output = emitPromise(
- compileContract(
- api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ first: First, second: Second }) })),
- ),
- )
- const types = output.files.find((file) => file.path === "types.ts")?.content
- expect(types).toContain("export type ExampleName = string")
- expect(types).toContain("export type ExampleName2 = 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 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(
- 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 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 })))
- 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("emits opaque Promise SSE fields as any", () => {
- const output = emitPromise(
- compileContract(
- api(
- HttpApiEndpoint.get("subscribe", "/event", {
- success: HttpApiSchema.StreamSse({
- data: Schema.Struct({
- metadata: Schema.Record(Schema.String, Schema.Unknown),
- label: Schema.Literal("unknown"),
- }),
- }),
- }),
- ),
- ),
- )
- const types = output.files.find((file) => file.path === "types.ts")?.content
- expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
- expect(types).toContain('readonly "label": "unknown"')
- })
- 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<Schema.Top>(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 })",
- )
- })
- })
|