generate.test.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  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 } from "effect/unstable/httpapi"
  7. import { format } from "prettier"
  8. import {
  9. compile as compileContract,
  10. emitEffect,
  11. emitEffectImported,
  12. emitPromise,
  13. generate,
  14. GenerationError,
  15. } from "../src"
  16. import { it } from "./effect"
  17. import { Api as FixtureApi, Missing } from "./fixture"
  18. function api(endpoint: HttpApiEndpoint.Any) {
  19. return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
  20. }
  21. function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
  22. return emitEffect(compileContract(source))
  23. }
  24. describe("HttpApiCodegen.generate", () => {
  25. test("compiles one contract for Promise and Effect emitters", () => {
  26. const contract = compileContract(
  27. api(
  28. HttpApiEndpoint.get("get", "/session/:sessionID", {
  29. params: { sessionID: Schema.String },
  30. success: Schema.Struct({ data: Schema.String }),
  31. }),
  32. ),
  33. )
  34. const promise = emitPromise(contract)
  35. const effect = emitEffect(contract)
  36. expect(promise.operations).toEqual(effect.operations)
  37. expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
  38. const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
  39. expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
  40. expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
  41. expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
  42. 'params: { "sessionID": input["sessionID"] }',
  43. )
  44. })
  45. test("allows Promise outputs to use an authoritative imported wire type", () => {
  46. const contract = compileContract(
  47. api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
  48. )
  49. const output = emitPromise(contract, {
  50. outputTypes: {
  51. "session.events": {
  52. name: "EventWire",
  53. import: 'import type { EventWire } from "./event-wire"',
  54. },
  55. },
  56. })
  57. const types = output.files.find((file) => file.path === "types.ts")?.content
  58. expect(types).toContain('import type { EventWire } from "./event-wire"')
  59. expect(types).toContain("export type SessionEventsOutput = EventWire")
  60. })
  61. test("emits an Effect client against an imported authoritative API", () => {
  62. const output = emitEffectImported(
  63. compileContract(
  64. api(
  65. HttpApiEndpoint.get("get", "/session/:sessionID", {
  66. params: { sessionID: Schema.String },
  67. success: Schema.Struct({ data: Schema.String }),
  68. }),
  69. ),
  70. ),
  71. { module: "@example/api", api: "Api" },
  72. )
  73. expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
  74. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  75. 'import { Api } from "@example/api"',
  76. )
  77. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  78. "HttpApiClient.ForApi<typeof Api>",
  79. )
  80. })
  81. test("projects imported endpoint constants into a generated API", () => {
  82. const output = emitEffectImported(
  83. compileContract(
  84. api(
  85. HttpApiEndpoint.get("get", "/session/:sessionID", {
  86. params: { sessionID: Schema.String },
  87. success: Schema.Struct({ data: Schema.String }),
  88. }),
  89. ),
  90. ),
  91. { module: "@example/api", endpoints: { "session.get": "SessionGet" } },
  92. )
  93. const client = output.files.find((file) => file.path === "client.ts")?.content
  94. expect(client).toContain('import { SessionGet } from "@example/api"')
  95. expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
  96. })
  97. test("imports an authoritative group without reconstructing it", () => {
  98. const output = emitEffectImported(
  99. compileContract(
  100. api(
  101. HttpApiEndpoint.get("get", "/session/:sessionID", {
  102. params: { sessionID: Schema.String },
  103. success: Schema.String,
  104. }),
  105. ),
  106. ),
  107. { module: "@example/api", group: "SessionGroup" },
  108. )
  109. const client = output.files.find((file) => file.path === "client.ts")?.content
  110. expect(client).toContain('import { SessionGroup } from "@example/api"')
  111. expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
  112. expect(client).not.toContain("HttpApiGroup")
  113. })
  114. test("separates hosted and consumer group names", () => {
  115. const source = HttpApi.make("test").add(
  116. HttpApiGroup.make("server.session").add(
  117. HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
  118. ),
  119. )
  120. const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
  121. expect(contract.groups[0]?.identifier).toBe("sessions")
  122. expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
  123. expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
  124. })
  125. test("supports explicit public endpoint names", () => {
  126. const source = HttpApi.make("test").add(
  127. HttpApiGroup.make("server.permission")
  128. .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String }))
  129. .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })),
  130. )
  131. const contract = compileContract(source, {
  132. endpointNames: { "permission.request.list": "listRequests" },
  133. })
  134. expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
  135. })
  136. test("omits custom transport endpoints", () => {
  137. const source = HttpApi.make("test").add(
  138. HttpApiGroup.make("server.pty")
  139. .add(HttpApiEndpoint.get("pty.get", "/pty", { success: Schema.String }))
  140. .add(HttpApiEndpoint.get("pty.connect", "/pty/connect", { success: Schema.Boolean })),
  141. )
  142. const contract = compileContract(source, { omitEndpoints: new Set(["pty.connect"]) })
  143. expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.endpoint.name)).toEqual(["pty.get"])
  144. })
  145. test("uses bracket access for input field names", () => {
  146. const source = api(
  147. HttpApiEndpoint.post("token", "/token", {
  148. headers: { "x-example-token": Schema.Literal("1") },
  149. success: Schema.String,
  150. }),
  151. )
  152. const contract = compileContract(source)
  153. const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
  154. const effect = emitEffectImported(contract, {
  155. module: "@example/api",
  156. endpoints: { "session.token": "Token" },
  157. }).files.find((file) => file.path === "client.ts")?.content
  158. expect(promise).toContain('"x-example-token": input["x-example-token"]')
  159. expect(effect).toContain('"x-example-token": input["x-example-token"]')
  160. })
  161. test("rejects consumer group name collisions", () => {
  162. const source = HttpApi.make("test")
  163. .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
  164. .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
  165. expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
  166. "Client group name collision: same",
  167. )
  168. })
  169. test("uses the unqualified endpoint name for the public client", () => {
  170. const contract = compileContract(
  171. api(
  172. HttpApiEndpoint.get("session.get", "/session/:sessionID", {
  173. params: { sessionID: Schema.String },
  174. success: Schema.String,
  175. }),
  176. ),
  177. )
  178. const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
  179. const effect = emitEffectImported(contract, {
  180. module: "@example/api",
  181. endpoints: { "session.session.get": "SessionGet" },
  182. }).files.find((file) => file.path === "client.ts")?.content
  183. expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
  184. expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
  185. expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
  186. expect(effect).toContain('raw["session.get"]')
  187. })
  188. test("preserves optional keys in Promise error types", () => {
  189. class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
  190. "OptionalError",
  191. { message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
  192. { httpApiStatus: 400 },
  193. ) {}
  194. const output = emitPromise(
  195. compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
  196. )
  197. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  198. 'readonly "message": string; readonly "detail"?: string | undefined',
  199. )
  200. })
  201. test("supports name-discriminated Promise errors", () => {
  202. class NamedError extends Schema.ErrorClass<NamedError>("NamedError")(
  203. { name: Schema.Literal("NamedError"), message: Schema.String },
  204. { httpApiStatus: 400 },
  205. ) {}
  206. const output = emitPromise(
  207. compileContract(
  208. api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })),
  209. ),
  210. )
  211. const types = output.files.find((file) => file.path === "types.ts")?.content
  212. expect(types).toContain('readonly "name": "NamedError"')
  213. expect(types).toContain('"name" in value && value["name"] === "NamedError"')
  214. })
  215. test("preserves reflected default error statuses", () => {
  216. class MissingStatus extends Schema.TaggedErrorClass<MissingStatus>()("MissingStatus", {
  217. message: Schema.String,
  218. }) {}
  219. const output = emitPromise(
  220. compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: MissingStatus }))),
  221. )
  222. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain("declaredStatuses: [500]")
  223. })
  224. test("erases brands from Promise wire types", () => {
  225. const output = emitPromise(
  226. compileContract(
  227. api(
  228. HttpApiEndpoint.get("get", "/session/:sessionID", {
  229. params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
  230. success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
  231. }),
  232. ),
  233. ),
  234. )
  235. const types = output.files.find((file) => file.path === "types.ts")?.content
  236. expect(types).toContain('readonly "sessionID": string')
  237. expect(types).not.toContain("Brand")
  238. })
  239. test("inlines non-recursive references in Promise wire types", () => {
  240. const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
  241. const output = emitPromise(
  242. compileContract(
  243. api(
  244. HttpApiEndpoint.get("get", "/session", {
  245. success: Schema.Struct({ data: Referenced }),
  246. }),
  247. ),
  248. ),
  249. )
  250. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  251. 'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
  252. )
  253. })
  254. test("expands Promise references only at identifier boundaries", () => {
  255. const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
  256. identifier: "Session",
  257. })
  258. const SessionID = Schema.String.annotate({ identifier: "SessionID" })
  259. const output = emitPromise(
  260. compileContract(
  261. api(
  262. HttpApiEndpoint.get("get", "/session", {
  263. success: Schema.Struct({ session: Session, sessionID: SessionID }),
  264. }),
  265. ),
  266. ),
  267. )
  268. expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
  269. 'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
  270. )
  271. })
  272. test("emits Effect Json schemas as standalone Promise types", () => {
  273. const output = emitPromise(
  274. compileContract(
  275. api(
  276. HttpApiEndpoint.get("get", "/session", {
  277. success: Schema.Json,
  278. }),
  279. ),
  280. ),
  281. )
  282. const types = output.files.find((file) => file.path === "types.ts")?.content
  283. expect(types).toContain("export type JsonValue =")
  284. expect(types).toContain("{ readonly [key: string]: JsonValue }")
  285. expect(types).not.toContain("Schema.Json")
  286. })
  287. test("emits an optional Promise input when every field is optional", () => {
  288. const output = emitPromise(
  289. compileContract(
  290. api(
  291. HttpApiEndpoint.get("list", "/session", {
  292. query: { limit: Schema.optional(Schema.Number) },
  293. success: Schema.Array(Schema.String),
  294. }),
  295. ),
  296. ),
  297. )
  298. expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
  299. '"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
  300. )
  301. })
  302. test("rejects Promise transports that are not implemented", () => {
  303. expect(() =>
  304. emitPromise(
  305. compileContract(
  306. api(
  307. HttpApiEndpoint.get("text", "/text", {
  308. success: Schema.String.pipe(HttpApiSchema.asText()),
  309. }),
  310. ),
  311. ),
  312. ),
  313. ).toThrow("Unsupported Promise success encoding: session.text")
  314. expect(() =>
  315. emitPromise(
  316. compileContract(
  317. api(
  318. HttpApiEndpoint.get("binary", "/binary", {
  319. success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
  320. }),
  321. ),
  322. ),
  323. ),
  324. ).toThrow("Unsupported Promise success encoding: session.binary")
  325. expect(() =>
  326. emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))),
  327. ).toThrow("Unsupported Promise path wildcard: /file/*")
  328. expect(() =>
  329. emitPromise(
  330. compileContract(
  331. api(
  332. HttpApiEndpoint.get("events", "/events", {
  333. success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
  334. }),
  335. ),
  336. ),
  337. ),
  338. ).toThrow("Unsupported Promise stream: session.events")
  339. })
  340. test("executes an emitted Promise GET through fetch", async () => {
  341. const output = emitPromise(
  342. compileContract(
  343. api(
  344. HttpApiEndpoint.get("get", "/session/:sessionID", {
  345. params: { sessionID: Schema.String },
  346. success: Schema.Struct({ data: Schema.String }),
  347. }),
  348. ),
  349. ),
  350. )
  351. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  352. try {
  353. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  354. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  355. let request: Request | undefined
  356. const client = generated.OpenCode.make({
  357. baseUrl: "https://example.com",
  358. fetch: async (input: RequestInfo | URL) => {
  359. request = input instanceof Request ? input : new Request(input)
  360. return Response.json({ data: "hello" })
  361. },
  362. })
  363. expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
  364. expect(request?.method).toBe("GET")
  365. expect(request?.url).toBe("https://example.com/session/a%2Fb")
  366. } finally {
  367. await rm(directory, { recursive: true, force: true })
  368. }
  369. })
  370. test("maps an emitted no-content response to undefined", async () => {
  371. const output = emitPromise(
  372. compileContract(
  373. api(
  374. HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
  375. params: { sessionID: Schema.String },
  376. success: HttpApiSchema.NoContent,
  377. }),
  378. ),
  379. ),
  380. )
  381. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  382. try {
  383. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  384. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  385. const client = generated.OpenCode.make({
  386. baseUrl: "https://example.com",
  387. fetch: async () => new Response(null, { status: 204 }),
  388. })
  389. expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
  390. } finally {
  391. await rm(directory, { recursive: true, force: true })
  392. }
  393. })
  394. test("serializes flattened query, header, and JSON payload inputs", async () => {
  395. const output = emitPromise(
  396. compileContract(
  397. api(
  398. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  399. params: { sessionID: Schema.String },
  400. query: { resume: Schema.optional(Schema.Boolean) },
  401. headers: { traceID: Schema.String },
  402. payload: Schema.Struct({ prompt: Schema.String }),
  403. success: Schema.Struct({ data: Schema.String }),
  404. }),
  405. ),
  406. ),
  407. )
  408. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  409. try {
  410. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  411. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  412. let request: Request | undefined
  413. const client = generated.OpenCode.make({
  414. baseUrl: "https://example.com",
  415. fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
  416. request = input instanceof Request ? input : new Request(input, init)
  417. return Response.json({ data: "admitted" })
  418. },
  419. })
  420. expect(
  421. await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
  422. ).toBe("admitted")
  423. expect(request?.url).toBe("https://example.com/session/session?resume=true")
  424. expect(request?.headers.get("traceID")).toBe("trace")
  425. expect(await request?.json()).toEqual({ prompt: "hello" })
  426. } finally {
  427. await rm(directory, { recursive: true, force: true })
  428. }
  429. })
  430. test("rejects with declared tagged errors and exports a type guard", async () => {
  431. const output = emitPromise(
  432. compileContract(
  433. api(
  434. HttpApiEndpoint.get("get", "/session/:sessionID", {
  435. params: { sessionID: Schema.String },
  436. success: Schema.Struct({ data: Schema.String }),
  437. error: Missing.pipe(HttpApiSchema.status(404)),
  438. }),
  439. ),
  440. ),
  441. )
  442. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  443. try {
  444. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  445. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  446. const client = generated.OpenCode.make({
  447. baseUrl: "https://example.com",
  448. fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
  449. })
  450. const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
  451. expect(error).toEqual({ _tag: "Missing", message: "gone" })
  452. expect(generated.isMissing(error)).toBeTrue()
  453. } finally {
  454. await rm(directory, { recursive: true, force: true })
  455. }
  456. })
  457. test("iterates an emitted SSE stream lazily without reconnecting", async () => {
  458. const output = emitPromise(
  459. compileContract(
  460. api(
  461. HttpApiEndpoint.get("subscribe", "/event", {
  462. query: { after: Schema.optional(Schema.Number) },
  463. success: HttpApiSchema.StreamSse({
  464. data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }),
  465. }),
  466. }),
  467. ),
  468. ),
  469. )
  470. const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
  471. try {
  472. await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
  473. const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
  474. let requests = 0
  475. let url: string | undefined
  476. const client = generated.OpenCode.make({
  477. baseUrl: "https://example.com",
  478. fetch: async (input: RequestInfo | URL) => {
  479. requests++
  480. url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
  481. const encoder = new TextEncoder()
  482. return new Response(
  483. new ReadableStream({
  484. start(controller) {
  485. controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
  486. controller.enqueue(encoder.encode("\n\r\n"))
  487. controller.close()
  488. },
  489. }),
  490. { headers: { "content-type": "text/event-stream" } },
  491. )
  492. },
  493. })
  494. const events = client.session.subscribe({ after: 2 })
  495. expect(requests).toBe(0)
  496. const received = []
  497. for await (const event of events) received.push(event)
  498. expect(received).toEqual([{ type: "ready", count: "1" }])
  499. expect(requests).toBe(1)
  500. expect(url).toBe("https://example.com/event?after=2")
  501. } finally {
  502. await rm(directory, { recursive: true, force: true })
  503. }
  504. })
  505. test("preserves public group and endpoint identifiers exactly", () => {
  506. const output = compile(
  507. HttpApi.make("test").add(
  508. HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
  509. ),
  510. )
  511. expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
  512. })
  513. test("emits one client module per HttpApi group", () => {
  514. const source = HttpApi.make("test")
  515. .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  516. .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
  517. const output = compile(source)
  518. expect(output.files.map((file) => file.path)).toEqual([
  519. "session.ts",
  520. "tool.ts",
  521. "client-error.ts",
  522. "client.ts",
  523. "index.ts",
  524. ])
  525. })
  526. test("emits syntactically valid TypeScript modules", () => {
  527. const output = compile(
  528. api(
  529. HttpApiEndpoint.get("get", "/session/:sessionID", {
  530. params: { sessionID: Schema.String },
  531. success: Schema.Struct({ data: Schema.String }),
  532. }),
  533. ),
  534. )
  535. const transpiler = new Bun.Transpiler({ loader: "ts" })
  536. for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
  537. })
  538. it.effect("keeps the strict generated-consumer fixture current", () =>
  539. Effect.gen(function* () {
  540. const output = compile(FixtureApi)
  541. const actual = yield* Effect.promise(() =>
  542. Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
  543. )
  544. expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
  545. output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
  546. )
  547. yield* Effect.forEach(output.files, (file) =>
  548. Effect.tryPromise(() =>
  549. Promise.all([
  550. Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
  551. format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
  552. ]),
  553. ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
  554. )
  555. }),
  556. )
  557. test("flattens transport input channels into one domain input", () => {
  558. const output = compile(
  559. api(
  560. HttpApiEndpoint.post("prompt", "/session/:sessionID", {
  561. params: { sessionID: Schema.String },
  562. query: { resume: Schema.String },
  563. headers: { traceID: Schema.String },
  564. payload: Schema.Struct({ prompt: Schema.String }),
  565. success: Schema.Struct({ data: Schema.String }),
  566. }),
  567. ),
  568. )
  569. expect(output.operations[0]?.input).toEqual([
  570. { name: "sessionID", source: "params" },
  571. { name: "resume", source: "query" },
  572. { name: "traceID", source: "headers" },
  573. { name: "prompt", source: "payload" },
  574. ])
  575. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  576. 'params: { "sessionID": input["sessionID"] }',
  577. )
  578. })
  579. test("uses no argument when an operation has no input fields", () => {
  580. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  581. expect(output.operations[0]?.inputMode).toBe("none")
  582. })
  583. test("uses an optional object when every input field is optional", () => {
  584. const output = compile(
  585. api(
  586. HttpApiEndpoint.get("list", "/session", {
  587. query: { limit: Schema.optional(Schema.String) },
  588. success: Schema.Array(Schema.String),
  589. }),
  590. ),
  591. )
  592. expect(output.operations[0]?.inputMode).toBe("optional")
  593. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
  594. })
  595. test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
  596. const output = compile(
  597. api(
  598. HttpApiEndpoint.get("list", "/session", {
  599. query: { archived: Schema.optional(Schema.Boolean) },
  600. success: Schema.String,
  601. }),
  602. ),
  603. )
  604. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
  605. })
  606. test("uses a required object when any input field is required", () => {
  607. const output = compile(
  608. api(
  609. HttpApiEndpoint.get("get", "/session/:sessionID", {
  610. params: { sessionID: Schema.String },
  611. query: { includeArchived: Schema.optional(Schema.String) },
  612. success: Schema.String,
  613. }),
  614. ),
  615. )
  616. expect(output.operations[0]?.inputMode).toBe("required")
  617. })
  618. test("rejects colliding input names across transport channels", () => {
  619. expect(() =>
  620. compile(
  621. api(
  622. HttpApiEndpoint.post("prompt", "/session/:id", {
  623. params: { id: Schema.String },
  624. payload: Schema.Struct({ id: Schema.String }),
  625. success: Schema.Void,
  626. }),
  627. ),
  628. ),
  629. ).toThrow("Input field collision: id")
  630. })
  631. test("rejects multiple payload alternatives until selection semantics are explicit", () => {
  632. expect(() =>
  633. compile(
  634. api(
  635. HttpApiEndpoint.post("prompt", "/session", {
  636. payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
  637. success: Schema.String,
  638. }),
  639. ),
  640. ),
  641. ).toThrow("Multiple payload schemas: session.prompt")
  642. })
  643. test("unwraps an exact data success envelope", () => {
  644. const output = compile(
  645. api(
  646. HttpApiEndpoint.get("get", "/session/:sessionID", {
  647. params: { sessionID: Schema.String },
  648. success: Schema.Struct({ data: Schema.String }),
  649. }),
  650. ),
  651. )
  652. expect(output.operations[0]?.success).toBe("value")
  653. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  654. "Effect.map((value) => value.data)",
  655. )
  656. })
  657. test("maps no-content success to void", () => {
  658. const output = compile(
  659. api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
  660. )
  661. expect(output.operations[0]?.success).toBe("void")
  662. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
  663. })
  664. test("preserves non-default empty response statuses", () => {
  665. const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
  666. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
  667. })
  668. test("returns a non-envelope success unchanged", () => {
  669. const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
  670. expect(output.operations[0]?.success).toBe("value")
  671. })
  672. test("rejects multiple success shapes until their public semantics are explicit", () => {
  673. expect(() =>
  674. compile(
  675. api(
  676. HttpApiEndpoint.get("get", "/session", {
  677. success: [Schema.String, Schema.Number],
  678. }),
  679. ),
  680. ),
  681. ).toThrow("Multiple success schemas: session.get")
  682. })
  683. test("models an SSE success as a direct stream", () => {
  684. const output = compile(
  685. api(
  686. HttpApiEndpoint.get("subscribe", "/event", {
  687. success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
  688. }),
  689. ),
  690. )
  691. expect(output.operations[0]?.success).toBe("stream")
  692. })
  693. test("preserves annotated stream response statuses", () => {
  694. const output = compile(
  695. api(
  696. HttpApiEndpoint.get("subscribe", "/event", {
  697. success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
  698. }),
  699. ),
  700. )
  701. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  702. ".pipe(HttpApiSchema.status(202))",
  703. )
  704. })
  705. test("rejects schemas whose semantics cannot be emitted exactly", () => {
  706. const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
  707. expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
  708. "Unportable schema: session.get.success",
  709. )
  710. })
  711. test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
  712. const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
  713. Schema.decodeTo(Schema.Boolean, {
  714. decode: SchemaGetter.transform((value) => value === "yes"),
  715. encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  716. }),
  717. )
  718. expect(() =>
  719. compile(
  720. api(
  721. HttpApiEndpoint.get("get", "/session", {
  722. query: { archived: QueryBoolean },
  723. success: Schema.String,
  724. }),
  725. ),
  726. ),
  727. ).toThrow("Effect schema requires authoritative import: session.get")
  728. })
  729. test("rejects custom validation checks without portable metadata", () => {
  730. const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
  731. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
  732. "Unportable schema: session.get.success",
  733. )
  734. })
  735. test("rejects spoofed and aborted validation checks", () => {
  736. const Spoofed = Schema.Number.check(
  737. Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
  738. )
  739. const Aborted = Schema.Number.check(Schema.isFinite().abort())
  740. expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
  741. "Unportable schema: session.spoofed.success",
  742. )
  743. expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
  744. "Unportable schema: session.aborted.success",
  745. )
  746. })
  747. test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
  748. const JsonNumber = Schema.toCodecJson(Schema.Number)
  749. const link = JsonNumber.ast.encoding?.[0]
  750. if (link === undefined) throw new Error("Expected JSON number encoding")
  751. // This helper is present at runtime but omitted from the public declaration surface.
  752. const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
  753. if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
  754. const ast: unknown = replaceEncoding(JsonNumber.ast, [
  755. new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
  756. ])
  757. if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
  758. const Altered = Schema.make(ast)
  759. expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
  760. "Effect schema requires authoritative import: session.get",
  761. )
  762. })
  763. test("rejects lexical generation and annotation values", () => {
  764. const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
  765. generation: { runtime: "LocalOnly", Type: "string" },
  766. })
  767. const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
  768. custom: () => "local",
  769. })
  770. expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
  771. "Unportable schema: session.generated.success",
  772. )
  773. expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
  774. "Unportable schema: session.annotated.success",
  775. )
  776. })
  777. test("preserves errors from server-only middleware", () => {
  778. class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
  779. class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
  780. error: Unauthorized,
  781. }) {}
  782. const output = compile(
  783. api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
  784. )
  785. expect(output.operations[0]).toBeDefined()
  786. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  787. 'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
  788. )
  789. })
  790. test("preserves tagged error response statuses", () => {
  791. class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
  792. const output = compile(
  793. api(
  794. HttpApiEndpoint.get("get", "/session", {
  795. success: Schema.String,
  796. error: Missing.pipe(HttpApiSchema.status(404)),
  797. }),
  798. ),
  799. )
  800. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  801. 'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
  802. )
  803. })
  804. test("supports every HttpApi method through the generic constructor", () => {
  805. const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
  806. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
  807. })
  808. test("uses safe unique module paths without changing public group identifiers", () => {
  809. const output = compile(
  810. HttpApi.make("test")
  811. .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
  812. .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
  813. )
  814. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
  815. expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
  816. })
  817. test("reserves support module names case-insensitively", () => {
  818. const output = compile(
  819. HttpApi.make("test")
  820. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
  821. .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
  822. )
  823. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
  824. })
  825. test("keeps searching when a reserved-name fallback is also occupied", () => {
  826. const output = compile(
  827. HttpApi.make("test")
  828. .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
  829. .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
  830. )
  831. expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
  832. })
  833. test("rejects collisions in the flattened client namespace", () => {
  834. expect(() =>
  835. compile(
  836. HttpApi.make("test")
  837. .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
  838. .add(
  839. HttpApiGroup.make("system", { topLevel: true }).add(
  840. HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
  841. ),
  842. ),
  843. ),
  844. ).toThrow("Client name collision: status")
  845. })
  846. test("emits a usable raw type for top-level groups", () => {
  847. const output = compile(
  848. HttpApi.make("test").add(
  849. HttpApiGroup.make("health", { topLevel: true }).add(
  850. HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
  851. ),
  852. ),
  853. )
  854. expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
  855. })
  856. it.effect("reports compiler failures in the generate Effect", () =>
  857. Effect.gen(function* () {
  858. const error = yield* generate(
  859. api(
  860. HttpApiEndpoint.get("get", "/url", {
  861. success: Schema.declare((input): input is URL => input instanceof URL),
  862. }),
  863. ),
  864. {
  865. directory: "/generated",
  866. },
  867. ).pipe(Effect.flip)
  868. expect(error).toBeInstanceOf(GenerationError)
  869. if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
  870. }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
  871. )
  872. test("rejects required client middleware without an adapter", () => {
  873. class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
  874. requiredForClient: true,
  875. }) {}
  876. expect(() =>
  877. compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
  878. ).toThrow("Client middleware requires adapter: SignedRequest")
  879. })
  880. test("maps transport and decode failures to one stable client error", () => {
  881. const output = compile(
  882. api(
  883. HttpApiEndpoint.get("get", "/session", {
  884. success: Schema.String,
  885. }),
  886. ),
  887. )
  888. expect(output.operations[0]?.errors).toContain("ClientError")
  889. expect(output.operations[0]?.errors).not.toContain("HttpClientError")
  890. expect(output.operations[0]?.errors).not.toContain("SchemaError")
  891. expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
  892. "new ClientError({ cause: error })",
  893. )
  894. })
  895. })