generate.test.ts 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484
  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("preserves suggestions for open string unions in Promise wire types", () => {
  452. const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
  453. identifier: "Field",
  454. })
  455. const output = emitPromise(
  456. compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))),
  457. )
  458. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  459. 'export type Field = "reasoning" | "reasoning_content" | (string & {})',
  460. )
  461. })
  462. test("retains non-recursive references in Promise wire types", () => {
  463. const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
  464. const output = emitPromise(
  465. compileContract(
  466. api(
  467. HttpApiEndpoint.get("get", "/session", {
  468. success: Schema.Struct({ data: Referenced }),
  469. }),
  470. ),
  471. ),
  472. )
  473. const types = output.files.find((file) => file.path === "types.ts")?.content
  474. expect(types).toContain('export type Referenced = { readonly "value": string }')
  475. expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]')
  476. })
  477. test("emits mutable Promise outputs without restricting inputs", () => {
  478. const output = emitPromise(
  479. compileContract(
  480. api(
  481. HttpApiEndpoint.post("create", "/session", {
  482. payload: Schema.Struct({ values: Schema.Array(Schema.String) }),
  483. success: Schema.Struct({ data: Schema.Array(Schema.Struct({ values: Schema.Array(Schema.String) })) }),
  484. }),
  485. ),
  486. ),
  487. { mutableOutputs: true },
  488. )
  489. const types = output.files.find((file) => file.path === "types.ts")?.content
  490. expect(types).toContain('readonly "values": ReadonlyArray<string>')
  491. expect(types).toContain(
  492. 'export type SessionCreateOutput = ({ "data": Array<{ "values": Array<string> }> })["data"]',
  493. )
  494. })
  495. test("retains distinct Promise references at identifier boundaries", () => {
  496. const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
  497. identifier: "Session",
  498. })
  499. const SessionID = Schema.String.annotate({ identifier: "SessionID" })
  500. const output = emitPromise(
  501. compileContract(
  502. api(
  503. HttpApiEndpoint.get("get", "/session", {
  504. success: Schema.Struct({ session: Session, sessionID: SessionID }),
  505. }),
  506. ),
  507. ),
  508. )
  509. const types = output.files.find((file) => file.path === "types.ts")?.content
  510. expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }')
  511. expect(types).toContain("export type SessionID = string")
  512. expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID')
  513. })
  514. test("disambiguates flattened Promise reference names", () => {
  515. const First = Schema.String.annotate({ identifier: "ExampleName" })
  516. const Second = Schema.String.annotate({ identifier: "Example_Name" })
  517. const output = emitPromise(
  518. compileContract(
  519. api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ first: First, second: Second }) })),
  520. ),
  521. )
  522. const types = output.files.find((file) => file.path === "types.ts")?.content
  523. expect(types).toContain("export type ExampleName = string")
  524. expect(types).toContain("export type ExampleName2 = string")
  525. })
  526. test("emits Effect Json schemas as standalone Promise types", () => {
  527. const output = emitPromise(
  528. compileContract(
  529. api(
  530. HttpApiEndpoint.get("get", "/session", {
  531. success: Schema.Json,
  532. }),
  533. ),
  534. ),
  535. )
  536. const types = output.files.find((file) => file.path === "types.ts")?.content
  537. expect(types).toContain("export type JsonValue =")
  538. expect(types).toContain("{ readonly [key: string]: JsonValue }")
  539. expect(types).not.toContain("Schema.Json")
  540. })
  541. test("emits an optional Promise input when every field is optional", () => {
  542. const output = emitPromise(
  543. compileContract(
  544. api(
  545. HttpApiEndpoint.get("list", "/session", {
  546. query: { limit: Schema.optional(Schema.Number) },
  547. success: Schema.Array(Schema.String),
  548. }),
  549. ),
  550. ),
  551. )
  552. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  553. '"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
  554. )
  555. })
  556. test("rejects Promise transports that are not implemented", () => {
  557. expect(() =>
  558. emitPromise(
  559. compileContract(
  560. api(
  561. HttpApiEndpoint.get("text", "/text", {
  562. success: Schema.String.pipe(HttpApiSchema.asText()),
  563. }),
  564. ),
  565. ),
  566. ),
  567. ).toThrow("Unsupported Promise success encoding: session.text")
  568. expect(() =>
  569. emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))),
  570. ).toThrow("Unsupported Promise path wildcard: /file/*/tail")
  571. expect(() =>
  572. emitPromise(
  573. compileContract(
  574. api(
  575. HttpApiEndpoint.get("events", "/events", {
  576. success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
  577. }),
  578. ),
  579. ),
  580. ),
  581. ).toThrow("Unsupported Promise stream: session.events")
  582. })
  583. test("executes an emitted Promise GET through fetch", async () => {
  584. const output = emitPromise(
  585. compileContract(
  586. api(
  587. HttpApiEndpoint.get("get", "/session/:sessionID", {
  588. params: { sessionID: Schema.String },
  589. success: Schema.Struct({ data: Schema.String }),
  590. }),
  591. ),
  592. ),
  593. )
  594. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  595. try {
  596. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  597. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  598. let request: Request | undefined
  599. const client = generated.OpenCode.make({
  600. baseUrl: "https://example.com",
  601. fetch: async (input: RequestInfo | URL) => {
  602. request = input instanceof Request ? input : new Request(input)
  603. return Response.json({ data: "hello" })
  604. },
  605. })
  606. expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
  607. expect(request?.method).toBe("GET")
  608. expect(request?.url).toBe("https://example.com/session/a%2Fb")
  609. } finally {
  610. await rm(directory, { recursive: true, force: true })
  611. }
  612. })
  613. test("maps an emitted no-content response to undefined", async () => {
  614. const output = emitPromise(
  615. compileContract(
  616. api(
  617. HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
  618. params: { sessionID: Schema.String },
  619. success: HttpApiSchema.NoContent,
  620. }),
  621. ),
  622. ),
  623. )
  624. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  625. try {
  626. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  627. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  628. const client = generated.OpenCode.make({
  629. baseUrl: "https://example.com",
  630. fetch: async () => new Response(null, { status: 204 }),
  631. })
  632. expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
  633. } finally {
  634. await rm(directory, { recursive: true, force: true })
  635. }
  636. })
  637. test("executes an emitted binary wildcard GET through fetch", async () => {
  638. const output = emitPromise(
  639. compileContract(
  640. api(
  641. HttpApiEndpoint.get("read", "/file/*", {
  642. query: { token: Schema.optional(Schema.String) },
  643. success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
  644. }),
  645. ),
  646. ),
  647. )
  648. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  649. try {
  650. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  651. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  652. let request: Request | undefined
  653. const client = generated.OpenCode.make({
  654. baseUrl: "https://example.com",
  655. fetch: async (input: RequestInfo | URL) => {
  656. request = input instanceof Request ? input : new Request(input)
  657. return new Response(new Uint8Array([1, 2, 3]))
  658. },
  659. })
  660. const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
  661. expect(result).toBeInstanceOf(Uint8Array)
  662. expect(Array.from(result)).toEqual([1, 2, 3])
  663. expect(request?.method).toBe("GET")
  664. expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
  665. } finally {
  666. await rm(directory, { recursive: true, force: true })
  667. }
  668. })
  669. test("serializes flattened query, header, and JSON payload inputs", async () => {
  670. const output = emitPromise(
  671. compileContract(
  672. api(
  673. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  674. params: { sessionID: Schema.String },
  675. query: { resume: Schema.optional(Schema.Boolean) },
  676. headers: { traceID: Schema.String },
  677. payload: Schema.Struct({ prompt: Schema.String }),
  678. success: Schema.Struct({ data: Schema.String }),
  679. }),
  680. ),
  681. ),
  682. )
  683. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  684. try {
  685. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  686. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  687. let request: Request | undefined
  688. const client = generated.OpenCode.make({
  689. baseUrl: "https://example.com",
  690. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  691. request = input instanceof Request ? input : new Request(input, init)
  692. return Response.json({ data: "admitted" })
  693. },
  694. })
  695. expect(
  696. await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
  697. ).toBe("admitted")
  698. expect(request?.url).toBe("https://example.com/session/session?resume=true")
  699. expect(request?.headers.get("traceID")).toBe("trace")
  700. expect(await request?.json()).toEqual({ prompt: "hello" })
  701. } finally {
  702. await rm(directory, { recursive: true, force: true })
  703. }
  704. })
  705. test("serializes an opaque union payload as the direct JSON body", async () => {
  706. const output = emitPromise(
  707. compileContract(
  708. api(
  709. HttpApiEndpoint.post("configure", "/session", {
  710. payload: Schema.Union([
  711. Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
  712. Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
  713. ]),
  714. success: HttpApiSchema.NoContent,
  715. }),
  716. ),
  717. ),
  718. )
  719. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  720. try {
  721. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  722. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  723. let request: Request | undefined
  724. const client = generated.OpenCode.make({
  725. baseUrl: "https://example.com",
  726. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  727. request = input instanceof Request ? input : new Request(input, init)
  728. return new Response(null, { status: 204 })
  729. },
  730. })
  731. await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
  732. expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
  733. } finally {
  734. await rm(directory, { recursive: true, force: true })
  735. }
  736. })
  737. test("serializes explicit null query values", async () => {
  738. const output = emitPromise(
  739. compileContract(
  740. api(
  741. HttpApiEndpoint.get("list", "/session", {
  742. query: { parentID: Schema.optional(Schema.NullOr(Schema.String)) },
  743. success: Schema.Struct({ data: Schema.Array(Schema.String) }),
  744. }),
  745. ),
  746. ),
  747. )
  748. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  749. try {
  750. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  751. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  752. let request: Request | undefined
  753. const client = generated.OpenCode.make({
  754. baseUrl: "https://example.com",
  755. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  756. request = input instanceof Request ? input : new Request(input, init)
  757. return Response.json({ data: [] })
  758. },
  759. })
  760. await client.session.list({ parentID: null })
  761. expect(request?.url).toBe("https://example.com/session?parentID=null")
  762. } finally {
  763. await rm(directory, { recursive: true, force: true })
  764. }
  765. })
  766. test("rejects with declared tagged errors and exports a type guard", async () => {
  767. const output = emitPromise(
  768. compileContract(
  769. api(
  770. HttpApiEndpoint.get("get", "/session/:sessionID", {
  771. params: { sessionID: Schema.String },
  772. success: Schema.Struct({ data: Schema.String }),
  773. error: Missing.pipe(HttpApiSchema.status(404)),
  774. }),
  775. ),
  776. ),
  777. )
  778. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  779. try {
  780. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  781. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  782. const client = generated.OpenCode.make({
  783. baseUrl: "https://example.com",
  784. fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
  785. })
  786. const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
  787. expect(error).toEqual({ _tag: "Missing", message: "gone" })
  788. expect(generated.isMissing(error)).toBeTrue()
  789. } finally {
  790. await rm(directory, { recursive: true, force: true })
  791. }
  792. })
  793. test("iterates an emitted SSE stream lazily without reconnecting", async () => {
  794. const output = emitPromise(
  795. compileContract(
  796. api(
  797. HttpApiEndpoint.get("subscribe", "/event", {
  798. query: { after: Schema.optional(Schema.Number) },
  799. success: HttpApiSchema.StreamSse({
  800. data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }),
  801. }),
  802. }),
  803. ),
  804. ),
  805. )
  806. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  807. try {
  808. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  809. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  810. let requests = 0
  811. let url: string | undefined
  812. const client = generated.OpenCode.make({
  813. baseUrl: "https://example.com",
  814. fetch: async (input: RequestInfo | URL) => {
  815. requests++
  816. url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  817. const encoder = new TextEncoder()
  818. return new Response(
  819. new ReadableStream({
  820. start(controller) {
  821. controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
  822. controller.enqueue(encoder.encode("\n\r\n"))
  823. controller.close()
  824. },
  825. }),
  826. { headers: { "content-type": "text/event-stream" } },
  827. )
  828. },
  829. })
  830. const events = client.session.subscribe({ after: 2 })
  831. expect(requests).toBe(0)
  832. const received = []
  833. for await (const event of events) received.push(event)
  834. expect(received).toEqual([{ type: "ready", count: "1" }])
  835. expect(requests).toBe(1)
  836. expect(url).toBe("https://example.com/event?after=2")
  837. } finally {
  838. await rm(directory, { recursive: true, force: true })
  839. }
  840. })
  841. test("preserves public group and endpoint identifiers exactly", () => {
  842. const output = compile(
  843. HttpApi.make("test").add(
  844. HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
  845. ),
  846. )
  847. expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
  848. })
  849. test("emits one client module per HttpApi group", () => {
  850. const source = HttpApi.make("test")
  851. .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  852. .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
  853. const output = compile(source)
  854. expect(output.files.map((file) => file.path)).toEqual([
  855. "session.ts",
  856. "tool.ts",
  857. "client-error.ts",
  858. "client.ts",
  859. "index.ts",
  860. ])
  861. })
  862. test("emits syntactically valid TypeScript modules", () => {
  863. const output = compile(
  864. api(
  865. HttpApiEndpoint.get("get", "/session/:sessionID", {
  866. params: { sessionID: Schema.String },
  867. success: Schema.Struct({ data: Schema.String }),
  868. }),
  869. ),
  870. )
  871. const transpiler = new Bun.Transpiler({ loader: "ts" })
  872. for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
  873. })
  874. it.effect("keeps the strict generated-consumer fixture current", () =>
  875. Effect.gen(function* () {
  876. const output = compile(FixtureApi)
  877. const actual = yield* Effect.promise(() =>
  878. Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
  879. )
  880. expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
  881. output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
  882. )
  883. yield* Effect.forEach(output.files, (file) =>
  884. Effect.tryPromise(() =>
  885. Promise.all([
  886. Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
  887. format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
  888. ]),
  889. ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
  890. )
  891. }),
  892. )
  893. test("flattens transport input channels into one domain input", () => {
  894. const output = compile(
  895. api(
  896. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  897. params: { sessionID: Schema.String },
  898. query: { resume: Schema.String },
  899. headers: { traceID: Schema.String },
  900. payload: Schema.Struct({ prompt: Schema.String }),
  901. success: Schema.Struct({ data: Schema.String }),
  902. }),
  903. ),
  904. )
  905. expect(output.operations[0]?.input).toEqual([
  906. { name: "sessionID", source: "params" },
  907. { name: "resume", source: "query" },
  908. { name: "traceID", source: "headers" },
  909. { name: "prompt", source: "payload" },
  910. ])
  911. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  912. 'params: { "sessionID": input["sessionID"] }',
  913. )
  914. })
  915. test("uses one opaque field for non-struct payloads across emitters", () => {
  916. const source = api(
  917. HttpApiEndpoint.post("configure", "/session/configure", {
  918. payload: Schema.Union([
  919. Schema.Struct({ type: Schema.Literal("local"), command: Schema.Array(Schema.String) }),
  920. Schema.Struct({ type: Schema.Literal("remote"), url: Schema.String }),
  921. ]),
  922. success: Schema.String,
  923. }),
  924. )
  925. const contract = compileContract(source)
  926. const effect = emitEffect(contract)
  927. const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
  928. const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
  929. const promise = emitPromise(contract)
  930. expect(effect.operations[0]).toMatchObject({
  931. input: [{ name: "payload", source: "payload" }],
  932. inputMode: "required",
  933. })
  934. expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain('payload: input["payload"]')
  935. expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain('payload: input["payload"]')
  936. expect(shape.files[0]?.content).toContain('Endpoint0_0Request["payload"]')
  937. expect(promise.files.find((file) => file.path === "types.ts")?.content).toContain(
  938. 'readonly "payload": { readonly "type": "local", readonly "command": ReadonlyArray<string> } | { readonly "type": "remote", readonly "url": string }',
  939. )
  940. expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain('body: input["payload"]')
  941. })
  942. test("routes arrays, primitives, and index-signature records through the opaque payload path", () => {
  943. for (const payload of [Schema.Array(Schema.String), Schema.String, Schema.Record(Schema.String, Schema.Number)]) {
  944. expect(
  945. compileContract(api(HttpApiEndpoint.post("set", "/session", { payload, success: HttpApiSchema.NoContent })))
  946. .groups[0]?.endpoints[0]?.operation.input,
  947. ).toEqual([{ name: "payload", source: "payload" }])
  948. }
  949. })
  950. test("rejects an opaque payload field that collides with another input channel", () => {
  951. expect(() =>
  952. compileContract(
  953. api(
  954. HttpApiEndpoint.post("configure", "/session", {
  955. query: { payload: Schema.String },
  956. payload: Schema.Union([Schema.String, Schema.Number]),
  957. success: Schema.String,
  958. }),
  959. ),
  960. ),
  961. ).toThrow("Opaque payload field collision: session.configure.payload conflicts with query.payload")
  962. })
  963. test("preserves required empty struct payloads in imported Effect adapters", () => {
  964. const contract = compileContract(
  965. api(
  966. HttpApiEndpoint.post("empty", "/session", {
  967. payload: Schema.Struct({}),
  968. success: Schema.String,
  969. }),
  970. ),
  971. )
  972. const effect = emitEffectImported(contract, { module: "@example/api", api: "Api" })
  973. const promise = emitPromise(contract)
  974. expect(effect.files.find((file) => file.path === "client.ts")?.content).toContain("payload: { }")
  975. expect(promise.files.find((file) => file.path === "client.ts")?.content).toContain("body: { }")
  976. })
  977. test("uses no argument when an operation has no input fields", () => {
  978. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  979. expect(output.operations[0]?.inputMode).toBe("none")
  980. })
  981. test("uses an optional object when every input field is optional", () => {
  982. const output = compile(
  983. api(
  984. HttpApiEndpoint.get("list", "/session", {
  985. query: { limit: Schema.optional(Schema.String) },
  986. success: Schema.Array(Schema.String),
  987. }),
  988. ),
  989. )
  990. expect(output.operations[0]?.inputMode).toBe("optional")
  991. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
  992. })
  993. test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
  994. const output = compile(
  995. api(
  996. HttpApiEndpoint.get("list", "/session", {
  997. query: { archived: Schema.optional(Schema.Boolean) },
  998. success: Schema.String,
  999. }),
  1000. ),
  1001. )
  1002. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
  1003. })
  1004. test("uses a required object when any input field is required", () => {
  1005. const output = compile(
  1006. api(
  1007. HttpApiEndpoint.get("get", "/session/:sessionID", {
  1008. params: { sessionID: Schema.String },
  1009. query: { includeArchived: Schema.optional(Schema.String) },
  1010. success: Schema.String,
  1011. }),
  1012. ),
  1013. )
  1014. expect(output.operations[0]?.inputMode).toBe("required")
  1015. })
  1016. test("rejects colliding input names across transport channels", () => {
  1017. expect(() =>
  1018. compile(
  1019. api(
  1020. HttpApiEndpoint.post("prompt", "/session/:id", {
  1021. params: { id: Schema.String },
  1022. payload: Schema.Struct({ id: Schema.String }),
  1023. success: Schema.Void,
  1024. }),
  1025. ),
  1026. ),
  1027. ).toThrow("Input field collision: id")
  1028. })
  1029. test("rejects multiple payload alternatives until selection semantics are explicit", () => {
  1030. expect(() =>
  1031. compile(
  1032. api(
  1033. HttpApiEndpoint.post("prompt", "/session", {
  1034. payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
  1035. success: Schema.String,
  1036. }),
  1037. ),
  1038. ),
  1039. ).toThrow("Multiple payload schemas: session.prompt")
  1040. })
  1041. test("unwraps an exact data success envelope", () => {
  1042. const output = compile(
  1043. api(
  1044. HttpApiEndpoint.get("get", "/session/:sessionID", {
  1045. params: { sessionID: Schema.String },
  1046. success: Schema.Struct({ data: Schema.String }),
  1047. }),
  1048. ),
  1049. )
  1050. expect(output.operations[0]?.success).toBe("value")
  1051. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1052. "Effect.map((value) => value.data)",
  1053. )
  1054. })
  1055. test("maps no-content success to void", () => {
  1056. const output = compile(
  1057. api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
  1058. )
  1059. expect(output.operations[0]?.success).toBe("void")
  1060. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
  1061. })
  1062. test("preserves non-default empty response statuses", () => {
  1063. const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
  1064. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
  1065. })
  1066. test("returns a non-envelope success unchanged", () => {
  1067. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  1068. expect(output.operations[0]?.success).toBe("value")
  1069. })
  1070. test("rejects multiple success shapes until their public semantics are explicit", () => {
  1071. expect(() =>
  1072. compile(
  1073. api(
  1074. HttpApiEndpoint.get("get", "/session", {
  1075. success: [Schema.String, Schema.Number],
  1076. }),
  1077. ),
  1078. ),
  1079. ).toThrow("Multiple success schemas: session.get")
  1080. })
  1081. test("models an SSE success as a direct stream", () => {
  1082. const output = compile(
  1083. api(
  1084. HttpApiEndpoint.get("subscribe", "/event", {
  1085. success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
  1086. }),
  1087. ),
  1088. )
  1089. expect(output.operations[0]?.success).toBe("stream")
  1090. })
  1091. test("emits opaque Promise SSE fields as any", () => {
  1092. const output = emitPromise(
  1093. compileContract(
  1094. api(
  1095. HttpApiEndpoint.get("subscribe", "/event", {
  1096. success: HttpApiSchema.StreamSse({
  1097. data: Schema.Struct({
  1098. metadata: Schema.Record(Schema.String, Schema.Unknown),
  1099. label: Schema.Literal("unknown"),
  1100. }),
  1101. }),
  1102. }),
  1103. ),
  1104. ),
  1105. )
  1106. const types = output.files.find((file) => file.path === "types.ts")?.content
  1107. expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
  1108. expect(types).toContain('readonly "label": "unknown"')
  1109. })
  1110. test("preserves annotated stream response statuses", () => {
  1111. const output = compile(
  1112. api(
  1113. HttpApiEndpoint.get("subscribe", "/event", {
  1114. success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
  1115. }),
  1116. ),
  1117. )
  1118. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1119. ".pipe(HttpApiSchema.status(202))",
  1120. )
  1121. })
  1122. test("rejects schemas whose semantics cannot be emitted exactly", () => {
  1123. const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
  1124. expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
  1125. "Unportable schema: session.get.success",
  1126. )
  1127. })
  1128. test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
  1129. const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
  1130. Schema.decodeTo(Schema.Boolean, {
  1131. decode: SchemaGetter.transform((value) => value === "yes"),
  1132. encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  1133. }),
  1134. )
  1135. expect(() =>
  1136. compile(
  1137. api(
  1138. HttpApiEndpoint.get("get", "/session", {
  1139. query: { archived: QueryBoolean },
  1140. success: Schema.String,
  1141. }),
  1142. ),
  1143. ),
  1144. ).toThrow("Effect schema requires authoritative import: session.get")
  1145. })
  1146. test("rejects custom validation checks without portable metadata", () => {
  1147. const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
  1148. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
  1149. "Unportable schema: session.get.success",
  1150. )
  1151. })
  1152. test("rejects spoofed and aborted validation checks", () => {
  1153. const Spoofed = Schema.Number.check(
  1154. Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
  1155. )
  1156. const Aborted = Schema.Number.check(Schema.isFinite().abort())
  1157. expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
  1158. "Unportable schema: session.spoofed.success",
  1159. )
  1160. expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
  1161. "Unportable schema: session.aborted.success",
  1162. )
  1163. })
  1164. test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
  1165. const JsonNumber = Schema.toCodecJson(Schema.Number)
  1166. const link = JsonNumber.ast.encoding?.[0]
  1167. if (link === undefined) throw new Error("Expected JSON number encoding")
  1168. // This helper is present at runtime but omitted from the public declaration surface.
  1169. const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
  1170. if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
  1171. const ast: unknown = replaceEncoding(JsonNumber.ast, [
  1172. new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
  1173. ])
  1174. if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
  1175. const Altered = Schema.make<Schema.Top>(ast)
  1176. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
  1177. "Effect schema requires authoritative import: session.get",
  1178. )
  1179. })
  1180. test("rejects lexical generation and annotation values", () => {
  1181. const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
  1182. generation: { runtime: "LocalOnly", Type: "string" },
  1183. })
  1184. const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
  1185. custom: () => "local",
  1186. })
  1187. expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
  1188. "Unportable schema: session.generated.success",
  1189. )
  1190. expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
  1191. "Unportable schema: session.annotated.success",
  1192. )
  1193. })
  1194. test("preserves errors from server-only middleware", () => {
  1195. class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
  1196. class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
  1197. error: Unauthorized,
  1198. }) {}
  1199. const output = compile(
  1200. api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
  1201. )
  1202. expect(output.operations[0]).toBeDefined()
  1203. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1204. 'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
  1205. )
  1206. })
  1207. test("preserves tagged error response statuses", () => {
  1208. class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
  1209. const output = compile(
  1210. api(
  1211. HttpApiEndpoint.get("get", "/session", {
  1212. success: Schema.String,
  1213. error: Missing.pipe(HttpApiSchema.status(404)),
  1214. }),
  1215. ),
  1216. )
  1217. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1218. 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
  1219. )
  1220. })
  1221. test("supports every HttpApi method through the generic constructor", () => {
  1222. const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
  1223. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
  1224. })
  1225. test("uses safe unique module paths without changing public group identifiers", () => {
  1226. const output = compile(
  1227. HttpApi.make("test")
  1228. .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  1229. .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
  1230. )
  1231. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
  1232. expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
  1233. })
  1234. test("reserves support module names case-insensitively", () => {
  1235. const output = compile(
  1236. HttpApi.make("test")
  1237. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
  1238. .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
  1239. )
  1240. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
  1241. })
  1242. test("keeps searching when a reserved-name fallback is also occupied", () => {
  1243. const output = compile(
  1244. HttpApi.make("test")
  1245. .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
  1246. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
  1247. )
  1248. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
  1249. })
  1250. test("rejects collisions in the flattened client namespace", () => {
  1251. expect(() =>
  1252. compile(
  1253. HttpApi.make("test")
  1254. .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
  1255. .add(
  1256. HttpApiGroup.make("system", { topLevel: true }).add(
  1257. HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
  1258. ),
  1259. ),
  1260. ),
  1261. ).toThrow("Client name collision: status")
  1262. })
  1263. test("emits a usable raw type for top-level groups", () => {
  1264. const output = compile(
  1265. HttpApi.make("test").add(
  1266. HttpApiGroup.make("health", { topLevel: true }).add(
  1267. HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
  1268. ),
  1269. ),
  1270. )
  1271. expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
  1272. })
  1273. it.effect("reports compiler failures in the generate Effect", () =>
  1274. Effect.gen(function* () {
  1275. const error = yield* generate(
  1276. api(
  1277. HttpApiEndpoint.get("get", "/url", {
  1278. success: Schema.declare((input): input is URL => input instanceof URL),
  1279. }),
  1280. ),
  1281. {
  1282. directory: "/generated",
  1283. },
  1284. ).pipe(Effect.flip)
  1285. expect(error).toBeInstanceOf(GenerationError)
  1286. if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
  1287. }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
  1288. )
  1289. test("rejects required client middleware without an adapter", () => {
  1290. class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
  1291. requiredForClient: true,
  1292. }) {}
  1293. expect(() =>
  1294. compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
  1295. ).toThrow("Client middleware requires adapter: SignedRequest")
  1296. })
  1297. test("maps transport and decode failures to one stable client error", () => {
  1298. const output = compile(
  1299. api(
  1300. HttpApiEndpoint.get("get", "/session", {
  1301. success: Schema.String,
  1302. }),
  1303. ),
  1304. )
  1305. expect(output.operations[0]?.errors).toContain("ClientError")
  1306. expect(output.operations[0]?.errors).not.toContain("HttpClientError")
  1307. expect(output.operations[0]?.errors).not.toContain("SchemaError")
  1308. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  1309. "new ClientError({ cause: error })",
  1310. )
  1311. })
  1312. })