generate.test.ts 57 KB

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