generate.test.ts 50 KB

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