1
0

generate.test.ts 34 KB

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