generate.test.ts 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545
  1. import { describe, expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import { tmpdir } from "node:os"
  4. import { join } from "node:path"
  5. import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
  6. import {
  7. HttpApi,
  8. HttpApiEndpoint,
  9. HttpApiGroup,
  10. HttpApiMiddleware,
  11. HttpApiSchema,
  12. OpenApi,
  13. } from "effect/unstable/httpapi"
  14. import { format } from "prettier"
  15. import {
  16. compile as compileContract,
  17. emitEffect,
  18. emitEffectImported,
  19. emitEffectShape,
  20. emitPromise,
  21. generate,
  22. GenerationError,
  23. } from "../src"
  24. import { it } from "./effect"
  25. import { Api as FixtureApi, Missing } from "./fixture"
  26. function api(endpoint: HttpApiEndpoint.Constraint) {
  27. return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
  28. }
  29. function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(source: HttpApi.HttpApi<Id, Groups>) {
  30. return emitEffect(compileContract(source))
  31. }
  32. describe("HttpApiCodegen.generate", () => {
  33. test("compiles one contract for Promise and Effect emitters", () => {
  34. const contract = compileContract(
  35. api(
  36. HttpApiEndpoint.get("get", "/session/:sessionID", {
  37. params: { sessionID: Schema.String },
  38. success: Schema.Struct({ data: Schema.String }),
  39. }),
  40. ),
  41. )
  42. const promise = emitPromise(contract)
  43. const effect = emitEffect(contract)
  44. expect(promise.operations).toEqual(effect.operations)
  45. expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
  46. const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
  47. expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
  48. expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
  49. expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
  50. 'params: { "sessionID": input["sessionID"] }',
  51. )
  52. })
  53. test("allows Promise outputs to use an authoritative imported wire type", () => {
  54. const contract = compileContract(
  55. api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
  56. )
  57. const output = emitPromise(contract, {
  58. outputTypes: {
  59. "session.events": {
  60. name: "EventWire",
  61. import: 'import type { EventWire } from "./event-wire"',
  62. },
  63. },
  64. })
  65. const types = output.files.find((file) => file.path === "types.ts")?.content
  66. expect(types).toContain('import type { EventWire } from "./event-wire"')
  67. expect(types).toContain("export type SessionEventsOutput = EventWire")
  68. })
  69. test("emits an Effect client against an imported authoritative API", () => {
  70. const output = emitEffectImported(
  71. compileContract(
  72. api(
  73. HttpApiEndpoint.get("get", "/session/:sessionID", {
  74. params: { sessionID: Schema.String },
  75. success: Schema.Struct({ data: Schema.String }),
  76. }),
  77. ),
  78. ),
  79. { module: "@example/api", api: "Api" },
  80. )
  81. expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
  82. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  83. 'import { Api } from "@example/api"',
  84. )
  85. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  86. "HttpApiClient.ForApi<typeof Api>",
  87. )
  88. })
  89. test("generates Effect API types from schemas instead of the imported API", () => {
  90. const Info = Schema.Struct({ id: Schema.String }).annotate({ identifier: "Session.Info" })
  91. const output = emitEffectShape(
  92. compileContract(
  93. api(
  94. HttpApiEndpoint.get("get", "/session/:id", {
  95. params: { id: Schema.String },
  96. success: Schema.Struct({ data: Info }),
  97. }),
  98. ),
  99. ),
  100. {
  101. typeReferences: [
  102. {
  103. schema: Info,
  104. name: "Session.Info",
  105. import: 'import type { Session } from "@example/schema/session"',
  106. },
  107. ],
  108. },
  109. )
  110. const source = output.files[0]?.content
  111. expect(source).toContain('import type { Session } from "@example/schema/session"')
  112. expect(source).toContain('export type Endpoint0_0Input = { readonly "id": string }')
  113. expect(source).toContain("export type Endpoint0_0Output = Session.Info")
  114. expect(source).not.toContain("HttpApiClient")
  115. expect(source).not.toContain("@example/api")
  116. })
  117. test("allows composed Effect outputs to use an authoritative named type", () => {
  118. const output = emitEffectShape(
  119. compileContract(api(HttpApiEndpoint.get("events", "/event", { success: Schema.Unknown }))),
  120. {
  121. outputTypes: {
  122. "session.events": {
  123. name: "OpenCodeEvent",
  124. import: 'import type { OpenCodeEvent } from "@example/protocol/event"',
  125. },
  126. },
  127. },
  128. )
  129. const source = output.files[0]?.content
  130. expect(source).toContain('import type { OpenCodeEvent } from "@example/protocol/event"')
  131. expect(source).toContain("export type Endpoint0_0Output = OpenCodeEvent")
  132. })
  133. test("exposes an imported Effect client through its generated shape", () => {
  134. const output = emitEffectImported(
  135. compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))),
  136. { module: "@example/api", api: "Api", shapeModule: "../api" },
  137. )
  138. const source = output.files.find((file) => file.path === "client.ts")?.content
  139. expect(source).toContain('import type { Endpoint0_0Output } from "../api"')
  140. expect(source).toContain("preserveEffect<Endpoint0_0Output>()")
  141. })
  142. test("projects imported endpoint constants into a generated API", () => {
  143. const output = emitEffectImported(
  144. compileContract(
  145. api(
  146. HttpApiEndpoint.get("get", "/session/:sessionID", {
  147. params: { sessionID: Schema.String },
  148. success: Schema.Struct({ data: Schema.String }),
  149. }),
  150. ),
  151. ),
  152. { module: "@example/api", endpoints: { "session.get": "SessionGet" } },
  153. )
  154. const client = output.files.find((file) => file.path === "client.ts")?.content
  155. expect(client).toContain('import { SessionGet } from "@example/api"')
  156. expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
  157. })
  158. test("imports an authoritative group without reconstructing it", () => {
  159. const output = emitEffectImported(
  160. compileContract(
  161. api(
  162. HttpApiEndpoint.get("get", "/session/:sessionID", {
  163. params: { sessionID: Schema.String },
  164. success: Schema.String,
  165. }),
  166. ),
  167. ),
  168. { module: "@example/api", group: "SessionGroup" },
  169. )
  170. const client = output.files.find((file) => file.path === "client.ts")?.content
  171. expect(client).toContain('import { SessionGroup } from "@example/api"')
  172. expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
  173. expect(client).not.toContain("HttpApiGroup")
  174. })
  175. test("separates hosted and consumer group names", () => {
  176. const source = HttpApi.make("test").add(
  177. HttpApiGroup.make("server.session").add(
  178. HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
  179. ),
  180. )
  181. const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
  182. expect(contract.groups[0]?.identifier).toBe("sessions")
  183. expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
  184. expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
  185. })
  186. test("derives nested paths from OpenAPI operation IDs", () => {
  187. const source = HttpApi.make("test").add(
  188. HttpApiGroup.make("server.session").add(
  189. HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge(
  190. OpenApi.annotations({ identifier: "v2.session.revert.stage" }),
  191. ),
  192. ),
  193. )
  194. const contract = compileContract(source, { groupNames: { "server.session": "session" } })
  195. expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"])
  196. expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage")
  197. })
  198. test("uses nested OpenAPI operation IDs across emitters", () => {
  199. const source = HttpApi.make("test").add(
  200. HttpApiGroup.make("server.session")
  201. .add(
  202. HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
  203. OpenApi.annotations({ identifier: "v2.session.instructions.list" }),
  204. ),
  205. )
  206. .add(
  207. HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
  208. OpenApi.annotations({ identifier: "v2.session.instructions.put" }),
  209. ),
  210. )
  211. .add(
  212. HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
  213. OpenApi.annotations({ identifier: "v2.session.instructions.remove" }),
  214. ),
  215. ),
  216. )
  217. const contract = compileContract(source, { groupNames: { "server.session": "session" } })
  218. expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
  219. ["instructions", "list"],
  220. ["instructions", "put"],
  221. ["instructions", "remove"],
  222. ])
  223. expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
  224. "instructions.list",
  225. "instructions.put",
  226. "instructions.remove",
  227. ])
  228. const promise = emitPromise(contract, {
  229. outputTypes: {
  230. "session.instructions.list": {
  231. name: "InstructionListWire",
  232. import: 'import type { InstructionListWire } from "./instruction-list-wire"',
  233. },
  234. },
  235. })
  236. const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
  237. const promiseTypes = promise.files.find((file) => file.path === "types.ts")?.content
  238. expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)')
  239. expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)')
  240. expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)')
  241. expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"')
  242. expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire")
  243. expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string")
  244. expect(promiseTypes).toContain("export type SessionInstructionsRemoveOutput = string")
  245. const effect = emitEffect(contract)
  246. expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
  247. '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }',
  248. )
  249. const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
  250. expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
  251. '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }',
  252. )
  253. const shape = emitEffectShape(contract)
  254. const apiShape = shape.files.find((file) => file.path === "api.ts")?.content
  255. expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation<E>')
  256. expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>')
  257. expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation<E>')
  258. })
  259. test("executes nested Promise operation IDs", async () => {
  260. const source = HttpApi.make("test").add(
  261. HttpApiGroup.make("session")
  262. .add(
  263. HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge(
  264. OpenApi.annotations({ identifier: "session.instructions.list" }),
  265. ),
  266. )
  267. .add(
  268. HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge(
  269. OpenApi.annotations({ identifier: "session.instructions.put" }),
  270. ),
  271. )
  272. .add(
  273. HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge(
  274. OpenApi.annotations({ identifier: "session.instructions.remove" }),
  275. ),
  276. ),
  277. )
  278. const output = emitPromise(compileContract(source))
  279. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  280. const methods: Array<string> = []
  281. try {
  282. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  283. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  284. const client = generated.OpenCode.make({
  285. baseUrl: "https://example.com",
  286. fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
  287. methods.push(init?.method ?? "GET")
  288. return Response.json("ok")
  289. },
  290. })
  291. expect(await client.session.instructions.list()).toBe("ok")
  292. expect(await client.session.instructions.put()).toBe("ok")
  293. expect(await client.session.instructions.remove()).toBe("ok")
  294. expect(methods).toEqual(["GET", "PUT", "DELETE"])
  295. } finally {
  296. await rm(directory, { recursive: true, force: true })
  297. }
  298. })
  299. test("rejects duplicate and leaf-namespace endpoint paths", () => {
  300. const source = HttpApi.make("test").add(
  301. HttpApiGroup.make("session")
  302. .add(
  303. HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
  304. OpenApi.annotations({ identifier: "session.instructions.list" }),
  305. ),
  306. )
  307. .add(
  308. HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
  309. OpenApi.annotations({ identifier: "session.instructions.list" }),
  310. ),
  311. ),
  312. )
  313. expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list")
  314. })
  315. test("rejects nested root collisions across top-level groups", () => {
  316. const source = HttpApi.make("test")
  317. .add(
  318. HttpApiGroup.make("first", { topLevel: true }).add(
  319. HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }).annotateMerge(
  320. OpenApi.annotations({ identifier: "instructions.list" }),
  321. ),
  322. ),
  323. )
  324. .add(
  325. HttpApiGroup.make("second", { topLevel: true }).add(
  326. HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge(
  327. OpenApi.annotations({ identifier: "instructions.put" }),
  328. ),
  329. ),
  330. )
  331. expect(() => compileContract(source)).toThrow("Client name collision: instructions")
  332. })
  333. test("rejects nested paths that collide after type-name normalization", () => {
  334. const source = HttpApi.make("test").add(
  335. HttpApiGroup.make("session")
  336. .add(
  337. HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
  338. OpenApi.annotations({ identifier: "session.foo.bar" }),
  339. ),
  340. )
  341. .add(
  342. HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
  343. OpenApi.annotations({ identifier: "session.foo-bar" }),
  344. ),
  345. ),
  346. )
  347. expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar")
  348. })
  349. test("rejects ambiguous and prototype-mutating nested path segments", () => {
  350. const source = api(
  351. HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge(
  352. OpenApi.annotations({ identifier: "session.__proto__.get" }),
  353. ),
  354. )
  355. expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__")
  356. })
  357. test("rejects normalized group, operation-key, and group prototype collisions", () => {
  358. const normalized = HttpApi.make("test")
  359. .add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
  360. .add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
  361. expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
  362. const endpointType = HttpApi.make("test")
  363. .add(
  364. HttpApiGroup.make("foo").add(
  365. HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge(
  366. OpenApi.annotations({ identifier: "foo.bar.baz" }),
  367. ),
  368. ),
  369. )
  370. .add(
  371. HttpApiGroup.make("fooBar").add(
  372. HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge(
  373. OpenApi.annotations({ identifier: "fooBar.baz" }),
  374. ),
  375. ),
  376. )
  377. expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz")
  378. const operationKey = HttpApi.make("test")
  379. .add(
  380. HttpApiGroup.make("a.b").add(
  381. HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge(
  382. OpenApi.annotations({ identifier: "a.b.c" }),
  383. ),
  384. ),
  385. )
  386. .add(
  387. HttpApiGroup.make("a").add(
  388. HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge(
  389. OpenApi.annotations({ identifier: "a.b.c" }),
  390. ),
  391. ),
  392. )
  393. expect(() => compileContract(operationKey)).toThrow("Client operation key collision: a.b.c")
  394. const prototype = HttpApi.make("test").add(
  395. HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })),
  396. )
  397. expect(() => compileContract(prototype, { groupNames: { session: "__proto__" } })).toThrow(
  398. "Client group name cannot be __proto__",
  399. )
  400. })
  401. test("omits custom transport endpoints", () => {
  402. const source = HttpApi.make("test").add(
  403. HttpApiGroup.make("server.pty")
  404. .add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String }))
  405. .add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })),
  406. )
  407. const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) })
  408. expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.identifier)).toEqual(["pty.get"])
  409. })
  410. test("uses bracket access for input field names", () => {
  411. const source = api(
  412. HttpApiEndpoint.post("token", "/token", {
  413. headers: { "x-example-token": Schema.Literal("1") },
  414. success: Schema.String,
  415. }),
  416. )
  417. const contract = compileContract(source)
  418. const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
  419. const effect = emitEffectImported(contract, {
  420. module: "@example/api",
  421. endpoints: { "session.token": "Token" },
  422. }).files.find((file) => file.path === "client.ts")?.content
  423. expect(promise).toContain('"x-example-token": input["x-example-token"]')
  424. expect(effect).toContain('"x-example-token": input["x-example-token"]')
  425. })
  426. test("rejects consumer group name collisions", () => {
  427. const source = HttpApi.make("test")
  428. .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
  429. .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
  430. expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
  431. "Client group name collision: same",
  432. )
  433. })
  434. test("uses the unqualified endpoint name for the public client", () => {
  435. const contract = compileContract(
  436. api(
  437. HttpApiEndpoint.get("session.get", "/session/:sessionID", {
  438. params: { sessionID: Schema.String },
  439. success: Schema.String,
  440. }),
  441. ),
  442. )
  443. const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
  444. const effect = emitEffectImported(contract, {
  445. module: "@example/api",
  446. endpoints: { "session.session.get": "SessionGet" },
  447. }).files.find((file) => file.path === "client.ts")?.content
  448. expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
  449. expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
  450. expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
  451. expect(effect).toContain('raw["session.get"]')
  452. })
  453. test("preserves optional keys in Promise error types", () => {
  454. class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
  455. "OptionalError",
  456. { message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
  457. { httpApiStatus: 400 },
  458. ) {}
  459. const output = emitPromise(
  460. compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
  461. )
  462. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  463. 'readonly "message": string; readonly "detail"?: string | undefined',
  464. )
  465. })
  466. test("supports name-discriminated Promise errors", () => {
  467. class NamedError extends Schema.ErrorClass<NamedError>("NamedError")(
  468. { name: Schema.Literal("NamedError"), message: Schema.String },
  469. { httpApiStatus: 400 },
  470. ) {}
  471. const output = emitPromise(
  472. compileContract(
  473. api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })),
  474. ),
  475. )
  476. const types = output.files.find((file) => file.path === "types.ts")?.content
  477. expect(types).toContain('readonly "name": "NamedError"')
  478. expect(types).toContain('"name" in value && value["name"] === "NamedError"')
  479. })
  480. test("preserves reflected default error statuses", () => {
  481. class MissingStatus extends Schema.TaggedErrorClass<MissingStatus>()("MissingStatus", {
  482. message: Schema.String,
  483. }) {}
  484. const output = emitPromise(
  485. compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))),
  486. )
  487. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]")
  488. })
  489. test("erases brands from Promise wire types", () => {
  490. const output = emitPromise(
  491. compileContract(
  492. api(
  493. HttpApiEndpoint.get("get", "/session/:sessionID", {
  494. params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
  495. success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
  496. }),
  497. ),
  498. ),
  499. )
  500. const types = output.files.find((file) => file.path === "types.ts")?.content
  501. expect(types).toContain('readonly "sessionID": string')
  502. expect(types).not.toContain("Brand")
  503. })
  504. test("preserves suggestions for open string unions in Promise wire types", () => {
  505. const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
  506. identifier: "Field",
  507. })
  508. const output = emitPromise(
  509. compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))),
  510. )
  511. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  512. 'export type Field = "reasoning" | "reasoning_content" | (string & {})',
  513. )
  514. })
  515. test("retains non-recursive references in Promise wire types", () => {
  516. const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
  517. const output = emitPromise(
  518. compileContract(
  519. api(
  520. HttpApiEndpoint.get("get", "/session", {
  521. success: Schema.Struct({ data: Referenced }),
  522. }),
  523. ),
  524. ),
  525. )
  526. const types = output.files.find((file) => file.path === "types.ts")?.content
  527. expect(types).toContain('export type Referenced = { readonly "value": string }')
  528. expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]')
  529. })
  530. test("emits mutable Promise outputs without restricting inputs", () => {
  531. const output = emitPromise(
  532. compileContract(
  533. api(
  534. HttpApiEndpoint.post("create", "/session", {
  535. payload: Schema.Struct({ values: Schema.Array(Schema.String) }),
  536. success: Schema.Struct({ data: Schema.Array(Schema.Struct({ values: Schema.Array(Schema.String) })) }),
  537. }),
  538. ),
  539. ),
  540. { mutableOutputs: true },
  541. )
  542. const types = output.files.find((file) => file.path === "types.ts")?.content
  543. expect(types).toContain('readonly "values": ReadonlyArray<string>')
  544. expect(types).toContain(
  545. 'export type SessionCreateOutput = ({ "data": Array<{ "values": Array<string> }> })["data"]',
  546. )
  547. })
  548. test("retains distinct Promise references at identifier boundaries", () => {
  549. const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
  550. identifier: "Session",
  551. })
  552. const SessionID = Schema.String.annotate({ identifier: "SessionID" })
  553. const output = emitPromise(
  554. compileContract(
  555. api(
  556. HttpApiEndpoint.get("get", "/session", {
  557. success: Schema.Struct({ session: Session, sessionID: SessionID }),
  558. }),
  559. ),
  560. ),
  561. )
  562. const types = output.files.find((file) => file.path === "types.ts")?.content
  563. expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }')
  564. expect(types).toContain("export type SessionID = string")
  565. expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID')
  566. })
  567. test("disambiguates flattened Promise reference names", () => {
  568. const First = Schema.String.annotate({ identifier: "ExampleName" })
  569. const Second = Schema.String.annotate({ identifier: "Example_Name" })
  570. const output = emitPromise(
  571. compileContract(
  572. api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ first: First, second: Second }) })),
  573. ),
  574. )
  575. const types = output.files.find((file) => file.path === "types.ts")?.content
  576. expect(types).toContain("export type ExampleName = string")
  577. expect(types).toContain("export type ExampleName2 = string")
  578. })
  579. test("emits Effect Json schemas as standalone Promise types", () => {
  580. const output = emitPromise(
  581. compileContract(
  582. api(
  583. HttpApiEndpoint.get("get", "/session", {
  584. success: Schema.Json,
  585. }),
  586. ),
  587. ),
  588. )
  589. const types = output.files.find((file) => file.path === "types.ts")?.content
  590. expect(types).toContain("export type JsonValue =")
  591. expect(types).toContain("{ readonly [key: string]: JsonValue }")
  592. expect(types).not.toContain("Schema.Json")
  593. })
  594. test("emits an optional Promise input when every field is optional", () => {
  595. const output = emitPromise(
  596. compileContract(
  597. api(
  598. HttpApiEndpoint.get("list", "/session", {
  599. query: { limit: Schema.optional(Schema.Number) },
  600. success: Schema.Array(Schema.String),
  601. }),
  602. ),
  603. ),
  604. )
  605. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  606. '"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
  607. )
  608. })
  609. test("rejects Promise transports that are not implemented", () => {
  610. expect(() =>
  611. emitPromise(
  612. compileContract(
  613. api(
  614. HttpApiEndpoint.get("text", "/text", {
  615. success: Schema.String.pipe(HttpApiSchema.asText()),
  616. }),
  617. ),
  618. ),
  619. ),
  620. ).toThrow("Unsupported Promise success encoding: session.text")
  621. expect(() =>
  622. emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))),
  623. ).toThrow("Unsupported Promise path wildcard: /file/*/tail")
  624. expect(() =>
  625. emitPromise(
  626. compileContract(
  627. api(
  628. HttpApiEndpoint.get("events", "/events", {
  629. success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
  630. }),
  631. ),
  632. ),
  633. ),
  634. ).toThrow("Unsupported Promise stream: session.events")
  635. })
  636. test("executes an emitted Promise GET through fetch", async () => {
  637. const output = emitPromise(
  638. compileContract(
  639. api(
  640. HttpApiEndpoint.get("get", "/session/:sessionID", {
  641. params: { sessionID: Schema.String },
  642. success: Schema.Struct({ data: Schema.String }),
  643. }),
  644. ),
  645. ),
  646. )
  647. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  648. try {
  649. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  650. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  651. let request: Request | undefined
  652. const client = generated.OpenCode.make({
  653. baseUrl: "https://example.com",
  654. fetch: async (input: RequestInfo | URL) => {
  655. request = input instanceof Request ? input : new Request(input)
  656. return Response.json({ data: "hello" })
  657. },
  658. })
  659. expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
  660. expect(request?.method).toBe("GET")
  661. expect(request?.url).toBe("https://example.com/session/a%2Fb")
  662. } finally {
  663. await rm(directory, { recursive: true, force: true })
  664. }
  665. })
  666. test("maps an emitted no-content response to undefined", async () => {
  667. const output = emitPromise(
  668. compileContract(
  669. api(
  670. HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
  671. params: { sessionID: Schema.String },
  672. success: HttpApiSchema.NoContent,
  673. }),
  674. ),
  675. ),
  676. )
  677. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  678. try {
  679. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  680. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  681. const client = generated.OpenCode.make({
  682. baseUrl: "https://example.com",
  683. fetch: async () => new Response(null, { status: 204 }),
  684. })
  685. expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
  686. } finally {
  687. await rm(directory, { recursive: true, force: true })
  688. }
  689. })
  690. test("executes an emitted binary wildcard GET through fetch", async () => {
  691. const output = emitPromise(
  692. compileContract(
  693. api(
  694. HttpApiEndpoint.get("read", "/file/*", {
  695. query: { token: Schema.optional(Schema.String) },
  696. success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
  697. }),
  698. ),
  699. ),
  700. )
  701. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  702. try {
  703. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  704. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  705. let request: Request | undefined
  706. const client = generated.OpenCode.make({
  707. baseUrl: "https://example.com",
  708. fetch: async (input: RequestInfo | URL) => {
  709. request = input instanceof Request ? input : new Request(input)
  710. return new Response(new Uint8Array([1, 2, 3]))
  711. },
  712. })
  713. const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
  714. expect(result).toBeInstanceOf(Uint8Array)
  715. expect(Array.from(result)).toEqual([1, 2, 3])
  716. expect(request?.method).toBe("GET")
  717. expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
  718. } finally {
  719. await rm(directory, { recursive: true, force: true })
  720. }
  721. })
  722. test("serializes flattened query, header, and JSON payload inputs", async () => {
  723. const output = emitPromise(
  724. compileContract(
  725. api(
  726. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  727. params: { sessionID: Schema.String },
  728. query: { resume: Schema.optional(Schema.Boolean) },
  729. headers: { traceID: Schema.String },
  730. payload: Schema.Struct({ prompt: Schema.String }),
  731. success: Schema.Struct({ data: Schema.String }),
  732. }),
  733. ),
  734. ),
  735. )
  736. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  737. try {
  738. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  739. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  740. let request: Request | undefined
  741. const client = generated.OpenCode.make({
  742. baseUrl: "https://example.com",
  743. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  744. request = input instanceof Request ? input : new Request(input, init)
  745. return Response.json({ data: "admitted" })
  746. },
  747. })
  748. expect(
  749. await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
  750. ).toBe("admitted")
  751. expect(request?.url).toBe("https://example.com/session/session?resume=true")
  752. expect(request?.headers.get("traceID")).toBe("trace")
  753. expect(await request?.json()).toEqual({ prompt: "hello" })
  754. } finally {
  755. await rm(directory, { recursive: true, force: true })
  756. }
  757. })
  758. test("serializes an opaque union payload as the direct JSON body", async () => {
  759. const output = emitPromise(
  760. compileContract(
  761. api(
  762. HttpApiEndpoint.post("configure", "/session", {
  763. payload: Schema.Union([
  764. Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
  765. Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
  766. ]),
  767. success: HttpApiSchema.NoContent,
  768. }),
  769. ),
  770. ),
  771. )
  772. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  773. try {
  774. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  775. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  776. let request: Request | undefined
  777. const client = generated.OpenCode.make({
  778. baseUrl: "https://example.com",
  779. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  780. request = input instanceof Request ? input : new Request(input, init)
  781. return new Response(null, { status: 204 })
  782. },
  783. })
  784. await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
  785. expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
  786. } finally {
  787. await rm(directory, { recursive: true, force: true })
  788. }
  789. })
  790. test("serializes explicit null query values", async () => {
  791. const output = emitPromise(
  792. compileContract(
  793. api(
  794. HttpApiEndpoint.get("list", "/session", {
  795. query: { parentID: Schema.optional(Schema.NullOr(Schema.String)) },
  796. success: Schema.Struct({ data: Schema.Array(Schema.String) }),
  797. }),
  798. ),
  799. ),
  800. )
  801. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  802. try {
  803. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  804. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  805. let request: Request | undefined
  806. const client = generated.OpenCode.make({
  807. baseUrl: "https://example.com",
  808. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  809. request = input instanceof Request ? input : new Request(input, init)
  810. return Response.json({ data: [] })
  811. },
  812. })
  813. await client.session.list({ parentID: null })
  814. expect(request?.url).toBe("https://example.com/session?parentID=null")
  815. } finally {
  816. await rm(directory, { recursive: true, force: true })
  817. }
  818. })
  819. test("rejects with declared tagged errors and exports a type guard", async () => {
  820. const output = emitPromise(
  821. compileContract(
  822. api(
  823. HttpApiEndpoint.get("get", "/session/:sessionID", {
  824. params: { sessionID: Schema.String },
  825. success: Schema.Struct({ data: Schema.String }),
  826. error: Missing.pipe(HttpApiSchema.status(404)),
  827. }),
  828. ),
  829. ),
  830. )
  831. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  832. try {
  833. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  834. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  835. const client = generated.OpenCode.make({
  836. baseUrl: "https://example.com",
  837. fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
  838. })
  839. const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
  840. expect(error).toEqual({ _tag: "Missing", message: "gone" })
  841. expect(generated.isMissing(error)).toBeTrue()
  842. } finally {
  843. await rm(directory, { recursive: true, force: true })
  844. }
  845. })
  846. test("iterates an emitted SSE stream lazily without reconnecting", async () => {
  847. const output = emitPromise(
  848. compileContract(
  849. api(
  850. HttpApiEndpoint.get("subscribe", "/event", {
  851. query: { after: Schema.optional(Schema.Number) },
  852. success: HttpApiSchema.StreamSse({
  853. data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }),
  854. }),
  855. }),
  856. ),
  857. ),
  858. )
  859. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  860. try {
  861. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  862. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  863. let requests = 0
  864. let url: string | undefined
  865. const client = generated.OpenCode.make({
  866. baseUrl: "https://example.com",
  867. fetch: async (input: RequestInfo | URL) => {
  868. requests++
  869. url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  870. const encoder = new TextEncoder()
  871. return new Response(
  872. new ReadableStream({
  873. start(controller) {
  874. controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
  875. controller.enqueue(encoder.encode("\n\r\n"))
  876. controller.close()
  877. },
  878. }),
  879. { headers: { "content-type": "text/event-stream" } },
  880. )
  881. },
  882. })
  883. const events = client.session.subscribe({ after: 2 })
  884. expect(requests).toBe(0)
  885. const received = []
  886. for await (const event of events) received.push(event)
  887. expect(received).toEqual([{ type: "ready", count: "1" }])
  888. expect(requests).toBe(1)
  889. expect(url).toBe("https://example.com/event?after=2")
  890. } finally {
  891. await rm(directory, { recursive: true, force: true })
  892. }
  893. })
  894. test("preserves public group and endpoint identifiers exactly", () => {
  895. const output = compile(
  896. HttpApi.make("test").add(
  897. HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
  898. ),
  899. )
  900. expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
  901. })
  902. test("emits one client module per HttpApi group", () => {
  903. const source = HttpApi.make("test")
  904. .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  905. .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
  906. const output = compile(source)
  907. expect(output.files.map((file) => file.path)).toEqual([
  908. "session.ts",
  909. "tool.ts",
  910. "client-error.ts",
  911. "client.ts",
  912. "index.ts",
  913. ])
  914. })
  915. test("emits syntactically valid TypeScript modules", () => {
  916. const output = compile(
  917. api(
  918. HttpApiEndpoint.get("get", "/session/:sessionID", {
  919. params: { sessionID: Schema.String },
  920. success: Schema.Struct({ data: Schema.String }),
  921. }),
  922. ),
  923. )
  924. const transpiler = new Bun.Transpiler({ loader: "ts" })
  925. for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
  926. })
  927. it.effect("keeps the strict generated-consumer fixture current", () =>
  928. Effect.gen(function* () {
  929. const output = compile(FixtureApi)
  930. const actual = yield* Effect.promise(() =>
  931. Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
  932. )
  933. expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
  934. output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
  935. )
  936. yield* Effect.forEach(output.files, (file) =>
  937. Effect.tryPromise(() =>
  938. Promise.all([
  939. Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
  940. format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
  941. ]),
  942. ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
  943. )
  944. }),
  945. )
  946. test("flattens transport input channels into one domain input", () => {
  947. const output = compile(
  948. api(
  949. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  950. params: { sessionID: Schema.String },
  951. query: { resume: Schema.String },
  952. headers: { traceID: Schema.String },
  953. payload: Schema.Struct({ prompt: Schema.String }),
  954. success: Schema.Struct({ data: Schema.String }),
  955. }),
  956. ),
  957. )
  958. expect(output.operations[0]?.input).toEqual([
  959. { name: "sessionID", source: "params" },
  960. { name: "resume", source: "query" },
  961. { name: "traceID", source: "headers" },
  962. { name: "prompt", source: "payload" },
  963. ])
  964. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  965. 'params: { "sessionID": input["sessionID"] }',
  966. )
  967. })
  968. test("uses one opaque field for non-struct payloads across emitters", () => {
  969. const source = api(
  970. HttpApiEndpoint.post("configure", "/session/configure", {
  971. payload: Schema.Union([
  972. Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
  973. Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
  974. ]),
  975. success: Schema.String,
  976. }),
  977. )
  978. const contract = compileContract(source)
  979. const effect = emitEffect(contract)
  980. const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
  981. const shape = emitEffectShape(contract)
  982. const promise = emitPromise(contract)
  983. expect(effect.operations[0]).toMatchObject({
  984. input: [{ name: "payload", source: "payload" }],
  985. inputMode: "required",
  986. })
  987. expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain('payload: input["payload"]')
  988. expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain('payload: input["payload"]')
  989. expect(shape.files[0]?.content).toContain(
  990. 'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray<string> }',
  991. )
  992. expect(promise.files.find((file) => file.path === "types.ts")?.content).toContain(
  993. 'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray<string> } | { readonly "type": "remote", readonly "url": string }',
  994. )
  995. expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain('body: input["payload"]')
  996. })
  997. test("routes arrays, primitives, and index-signature records through the opaque payload path", () => {
  998. for (const payload of [Schema.Array(Schema.String), Schema.String, Schema.Record(Schema.String, Schema.Number)]) {
  999. expect(
  1000. compileContract(api(HttpApiEndpoint.post("set", "/session", { payload, success: HttpApiSchema.NoContent })))
  1001. .groups[0]?.endpoints[0]?.operation.input,
  1002. ).toEqual([{ name: "payload", source: "payload" }])
  1003. }
  1004. })
  1005. test("rejects an opaque payload field that collides with another input channel", () => {
  1006. expect(() =>
  1007. compileContract(
  1008. api(
  1009. HttpApiEndpoint.post("configure", "/session", {
  1010. query: { payload: Schema.String },
  1011. payload: Schema.Union([Schema.String, Schema.Number]),
  1012. success: Schema.String,
  1013. }),
  1014. ),
  1015. ),
  1016. ).toThrow("Opaque payload field collision: session.configure.payload conflicts with query.payload")
  1017. })
  1018. test("preserves required empty struct payloads in imported Effect adapters", () => {
  1019. const contract = compileContract(
  1020. api(
  1021. HttpApiEndpoint.post("empty", "/session", {
  1022. payload: Schema.Struct({}),
  1023. success: Schema.String,
  1024. }),
  1025. ),
  1026. )
  1027. const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" })
  1028. const promise = emitPromise(contract)
  1029. expect(effect.files.find((file) => file.path === "client.ts")?.content).toContain("payload: { }")
  1030. expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain("body: { }")
  1031. })
  1032. test("uses no argument when an operation has no input fields", () => {
  1033. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  1034. expect(output.operations[0]?.inputMode).toBe("none")
  1035. })
  1036. test("uses an optional object when every input field is optional", () => {
  1037. const output = compile(
  1038. api(
  1039. HttpApiEndpoint.get("list", "/session", {
  1040. query: { limit: Schema.optional(Schema.String) },
  1041. success: Schema.Array(Schema.String),
  1042. }),
  1043. ),
  1044. )
  1045. expect(output.operations[0]?.inputMode).toBe("optional")
  1046. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
  1047. })
  1048. test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
  1049. const output = compile(
  1050. api(
  1051. HttpApiEndpoint.get("list", "/session", {
  1052. query: { archived: Schema.optional(Schema.Boolean) },
  1053. success: Schema.String,
  1054. }),
  1055. ),
  1056. )
  1057. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
  1058. })
  1059. test("uses a required object when any input field is required", () => {
  1060. const output = compile(
  1061. api(
  1062. HttpApiEndpoint.get("get", "/session/:sessionID", {
  1063. params: { sessionID: Schema.String },
  1064. query: { includeArchived: Schema.optional(Schema.String) },
  1065. success: Schema.String,
  1066. }),
  1067. ),
  1068. )
  1069. expect(output.operations[0]?.inputMode).toBe("required")
  1070. })
  1071. test("rejects colliding input names across transport channels", () => {
  1072. expect(() =>
  1073. compile(
  1074. api(
  1075. HttpApiEndpoint.post("prompt", "/session/:id", {
  1076. params: { id: Schema.String },
  1077. payload: Schema.Struct({ id: Schema.String }),
  1078. success: Schema.Void,
  1079. }),
  1080. ),
  1081. ),
  1082. ).toThrow("Input field collision: id")
  1083. })
  1084. test("rejects multiple payload alternatives until selection semantics are explicit", () => {
  1085. expect(() =>
  1086. compile(
  1087. api(
  1088. HttpApiEndpoint.post("prompt", "/session", {
  1089. payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
  1090. success: Schema.String,
  1091. }),
  1092. ),
  1093. ),
  1094. ).toThrow("Multiple payload schemas: session.prompt")
  1095. })
  1096. test("unwraps an exact data success envelope", () => {
  1097. const output = compile(
  1098. api(
  1099. HttpApiEndpoint.get("get", "/session/:sessionID", {
  1100. params: { sessionID: Schema.String },
  1101. success: Schema.Struct({ data: Schema.String }),
  1102. }),
  1103. ),
  1104. )
  1105. expect(output.operations[0]?.success).toBe("value")
  1106. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1107. "Effect.map((value) => value.data)",
  1108. )
  1109. })
  1110. test("maps no-content success to void", () => {
  1111. const output = compile(
  1112. api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
  1113. )
  1114. expect(output.operations[0]?.success).toBe("void")
  1115. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
  1116. })
  1117. test("preserves non-default empty response statuses", () => {
  1118. const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
  1119. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
  1120. })
  1121. test("returns a non-envelope success unchanged", () => {
  1122. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  1123. expect(output.operations[0]?.success).toBe("value")
  1124. })
  1125. test("rejects multiple success shapes until their public semantics are explicit", () => {
  1126. expect(() =>
  1127. compile(
  1128. api(
  1129. HttpApiEndpoint.get("get", "/session", {
  1130. success: [Schema.String, Schema.Number],
  1131. }),
  1132. ),
  1133. ),
  1134. ).toThrow("Multiple success schemas: session.get")
  1135. })
  1136. test("models an SSE success as a direct stream", () => {
  1137. const output = compile(
  1138. api(
  1139. HttpApiEndpoint.get("subscribe", "/event", {
  1140. success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
  1141. }),
  1142. ),
  1143. )
  1144. expect(output.operations[0]?.success).toBe("stream")
  1145. })
  1146. test("emits opaque Promise SSE fields as any", () => {
  1147. const output = emitPromise(
  1148. compileContract(
  1149. api(
  1150. HttpApiEndpoint.get("subscribe", "/event", {
  1151. success: HttpApiSchema.StreamSse({
  1152. data: Schema.Struct({
  1153. metadata: Schema.Record(Schema.String, Schema.Unknown),
  1154. label: Schema.Literal("unknown"),
  1155. }),
  1156. }),
  1157. }),
  1158. ),
  1159. ),
  1160. )
  1161. const types = output.files.find((file) => file.path === "types.ts")?.content
  1162. expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
  1163. expect(types).toContain('readonly "label": "unknown"')
  1164. })
  1165. test("preserves annotated stream response statuses", () => {
  1166. const output = compile(
  1167. api(
  1168. HttpApiEndpoint.get("subscribe", "/event", {
  1169. success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
  1170. }),
  1171. ),
  1172. )
  1173. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1174. ".pipe(HttpApiSchema.status(202))",
  1175. )
  1176. })
  1177. test("rejects schemas whose semantics cannot be emitted exactly", () => {
  1178. const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
  1179. expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
  1180. "Unportable schema: session.get.success",
  1181. )
  1182. })
  1183. test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
  1184. const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
  1185. Schema.decodeTo(Schema.Boolean, {
  1186. decode: SchemaGetter.transform((value) => value === "yes"),
  1187. encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  1188. }),
  1189. )
  1190. expect(() =>
  1191. compile(
  1192. api(
  1193. HttpApiEndpoint.get("get", "/session", {
  1194. query: { archived: QueryBoolean },
  1195. success: Schema.String,
  1196. }),
  1197. ),
  1198. ),
  1199. ).toThrow("Effect schema requires authoritative import: session.get")
  1200. })
  1201. test("rejects custom validation checks without portable metadata", () => {
  1202. const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
  1203. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
  1204. "Unportable schema: session.get.success",
  1205. )
  1206. })
  1207. test("rejects spoofed and aborted validation checks", () => {
  1208. const Spoofed = Schema.Number.check(
  1209. Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
  1210. )
  1211. const Aborted = Schema.Number.check(Schema.isFinite().abort())
  1212. expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
  1213. "Unportable schema: session.spoofed.success",
  1214. )
  1215. expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
  1216. "Unportable schema: session.aborted.success",
  1217. )
  1218. })
  1219. test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
  1220. const JsonNumber = Schema.toCodecJson(Schema.Number)
  1221. const link = JsonNumber.ast.encoding?.[0]
  1222. if (link === undefined) throw new Error("Expected JSON number encoding")
  1223. // This helper is present at runtime but omitted from the public declaration surface.
  1224. const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
  1225. if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
  1226. const ast: unknown = replaceEncoding(JsonNumber.ast, [
  1227. new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
  1228. ])
  1229. if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
  1230. const Altered = Schema.make<Schema.Top>(ast)
  1231. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
  1232. "Effect schema requires authoritative import: session.get",
  1233. )
  1234. })
  1235. test("rejects lexical generation and annotation values", () => {
  1236. const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
  1237. generation: { runtime: "LocalOnly", Type: "string" },
  1238. })
  1239. const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
  1240. custom: () => "local",
  1241. })
  1242. expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
  1243. "Unportable schema: session.generated.success",
  1244. )
  1245. expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
  1246. "Unportable schema: session.annotated.success",
  1247. )
  1248. })
  1249. test("preserves errors from server-only middleware", () => {
  1250. class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
  1251. class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
  1252. error: Unauthorized,
  1253. }) {}
  1254. const output = compile(
  1255. api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
  1256. )
  1257. expect(output.operations[0]).toBeDefined()
  1258. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1259. 'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
  1260. )
  1261. })
  1262. test("preserves tagged error response statuses", () => {
  1263. class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
  1264. const output = compile(
  1265. api(
  1266. HttpApiEndpoint.get("get", "/session", {
  1267. success: Schema.String,
  1268. error: Missing.pipe(HttpApiSchema.status(404)),
  1269. }),
  1270. ),
  1271. )
  1272. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1273. 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
  1274. )
  1275. })
  1276. test("supports every HttpApi method through the generic constructor", () => {
  1277. const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
  1278. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
  1279. })
  1280. test("uses safe unique module paths without changing public group identifiers", () => {
  1281. const output = compile(
  1282. HttpApi.make("test")
  1283. .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  1284. .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
  1285. )
  1286. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
  1287. expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
  1288. })
  1289. test("reserves support module names case-insensitively", () => {
  1290. const output = compile(
  1291. HttpApi.make("test")
  1292. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
  1293. .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
  1294. )
  1295. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
  1296. })
  1297. test("keeps searching when a reserved-name fallback is also occupied", () => {
  1298. const output = compile(
  1299. HttpApi.make("test")
  1300. .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
  1301. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
  1302. )
  1303. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
  1304. })
  1305. test("rejects collisions in the flattened client namespace", () => {
  1306. expect(() =>
  1307. compile(
  1308. HttpApi.make("test")
  1309. .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
  1310. .add(
  1311. HttpApiGroup.make("system", { topLevel: true }).add(
  1312. HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
  1313. ),
  1314. ),
  1315. ),
  1316. ).toThrow("Client name collision: status")
  1317. })
  1318. test("emits a usable raw type for top-level groups", () => {
  1319. const output = compile(
  1320. HttpApi.make("test").add(
  1321. HttpApiGroup.make("health", { topLevel: true }).add(
  1322. HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
  1323. ),
  1324. ),
  1325. )
  1326. expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
  1327. })
  1328. it.effect("reports compiler failures in the generate Effect", () =>
  1329. Effect.gen(function* () {
  1330. const error = yield* generate(
  1331. api(
  1332. HttpApiEndpoint.get("get", "/url", {
  1333. success: Schema.declare((input): input is URL => input instanceof URL),
  1334. }),
  1335. ),
  1336. {
  1337. directory: "/generated",
  1338. },
  1339. ).pipe(Effect.flip)
  1340. expect(error).toBeInstanceOf(GenerationError)
  1341. if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
  1342. }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
  1343. )
  1344. test("rejects required client middleware without an adapter", () => {
  1345. class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
  1346. requiredForClient: true,
  1347. }) {}
  1348. expect(() =>
  1349. compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
  1350. ).toThrow("Client middleware requires adapter: SignedRequest")
  1351. })
  1352. test("maps transport and decode failures to one stable client error", () => {
  1353. const output = compile(
  1354. api(
  1355. HttpApiEndpoint.get("get", "/session", {
  1356. success: Schema.String,
  1357. }),
  1358. ),
  1359. )
  1360. expect(output.operations[0]?.errors).toContain("ClientError")
  1361. expect(output.operations[0]?.errors).not.toContain("HttpClientError")
  1362. expect(output.operations[0]?.errors).not.toContain("SchemaError")
  1363. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1364. "new ClientError({ cause: error })",
  1365. )
  1366. })
  1367. })