index.ts 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185
  1. import { isAbsolute, join } from "node:path"
  2. import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect"
  3. import { HttpMethod, type HttpRouter } from "effect/unstable/http"
  4. import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
  5. import { format } from "prettier"
  6. export type InputField = {
  7. readonly name: string
  8. readonly source: "params" | "query" | "headers" | "payload"
  9. }
  10. export type Operation = {
  11. readonly group: string
  12. readonly name: string
  13. readonly input: ReadonlyArray<InputField>
  14. readonly inputMode: "none" | "optional" | "required"
  15. readonly success: "value" | "void" | "stream"
  16. readonly errors: ReadonlyArray<string>
  17. }
  18. export type Output = {
  19. readonly operations: ReadonlyArray<Operation>
  20. readonly files: ReadonlyArray<{
  21. readonly path: string
  22. readonly content: string
  23. }>
  24. }
  25. export type Contract = {
  26. readonly groups: ReadonlyArray<Group>
  27. }
  28. export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()("GenerationError", {
  29. reason: Schema.String,
  30. }) {
  31. override get message() {
  32. return this.reason
  33. }
  34. }
  35. export type Endpoint = {
  36. readonly group: string
  37. readonly sourceGroup: string
  38. readonly topLevel: boolean
  39. readonly endpoint: HttpApiEndpoint.AnyWithProps
  40. readonly params: Schema.Top | undefined
  41. readonly query: Schema.Top | undefined
  42. readonly headers: Schema.Top | undefined
  43. readonly payloads: ReadonlyArray<Schema.Top>
  44. readonly operation: Operation
  45. readonly input: ReadonlyArray<InputField & { readonly optional: boolean }>
  46. readonly unwrapData: boolean
  47. readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }>
  48. readonly successes: ReadonlyArray<Schema.Top>
  49. readonly effectPortable: boolean
  50. }
  51. export type Group = {
  52. readonly identifier: string
  53. readonly sourceIdentifier: string
  54. readonly module: string
  55. readonly endpoints: ReadonlyArray<Endpoint>
  56. }
  57. type Slot = {
  58. readonly name: string
  59. readonly schema: Schema.Top
  60. }
  61. const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
  62. const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
  63. const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
  64. const Manifest = Schema.fromJsonString(Schema.Array(Schema.String))
  65. const manifestName = ".httpapi-codegen.json"
  66. export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
  67. api: HttpApi.HttpApi<Id, Groups>,
  68. options?: {
  69. readonly groupNames?: Readonly<Record<string, string>>
  70. readonly endpointNames?: Readonly<Record<string, string>>
  71. readonly omitEndpoints?: ReadonlySet<string>
  72. },
  73. ): Contract {
  74. const endpoints: Array<Endpoint> = []
  75. const portable = new Map<SchemaAST.AST, boolean>()
  76. HttpApi.reflect(api, {
  77. onGroup() {},
  78. onEndpoint({ endpoint, errors, group, middleware }) {
  79. if (options?.omitEndpoints?.has(endpoint.name)) return
  80. const groupName = options?.groupNames?.[group.identifier] ?? group.identifier
  81. const name = `${groupName}.${endpoint.name}`
  82. const required = Array.from(middleware).find((item) => item.requiredForClient)
  83. if (required !== undefined) {
  84. throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` })
  85. }
  86. const successSchemas = Array.from(endpoint.success)
  87. if (successSchemas.length === 0) successSchemas.push(HttpApiSchema.NoContent)
  88. if (successSchemas.length > 1) throw new GenerationError({ reason: `Multiple success schemas: ${name}` })
  89. const params = normalizeTransport(endpoint.params, "params", endpoint, name)
  90. const query = normalizeTransport(endpoint.query, "query", endpoint, name)
  91. const headers = normalizeTransport(endpoint.headers, "headers", endpoint, name)
  92. const sourcePayloads = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
  93. if (sourcePayloads.length > 1) {
  94. throw new GenerationError({ reason: `Multiple payload schemas: ${name}` })
  95. }
  96. const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!)
  97. const success = normalizeTransport(successSchemas[0], "success", endpoint, name)!
  98. const errorSchemas = Array.from(errors).flatMap(([status, schemas]) =>
  99. schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })),
  100. )
  101. const inputs = [
  102. ...inputFields(params?.schema, "params", name),
  103. ...inputFields(query?.schema, "query", name),
  104. ...inputFields(headers?.schema, "headers", name),
  105. ...payloads.flatMap((item) => inputFields(item.schema, "payload", name)),
  106. ]
  107. const names = new Set<string>()
  108. for (const field of inputs) {
  109. if (names.has(field.name)) throw new GenerationError({ reason: `Input field collision: ${field.name}` })
  110. names.add(field.name)
  111. }
  112. const schemaPaths: Array<readonly [string, Schema.Top]> = [
  113. ...(params === undefined ? [] : [[`${name}.params`, params.schema] as const]),
  114. ...(query === undefined ? [] : [[`${name}.query`, query.schema] as const]),
  115. ...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]),
  116. ...payloads.map((item) => [`${name}.payload`, item.schema] as const),
  117. ...responseSchemas(success.schema, `${name}.success`),
  118. ...errorSchemas.map((item) => [`${name}.error.${item.status}`, item.schema] as const),
  119. ]
  120. const effectPortable =
  121. [params, query, headers, ...payloads, success, ...errorSchemas].every(
  122. (item) => item?.effectPortable !== false,
  123. ) && streamEffectPortable(success.schema)
  124. if (effectPortable) {
  125. for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable)
  126. }
  127. endpoints.push({
  128. group: groupName,
  129. sourceGroup: group.identifier,
  130. topLevel: group.topLevel,
  131. endpoint,
  132. params: params?.schema,
  133. query: query?.schema,
  134. headers: headers?.schema,
  135. payloads: payloads.map((item) => item.schema),
  136. input: inputs,
  137. unwrapData: isDataEnvelope(success.schema),
  138. successes: [success.schema],
  139. errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })),
  140. effectPortable,
  141. operation: {
  142. group: groupName,
  143. name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name),
  144. input: inputs.map(({ name, source }) => ({ name, source })),
  145. inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required",
  146. success: isStreamSchema(success.schema)
  147. ? "stream"
  148. : HttpApiSchema.isNoContent(success.schema.ast)
  149. ? "void"
  150. : "value",
  151. errors: [
  152. ...new Set([
  153. ...errorSchemas.flatMap((item) => {
  154. const identifier = SchemaAST.resolveIdentifier(item.schema.ast)
  155. return identifier === undefined ? [] : [identifier]
  156. }),
  157. "ClientError",
  158. ]),
  159. ],
  160. },
  161. })
  162. },
  163. })
  164. const modules = new Set(["client", "client-error", "index"])
  165. const groups = Array.from(
  166. Map.groupBy(endpoints, (endpoint) => endpoint.group),
  167. ([identifier, endpoints], index) => {
  168. if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) {
  169. throw new GenerationError({ reason: `Client group name collision: ${identifier}` })
  170. }
  171. const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}`
  172. const module = uniqueModule(base, index, modules)
  173. modules.add(module.toLowerCase())
  174. return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints }
  175. },
  176. )
  177. const publicNames = new Set<string>()
  178. for (const group of groups) {
  179. const endpointNames = new Set<string>()
  180. for (const endpoint of group.endpoints) {
  181. if (endpointNames.has(endpoint.operation.name)) {
  182. throw new GenerationError({
  183. reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`,
  184. })
  185. }
  186. endpointNames.add(endpoint.operation.name)
  187. }
  188. const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier]
  189. for (const name of names) {
  190. if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` })
  191. publicNames.add(name)
  192. }
  193. }
  194. return {
  195. groups,
  196. }
  197. }
  198. export function emitEffect(contract: Contract): Output {
  199. const endpoint = contract.groups.flatMap((group) => group.endpoints).find((endpoint) => !endpoint.effectPortable)
  200. if (endpoint !== undefined) {
  201. throw new GenerationError({
  202. reason: `Effect schema requires authoritative import: ${endpoint.group}.${endpoint.endpoint.name}`,
  203. })
  204. }
  205. return { operations: operations(contract.groups), files: renderEffectFiles(contract.groups) }
  206. }
  207. export function emitEffectImported(
  208. contract: Contract,
  209. options:
  210. | { readonly module: string; readonly api: string }
  211. | { readonly module: string; readonly group: string }
  212. | { readonly module: string; readonly endpoints: Readonly<Record<string, string>> },
  213. ): Output {
  214. return {
  215. operations: operations(contract.groups),
  216. files: renderImportedEffectFiles(contract.groups, options),
  217. }
  218. }
  219. export function emitPromise(
  220. contract: Contract,
  221. options?: {
  222. readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
  223. },
  224. ): Output {
  225. const groups = contract.groups
  226. for (const group of groups) {
  227. for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
  228. }
  229. return {
  230. operations: operations(groups),
  231. files: [
  232. { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
  233. {
  234. path: "client-error.ts",
  235. content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
  236. },
  237. {
  238. path: "client.ts",
  239. content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
  240. },
  241. {
  242. path: "index.ts",
  243. content:
  244. 'export { ClientError, type ClientErrorReason } from "./client-error"\nexport * as OpenCode from "./client"\nexport * from "./types"\n',
  245. },
  246. ],
  247. }
  248. }
  249. function assertPromiseEndpoint(endpoint: Endpoint) {
  250. const name = `${endpoint.group}.${endpoint.endpoint.name}`
  251. const payload = endpoint.payloads[0]
  252. const payloadEncoding = payload === undefined ? undefined : resolveHttpApiEncoding(payload.ast)
  253. if (
  254. payload !== undefined &&
  255. (payloadEncoding?._tag ?? (HttpMethod.hasBody(endpoint.endpoint.method) ? "Json" : "FormUrlEncoded")) !== "Json"
  256. ) {
  257. throw new GenerationError({ reason: `Unsupported Promise payload encoding: ${name}` })
  258. }
  259. const success = endpoint.successes[0]
  260. if (isStreamSchema(success)) {
  261. if (
  262. success._tag !== "StreamSse" ||
  263. success.sseMode !== "data" ||
  264. !SchemaAST.isNever(Schema.toType(success.error).ast)
  265. ) {
  266. throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` })
  267. }
  268. } else if (
  269. !HttpApiSchema.isNoContent(success.ast) &&
  270. (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json"
  271. ) {
  272. throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
  273. }
  274. for (const error of endpoint.errors) {
  275. if (declaredErrorFields(error.schema) === undefined) {
  276. throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
  277. }
  278. if ((resolveHttpApiEncoding(error.schema.ast)?._tag ?? "Json") !== "Json") {
  279. throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` })
  280. }
  281. }
  282. }
  283. function operations(groups: ReadonlyArray<Group>) {
  284. return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation))
  285. }
  286. function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
  287. return [
  288. ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
  289. {
  290. path: "client-error.ts",
  291. content:
  292. 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n',
  293. },
  294. { path: "client.ts", content: renderClient(groups) },
  295. {
  296. path: "index.ts",
  297. content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n',
  298. },
  299. ]
  300. }
  301. function renderImportedEffectFiles(
  302. groups: ReadonlyArray<Group>,
  303. options:
  304. | { readonly module: string; readonly api: string }
  305. | { readonly module: string; readonly group: string }
  306. | { readonly module: string; readonly endpoints: Readonly<Record<string, string>> },
  307. ): Output["files"] {
  308. const adapters = groups.map((group, groupIndex) => {
  309. const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
  310. const methods = group.endpoints.map((item, endpointIndex) => {
  311. const prefix = `Endpoint${groupIndex}_${endpointIndex}`
  312. const request = (["params", "query", "headers", "payload"] as const)
  313. .flatMap((source) => {
  314. const fields = item.input.filter((field) => field.source === source)
  315. if (fields.length === 0) return []
  316. return [
  317. `${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`,
  318. ]
  319. })
  320. .join(", ")
  321. const input = item.input
  322. .map(
  323. (field) =>
  324. `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`,
  325. )
  326. .join("; ")
  327. const argument =
  328. item.operation.inputMode === "none"
  329. ? ""
  330. : `input${item.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`
  331. const rawCall = `raw[${JSON.stringify(item.endpoint.name)}]({ ${request} })`
  332. const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})`
  333. return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}`
  334. })
  335. return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })`
  336. })
  337. const fields = groups.flatMap((group, index) =>
  338. group.endpoints[0]?.topLevel
  339. ? [`...adaptGroup${index}(raw)`]
  340. : [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`],
  341. )
  342. const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream"))
  343. const imported = "api" in options
  344. const projection = imported
  345. ? undefined
  346. : "group" in options
  347. ? renderImportedGroup(options.group)
  348. : renderImportedProjection(groups, options.endpoints)
  349. const api = imported ? options.api : "Api"
  350. const imports =
  351. projection === undefined
  352. ? `import { ${api} } from ${JSON.stringify(options.module)}`
  353. : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
  354. const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : ""
  355. const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi<typeof ${api}>\n\nconst mapClientError = <E>(error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n`
  356. return [
  357. {
  358. path: "client-error.ts",
  359. content:
  360. 'import { Schema } from "effect"\n\nexport class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {\n cause: Schema.Defect(),\n}) {}\n',
  361. },
  362. { path: "client.ts", content: client },
  363. {
  364. path: "index.ts",
  365. content: 'export { ClientError } from "./client-error"\nexport * as OpenCode from "./client"\n',
  366. },
  367. ]
  368. }
  369. function renderImportedGroup(group: string) {
  370. return {
  371. imports: [group],
  372. source: `const Api = HttpApi.make("generated").add(${group})\n\n`,
  373. }
  374. }
  375. function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Readonly<Record<string, string>>) {
  376. const imports = groups.flatMap((group) =>
  377. group.endpoints.map((endpoint) => {
  378. const name = endpoints[`${group.identifier}.${endpoint.endpoint.name}`]
  379. if (name === undefined) {
  380. throw new GenerationError({
  381. reason: `Missing imported endpoint: ${group.identifier}.${endpoint.endpoint.name}`,
  382. })
  383. }
  384. return name
  385. }),
  386. )
  387. const source = `const Api = HttpApi.make("generated").${groups
  388. .map((group) => {
  389. const options = group.endpoints[0]?.topLevel ? ", { topLevel: true }" : ""
  390. return `add(HttpApiGroup.make(${JSON.stringify(group.identifier)}${options})${group.endpoints.map((endpoint) => `.add(${endpoints[`${group.identifier}.${endpoint.endpoint.name}`]})`).join("")})`
  391. })
  392. .join(".")}\n\n`
  393. return { imports: [...new Set(imports)], source }
  394. }
  395. function renderPromiseTypes(
  396. groups: ReadonlyArray<Group>,
  397. outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
  398. ) {
  399. const types = new Map<SchemaAST.AST, string>()
  400. const typeOf = (schema: Schema.Top, decoded = false) => {
  401. const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
  402. const cached = types.get(projected.ast)
  403. if (cached !== undefined) return cached
  404. const type = structuralType(projected)
  405. types.set(projected.ast, type)
  406. return type
  407. }
  408. const errors = new Map(
  409. groups.flatMap((group) =>
  410. group.endpoints.flatMap((endpoint) =>
  411. endpoint.errors.flatMap((error) => {
  412. const tagged = declaredErrorFields(error.schema)
  413. return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
  414. }),
  415. ),
  416. ),
  417. )
  418. const errorTypes = Array.from(errors.values()).map((error) => {
  419. const fields = error.fields
  420. .map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
  421. .join("; ")
  422. return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}`
  423. })
  424. const operations = groups
  425. .flatMap((group) =>
  426. group.endpoints.flatMap((endpoint) => {
  427. const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
  428. const schemas = {
  429. params: endpoint.params,
  430. query: endpoint.query,
  431. headers: endpoint.headers,
  432. payload: endpoint.payloads[0],
  433. }
  434. const input = endpoint.input
  435. .map((field) => {
  436. const schema = schemas[field.source]
  437. if (schema === undefined)
  438. throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
  439. return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
  440. })
  441. .join("; ")
  442. const successSchema = endpoint.successes[0]
  443. const success =
  444. outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ??
  445. typeOf(
  446. isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
  447. ? successSchema.sseMode === "data"
  448. ? streamEncodedDataSchema(successSchema)
  449. : successSchema.events
  450. : successSchema,
  451. )
  452. return [
  453. ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
  454. `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
  455. ]
  456. }),
  457. )
  458. .join("\n\n")
  459. const json = operations.includes("JsonValue")
  460. ? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
  461. : ""
  462. const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
  463. return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
  464. }
  465. function renderPromiseClient(groups: ReadonlyArray<Group>) {
  466. const imports = groups.flatMap((group) =>
  467. group.endpoints.flatMap((endpoint) => {
  468. const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
  469. return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`]
  470. }),
  471. )
  472. const fields = groups.map((group) => {
  473. const methods = group.endpoints.map((endpoint) => {
  474. const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
  475. const argument =
  476. endpoint.operation.inputMode === "none"
  477. ? "requestOptions?: RequestOptions"
  478. : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
  479. const path = promisePath(endpoint.endpoint.path, endpoint.input)
  480. const access = (name: string) =>
  481. `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
  482. const part = (source: InputField["source"]) => {
  483. const inputs = endpoint.input.filter((field) => field.source === source)
  484. return inputs.length === 0
  485. ? undefined
  486. : `{ ${inputs.map((field) => `${JSON.stringify(field.name)}: ${access(field.name)}`).join(", ")} }`
  487. }
  488. const parts = [
  489. endpoint.query === undefined ? undefined : `query: ${part("query")}`,
  490. endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`,
  491. endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
  492. ].filter((value): value is string => value !== undefined)
  493. const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
  494. const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }`
  495. if (endpoint.operation.success === "stream") {
  496. const success = endpoint.successes[0]
  497. if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
  498. throw new GenerationError({
  499. reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`,
  500. })
  501. }
  502. return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)`
  503. }
  504. const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : ""
  505. return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}`
  506. })
  507. if (group.endpoints[0]?.topLevel) return methods.join(", ")
  508. return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }`
  509. })
  510. return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record<string, unknown>\n readonly headers?: Record<string, unknown>\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray<number>\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult<Uint8Array>\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise<unknown> {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n`
  511. }
  512. function promiseTypePrefix(group: string, endpoint: string) {
  513. return `${identifierPart(group)}${identifierPart(endpoint)}`
  514. }
  515. function clientEndpointName(name: string) {
  516. return name.slice(name.lastIndexOf(".") + 1)
  517. }
  518. function identifierPart(value: string) {
  519. return value
  520. .split(/[^A-Za-z0-9]+/)
  521. .filter(Boolean)
  522. .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
  523. .join("")
  524. }
  525. function structuralType(schema: Schema.Top) {
  526. const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast]))
  527. if (
  528. document.artifacts.some(
  529. (artifact) =>
  530. artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"',
  531. ) ||
  532. Object.keys(document.references.recursives).length > 0
  533. ) {
  534. throw new GenerationError({ reason: "Referenced Promise types are not implemented" })
  535. }
  536. const references = new Map(
  537. document.references.nonRecursives.map((reference) => [reference.$ref, reference.code.Type]),
  538. )
  539. const expand = (type: string, seen = new Set<string>()): string => {
  540. for (const [reference, value] of references) {
  541. const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
  542. if (!new RegExp(pattern).test(type)) continue
  543. if (seen.has(reference)) {
  544. throw new GenerationError({ reason: `Recursive Promise types are not implemented: ${reference}` })
  545. }
  546. type = type.replace(new RegExp(pattern, "g"), `(${expand(value, new Set([...seen, reference]))})`)
  547. }
  548. return type
  549. }
  550. return expand(document.codes[0].Type)
  551. .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
  552. .replaceAll("Schema.Json", "JsonValue")
  553. }
  554. function promisePath(path: string, input: ReadonlyArray<InputField>) {
  555. if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` })
  556. const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name))
  557. const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean)
  558. const template = segments
  559. .map((segment) => {
  560. if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`")
  561. const name = segment.slice(1)
  562. if (!fields.has(name)) throw new GenerationError({ reason: `Missing path parameter: ${name}` })
  563. return `\${encodeURIComponent(input.${name})}`
  564. })
  565. .join("")
  566. return `\`${template}\``
  567. }
  568. function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {
  569. if (!modules.has(base.toLowerCase())) return base
  570. const seed = `${base}-${index}`
  571. let suffix = 0
  572. while (modules.has(`${seed}${suffix === 0 ? "" : `-${suffix}`}`.toLowerCase())) suffix++
  573. return `${seed}${suffix === 0 ? "" : `-${suffix}`}`
  574. }
  575. function normalizeTransport(
  576. schema: Schema.Top | undefined,
  577. source: InputField["source"] | "success" | "error",
  578. endpoint: HttpApiEndpoint.AnyWithProps,
  579. operation: string,
  580. ) {
  581. if (schema === undefined) return undefined
  582. if (isStreamSchema(schema)) return { schema, effectPortable: true } as const
  583. if (!metadataPortable(schema.ast, new Set())) {
  584. throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` })
  585. }
  586. const decoded = Schema.toType(schema)
  587. if (!isPathInput(endpoint.path)) {
  588. throw new GenerationError({ reason: `Invalid endpoint path: ${operation}` })
  589. }
  590. const rebuilt = HttpApiEndpoint.make(endpoint.method)(endpoint.name, endpoint.path, {
  591. ...(source === "params" ? { params: decoded } : undefined),
  592. ...(source === "query" ? { query: decoded } : undefined),
  593. ...(source === "headers" ? { headers: decoded } : undefined),
  594. ...(source === "payload" ? { payload: decoded } : undefined),
  595. ...(source === "success" ? { success: decoded } : { success: Schema.String }),
  596. ...(source === "error" ? { error: decoded } : undefined),
  597. })
  598. const normalized =
  599. source === "params"
  600. ? rebuilt.params
  601. : source === "query"
  602. ? rebuilt.query
  603. : source === "headers"
  604. ? rebuilt.headers
  605. : source === "payload"
  606. ? Array.from(rebuilt.payload.values())[0]?.schemas[0]
  607. : source === "success"
  608. ? Array.from(rebuilt.success)[0]
  609. : Array.from(rebuilt.error)[0]
  610. if (normalized === undefined) throw new GenerationError({ reason: `Unportable schema: ${operation}.${source}` })
  611. if (!sameEncoding(schema.ast, normalized.ast)) return { schema, effectPortable: false } as const
  612. return { schema: decoded, effectPortable: true } as const
  613. }
  614. function isPathInput(path: string): path is HttpRouter.PathInput {
  615. return path === "*" || path.startsWith("/")
  616. }
  617. function sameEncoding(left: SchemaAST.AST, right: SchemaAST.AST): boolean {
  618. if (left._tag !== right._tag || left.encoding?.length !== right.encoding?.length) return false
  619. if (
  620. left.encoding?.some((link, index) => {
  621. const other = right.encoding?.[index]
  622. return other === undefined || link.transformation !== other.transformation || !sameEncoding(link.to, other.to)
  623. })
  624. )
  625. return false
  626. if (!sameChecks(left.checks, right.checks) || !sameContext(left.context, right.context)) return false
  627. if (SchemaAST.isSuspend(left) && SchemaAST.isSuspend(right)) return sameEncoding(left.thunk(), right.thunk())
  628. if (SchemaAST.isUnion(left) && SchemaAST.isUnion(right)) {
  629. return (
  630. left.types.length === right.types.length &&
  631. left.types.every((ast, index) => sameEncoding(ast, right.types[index]))
  632. )
  633. }
  634. if (SchemaAST.isArrays(left) && SchemaAST.isArrays(right)) {
  635. return (
  636. left.elements.length === right.elements.length &&
  637. left.rest.length === right.rest.length &&
  638. left.elements.every((ast, index) => sameEncoding(ast, right.elements[index])) &&
  639. left.rest.every((ast, index) => sameEncoding(ast, right.rest[index]))
  640. )
  641. }
  642. if (SchemaAST.isObjects(left) && SchemaAST.isObjects(right)) {
  643. return (
  644. left.propertySignatures.length === right.propertySignatures.length &&
  645. left.indexSignatures.length === right.indexSignatures.length &&
  646. left.propertySignatures.every((field, index) => sameEncoding(field.type, right.propertySignatures[index].type)) &&
  647. left.indexSignatures.every(
  648. (field, index) =>
  649. sameEncoding(field.parameter, right.indexSignatures[index].parameter) &&
  650. sameEncoding(field.type, right.indexSignatures[index].type),
  651. )
  652. )
  653. }
  654. return true
  655. }
  656. function sameChecks(left: SchemaAST.Checks | undefined, right: SchemaAST.Checks | undefined): boolean {
  657. if (left?.length !== right?.length) return false
  658. if (left === undefined || right === undefined) return true
  659. return left.every((check, index) => {
  660. const other = right[index]
  661. if (other === undefined || check._tag !== other._tag) return false
  662. if (check._tag === "Filter" && other._tag === "Filter") {
  663. return check.run === other.run && check.aborted === other.aborted
  664. }
  665. return check._tag === "FilterGroup" && other._tag === "FilterGroup" && sameChecks(check.checks, other.checks)
  666. })
  667. }
  668. function sameContext(left: SchemaAST.Context | undefined, right: SchemaAST.Context | undefined) {
  669. return left?.isOptional === right?.isOptional && left?.isMutable === right?.isMutable
  670. }
  671. export function write(
  672. output: Output,
  673. directory: string,
  674. ): Effect.Effect<void, GenerationError | PlatformError.PlatformError, FileSystem.FileSystem> {
  675. return Effect.gen(function* () {
  676. const paths = new Set<string>()
  677. const normalizedPaths = new Set<string>()
  678. for (const file of output.files) {
  679. if (!isSafeOutputPath(file.path)) yield* new GenerationError({ reason: `Unsafe output path: ${file.path}` })
  680. const path = file.path.toLowerCase()
  681. if (normalizedPaths.has(path)) yield* new GenerationError({ reason: `Duplicate output path: ${file.path}` })
  682. normalizedPaths.add(path)
  683. paths.add(file.path)
  684. }
  685. const fs = yield* FileSystem.FileSystem
  686. yield* fs.makeDirectory(directory, { recursive: true })
  687. const manifest = join(directory, manifestName)
  688. const previous = (yield* fs.exists(manifest))
  689. ? yield* fs.readFileString(manifest).pipe(
  690. Effect.flatMap(Schema.decodeUnknownEffect(Manifest)),
  691. Effect.mapError(() => new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })),
  692. )
  693. : []
  694. if (previous.some((path) => !isSafeOutputPath(path))) {
  695. yield* new GenerationError({ reason: `Invalid generated file manifest: ${manifest}` })
  696. }
  697. yield* Effect.forEach(
  698. previous.filter((path) => !paths.has(path)),
  699. (path) => fs.remove(join(directory, path), { force: true }),
  700. { concurrency: 8, discard: true },
  701. )
  702. yield* Effect.forEach(
  703. output.files,
  704. (file) =>
  705. fs.exists(join(directory, file.path)).pipe(
  706. Effect.flatMap((exists) => (exists ? fs.stat(join(directory, file.path)) : Effect.succeed(undefined))),
  707. Effect.flatMap((info) =>
  708. info?.type === "SymbolicLink"
  709. ? new GenerationError({ reason: `Unsafe output path: ${file.path}` })
  710. : Effect.void,
  711. ),
  712. ),
  713. { concurrency: 8, discard: true },
  714. )
  715. yield* Effect.forEach(
  716. output.files,
  717. (file) =>
  718. Effect.tryPromise({
  719. try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }),
  720. catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }),
  721. }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
  722. { concurrency: 8, discard: true },
  723. )
  724. yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
  725. })
  726. }
  727. function isSafeOutputPath(path: string) {
  728. return path !== manifestName && !isAbsolute(path) && path !== "." && path !== ".." && !/[\\/]/.test(path)
  729. }
  730. export function generate<Id extends string, Groups extends HttpApiGroup.Any>(
  731. api: HttpApi.HttpApi<Id, Groups>,
  732. options: { readonly directory: string },
  733. ): Effect.Effect<void, GenerationError | PlatformError.PlatformError, FileSystem.FileSystem> {
  734. return Effect.try({
  735. try: () => emitEffect(compile(api)),
  736. catch: (error) => (error instanceof GenerationError ? error : new GenerationError({ reason: String(error) })),
  737. }).pipe(Effect.flatMap((output) => write(output, options.directory)))
  738. }
  739. function inputFields(schema: Schema.Top | undefined, source: InputField["source"], operation: string) {
  740. if (schema === undefined) return []
  741. const ast = Schema.toType(schema).ast
  742. if (!SchemaAST.isObjects(ast) || ast.indexSignatures.length > 0) {
  743. throw new GenerationError({ reason: `Input schema must be a struct: ${operation}.${source}` })
  744. }
  745. return ast.propertySignatures.map((field) => {
  746. if (typeof field.name !== "string") {
  747. throw new GenerationError({ reason: `Input field must have a string name: ${operation}.${source}` })
  748. }
  749. return {
  750. name: field.name,
  751. source,
  752. optional: SchemaAST.isOptional(field.type),
  753. }
  754. })
  755. }
  756. function responseSchemas(schema: Schema.Top, path: string): Array<readonly [string, Schema.Top]> {
  757. if (HttpApiSchema.isNoContent(schema.ast)) return []
  758. if (!isStreamSchema(schema)) return [[path, schema]]
  759. if (schema._tag === "StreamUint8Array") return []
  760. const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events
  761. return [
  762. [`${path}.${schema.sseMode}`, value],
  763. [`${path}.error`, schema.error],
  764. ]
  765. }
  766. function assertPortable(schema: Schema.Top, path: string, portable: Map<SchemaAST.AST, boolean>) {
  767. const visiting = new Set<SchemaAST.AST>()
  768. const taggedError = taggedErrorFields(schema)
  769. const visit = (ast: SchemaAST.AST): boolean => {
  770. const cached = portable.get(ast)
  771. if (cached !== undefined) return cached
  772. if (visiting.has(ast)) return true
  773. visiting.add(ast)
  774. const result = visitCurrent(ast)
  775. visiting.delete(ast)
  776. portable.set(ast, result)
  777. return result
  778. }
  779. const visitCurrent = (ast: SchemaAST.AST): boolean => {
  780. if (!annotationsPortable(ast.annotations)) return false
  781. if (!checksPortable(ast.checks) || ("encodingChecks" in ast && !checksPortable(ast.encodingChecks))) return false
  782. if (SchemaAST.isDeclaration(ast)) {
  783. return generationPortable(ast.annotations?.generation) && ast.typeParameters.every(visit)
  784. }
  785. if (ast.encoding !== undefined && ast.annotations?.generation === undefined) return false
  786. if (SchemaAST.isSuspend(ast)) return visit(ast.thunk())
  787. if (SchemaAST.isUnion(ast)) return ast.types.every(visit)
  788. if (SchemaAST.isArrays(ast)) {
  789. return ast.elements.every(visit) && ast.rest.every(visit)
  790. }
  791. if (SchemaAST.isObjects(ast)) {
  792. return (
  793. ast.propertySignatures.every((field) => visit(field.type)) &&
  794. ast.indexSignatures.every((index) => visit(index.parameter) && visit(index.type))
  795. )
  796. }
  797. if (SchemaAST.isTemplateLiteral(ast)) return ast.parts.every(visit)
  798. return true
  799. }
  800. if (taggedError !== undefined && SchemaAST.isDeclaration(schema.ast)) {
  801. if (
  802. schema.ast.checks !== undefined ||
  803. ("encodingChecks" in schema.ast && !checksPortable(schema.ast.encodingChecks)) ||
  804. schema.ast.typeParameters.some((ast) => ast.checks !== undefined) ||
  805. !schema.ast.typeParameters.every(visit)
  806. ) {
  807. throw new GenerationError({ reason: `Unportable schema: ${path}` })
  808. }
  809. return
  810. }
  811. if (!visit(schema.ast)) throw new GenerationError({ reason: `Unportable schema: ${path}` })
  812. }
  813. function checksPortable(checks: SchemaAST.Checks | undefined): boolean {
  814. if (checks === undefined) return true
  815. return checks.every((check) =>
  816. check._tag === "Filter"
  817. ? !check.aborted &&
  818. check.annotations?.meta !== undefined &&
  819. typeof check.annotations.arbitrary === "object" &&
  820. check.annotations.arbitrary !== null &&
  821. "constraint" in check.annotations.arbitrary
  822. : checksPortable(check.checks),
  823. )
  824. }
  825. function metadataPortable(ast: SchemaAST.AST, seen: Set<SchemaAST.AST>): boolean {
  826. if (seen.has(ast)) return true
  827. seen.add(ast)
  828. if (!annotationsPortable(ast.annotations) || !checksPortable(ast.checks)) return false
  829. if ("encodingChecks" in ast && !checksPortable(ast.encodingChecks)) return false
  830. if (ast.encoding?.some((link) => !metadataPortable(link.to, seen))) return false
  831. if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.every((item) => metadataPortable(item, seen))
  832. if (SchemaAST.isSuspend(ast)) return metadataPortable(ast.thunk(), seen)
  833. if (SchemaAST.isUnion(ast)) return ast.types.every((item) => metadataPortable(item, seen))
  834. if (SchemaAST.isArrays(ast)) {
  835. return (
  836. ast.elements.every((item) => metadataPortable(item, seen)) &&
  837. ast.rest.every((item) => metadataPortable(item, seen))
  838. )
  839. }
  840. if (SchemaAST.isObjects(ast)) {
  841. return (
  842. ast.propertySignatures.every((field) => metadataPortable(field.type, seen)) &&
  843. ast.indexSignatures.every(
  844. (field) => metadataPortable(field.parameter, seen) && metadataPortable(field.type, seen),
  845. )
  846. )
  847. }
  848. return true
  849. }
  850. function generationPortable(generation: unknown): boolean {
  851. if (typeof generation !== "object" || generation === null) return false
  852. const value = generation as {
  853. readonly runtime?: unknown
  854. readonly Type?: unknown
  855. readonly importDeclaration?: unknown
  856. }
  857. if (typeof value.runtime !== "string" || typeof value.Type !== "string") return false
  858. if (value.importDeclaration !== undefined) {
  859. if (
  860. typeof value.importDeclaration !== "string" ||
  861. !/from ["']effect(?:\/[^"']+)?["']$/.test(value.importDeclaration)
  862. ) {
  863. return false
  864. }
  865. }
  866. const namespace =
  867. typeof value.importDeclaration === "string"
  868. ? /import(?: type)? \* as ([A-Za-z_$][\w$]*)/.exec(value.importDeclaration)?.[1]
  869. : undefined
  870. return value.runtime.startsWith("Schema.") || (namespace !== undefined && value.runtime.startsWith(`${namespace}.`))
  871. }
  872. function annotationsPortable(annotations: Schema.Annotations.Annotations | undefined) {
  873. if (annotations === undefined) return true
  874. return Object.entries(annotations).every(([key, value]) => {
  875. if (
  876. ["toCodec", "toCodecJson", "toArbitrary", "toFormatter", "toEquivalence", "~effect/Schema/Class"].includes(key)
  877. ) {
  878. return true
  879. }
  880. if (key === "generation") return generationPortable(value)
  881. return serializable(value)
  882. })
  883. }
  884. function serializable(value: unknown): boolean {
  885. if (value === null || ["string", "number", "boolean"].includes(typeof value)) return true
  886. if (Array.isArray(value)) return value.every(serializable)
  887. if (typeof value !== "object") return false
  888. return Object.values(value).every(serializable)
  889. }
  890. function taggedErrorFields(schema: Schema.Top) {
  891. const fields = declaredErrorFields(schema)
  892. return fields?.key === "_tag" ? fields : undefined
  893. }
  894. function declaredErrorFields(schema: Schema.Top) {
  895. if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) {
  896. return undefined
  897. }
  898. const fields = schema.ast.typeParameters[0]
  899. if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined
  900. const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name
  901. if (key !== "_tag" && key !== "name") return undefined
  902. const tag = fields.propertySignatures.find((field) => field.name === key)?.type
  903. if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined
  904. return {
  905. key,
  906. tag: tag.literal,
  907. identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal,
  908. fields: fields.propertySignatures.flatMap((field) =>
  909. field.name === key || typeof field.name !== "string"
  910. ? []
  911. : [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const],
  912. ),
  913. }
  914. }
  915. function isDataEnvelope(schema: Schema.Top) {
  916. if (isStreamSchema(schema) || HttpApiSchema.isNoContent(schema.ast)) return false
  917. const ast = Schema.toType(schema).ast
  918. return (
  919. SchemaAST.isObjects(ast) &&
  920. ast.indexSignatures.length === 0 &&
  921. ast.propertySignatures.length === 1 &&
  922. ast.propertySignatures[0]?.name === "data"
  923. )
  924. }
  925. function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchema {
  926. return "_tag" in schema && (schema._tag === "StreamSse" || schema._tag === "StreamUint8Array")
  927. }
  928. function streamDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
  929. return Schema.make(streamDataAst(Schema.toType(schema.events).ast))
  930. }
  931. function streamEncodedDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
  932. const data = streamDataAst(schema.events.ast)
  933. const encodedAst = data.encoding?.at(-1)?.to
  934. if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" })
  935. const encoded = resolveContentSchema(encodedAst)
  936. if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" })
  937. return Schema.make(encoded)
  938. }
  939. function streamDataAst(ast: SchemaAST.AST) {
  940. if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" })
  941. const data = ast.propertySignatures.find((field) => field.name === "data")?.type
  942. if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" })
  943. return data
  944. }
  945. function streamEffectPortable(schema: Schema.Top) {
  946. if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true
  947. const rebuilt = HttpApiSchema.StreamSse({
  948. data: streamDataSchema(schema),
  949. error: schema.error,
  950. contentType: schema.contentType,
  951. })
  952. return sameEncoding(schema.events.ast, rebuilt.events.ast)
  953. }
  954. function renderGroup(group: Group, groupIndex: number) {
  955. const slots: Array<Slot> = []
  956. const adapters: Array<string> = []
  957. const endpointSources = group.endpoints.map((operation, endpointIndex) => {
  958. const {
  959. endpoint,
  960. errors,
  961. headers: endpointHeaders,
  962. params: endpointParams,
  963. payloads: endpointPayloads,
  964. query: endpointQuery,
  965. successes,
  966. } = operation
  967. const prefix = `Endpoint${endpointIndex}`
  968. const params = addSlot(endpointParams, `${prefix}Params`)
  969. const query = addSlot(endpointQuery, `${prefix}Query`)
  970. const headers = addSlot(endpointHeaders, `${prefix}Headers`)
  971. const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!)
  972. const success = renderSuccess(successes[0], `${prefix}Success`)
  973. const errorSlots = errors.map((error, index) => addSlot(error.schema, `${prefix}Error${index}`)!)
  974. const options = [
  975. params === undefined ? undefined : `params: ${params.name}`,
  976. query === undefined ? undefined : `query: ${query.name}`,
  977. headers === undefined ? undefined : `headers: ${headers.name}`,
  978. payloads.length === 0
  979. ? undefined
  980. : `payload: ${payloads.length === 1 ? payloads[0].name : `[${payloads.map((slot) => slot.name).join(", ")}]`}`,
  981. `success: ${success.source}`,
  982. errorSlots.length === 0
  983. ? undefined
  984. : `error: ${errorSlots.length === 1 ? errorSlots[0].name : `[${errorSlots.map((slot) => slot.name).join(", ")}]`}`,
  985. ].filter((option): option is string => option !== undefined)
  986. const schemaBySource = { params, query, headers, payload: payloads[0] }
  987. const inputType = operation.input
  988. .map((field) => {
  989. const slot = schemaBySource[field.source]
  990. if (slot === undefined) {
  991. throw new GenerationError({ reason: `Missing input schema: ${group.identifier}.${endpoint.name}` })
  992. }
  993. return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (typeof ${slot.name}.Type)[${JSON.stringify(field.name)}]`
  994. })
  995. .join("; ")
  996. const argument =
  997. operation.operation.inputMode === "none"
  998. ? ""
  999. : `input${operation.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`
  1000. const request = (["params", "query", "headers", "payload"] as const)
  1001. .flatMap((source) => {
  1002. const slot = schemaBySource[source]
  1003. if (slot === undefined) return []
  1004. const fields = operation.input
  1005. .filter((field) => field.source === source)
  1006. .map(
  1007. (field) =>
  1008. `${JSON.stringify(field.name)}: input${operation.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`,
  1009. )
  1010. return [`${source}: { ${fields.join(", ")} }`]
  1011. })
  1012. .join(", ")
  1013. const declared = [...errorSlots, ...(success.streamError === undefined ? [] : [success.streamError])]
  1014. const declaredSchema =
  1015. declared.length === 0 ? "Schema.Never" : `Schema.Union([${declared.map((slot) => slot.name).join(", ")}])`
  1016. const rawCall = `raw[${JSON.stringify(endpoint.name)}]({ ${request} })`
  1017. const mapped = `${rawCall}.pipe(Effect.mapError(map${prefix}Error)${operation.unwrapData ? ", Effect.map((value) => value.data)" : ""})`
  1018. const inputDeclaration = operation.operation.inputMode === "none" ? "" : `type ${prefix}Input = { ${inputType} }\n`
  1019. adapters.push(
  1020. `${inputDeclaration}const ${prefix}DeclaredError = ${declaredSchema}\nconst map${prefix}Error = (error: unknown) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : Schema.is(${prefix}DeclaredError)(error) ? error : new ClientError({ cause: error })\nconst ${prefix} = (raw: RawGroup) => (${argument}) => ${operation.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(map${prefix}Error), Effect.map((stream) => stream.pipe(Stream.mapError(map${prefix}Error)))))` : mapped}`,
  1021. )
  1022. return `HttpApiEndpoint.make(${JSON.stringify(endpoint.method)})(${JSON.stringify(endpoint.name)}, ${JSON.stringify(endpoint.path)}, { ${options.join(", ")} })`
  1023. })
  1024. function addSlot(schema: Schema.Top | undefined, name: string) {
  1025. if (schema === undefined) return undefined
  1026. const slot = { name, schema }
  1027. slots.push(slot)
  1028. return slot
  1029. }
  1030. function renderSuccess(schema: Schema.Top, name: string) {
  1031. if (!isStreamSchema(schema)) return { source: addSlot(schema, name)!.name }
  1032. const status = resolveHttpApiStatus(schema.ast) ?? 200
  1033. const annotate = status === 200 ? "" : `.pipe(HttpApiSchema.status(${status}))`
  1034. if (schema._tag === "StreamUint8Array") {
  1035. return {
  1036. source: `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(schema.contentType)} })${annotate}`,
  1037. }
  1038. }
  1039. const value = addSlot(
  1040. schema.sseMode === "data" ? streamDataSchema(schema) : schema.events,
  1041. `${name}${schema.sseMode === "data" ? "Data" : "Events"}`,
  1042. )!
  1043. const error = addSlot(schema.error, `${name}Error`)!
  1044. return {
  1045. source: `HttpApiSchema.StreamSse({ ${schema.sseMode}: ${value.name}, error: ${error.name}, contentType: ${JSON.stringify(schema.contentType)} })${annotate}`,
  1046. streamError: error,
  1047. }
  1048. }
  1049. const declarations = renderSchemas(slots)
  1050. const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}`
  1051. const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema."))
  1052. const methods = group.endpoints
  1053. .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`)
  1054. .join(", ")
  1055. const rawGroup = group.endpoints[0]?.topLevel
  1056. ? `HttpApiClient.Client<typeof Group${groupIndex}>`
  1057. : `HttpApiClient.Client.Group<typeof Group${groupIndex}, ${JSON.stringify(group.identifier)}, never, never>`
  1058. const usesStream = group.endpoints.some((item) => item.operation.success === "stream")
  1059. return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect, Schema${usesStream ? ", Stream" : ""} } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\nimport { HttpApiClient, HttpApiEndpoint, HttpApiGroup${usesHttpApiSchema ? ", HttpApiSchema" : ""} } from "effect/unstable/httpapi"\nimport { ClientError } from "./client-error"\n\n${declarations}\n\nexport const Group${groupIndex} = ${groupSource}\n\ntype RawGroup = ${rawGroup}\n\n${adapters.join("\n\n")}\n\nexport const adaptGroup${groupIndex} = (raw: RawGroup) => ({ ${methods} })\n`
  1060. }
  1061. function renderSchemas(slots: ReadonlyArray<Slot>) {
  1062. if (slots.length === 0) return ""
  1063. const classes = new Map(
  1064. slots.flatMap((slot, index) => {
  1065. const tagged = taggedErrorFields(slot.schema)
  1066. return tagged === undefined ? [] : [[index, tagged] as const]
  1067. }),
  1068. )
  1069. const expanded = [
  1070. ...slots.map((slot, index) => (classes.has(index) ? { name: slot.name, schema: Schema.Never } : slot)),
  1071. ...Array.from(classes.values()).flatMap((tagged, classIndex) =>
  1072. tagged.fields.map(([name, schema]) => ({ name: `Class${classIndex}${name}`, schema })),
  1073. ),
  1074. ]
  1075. const [first, ...rest] = expanded
  1076. const document = SchemaRepresentation.toCodeDocument(
  1077. SchemaRepresentation.fromASTs([first.schema.ast, ...rest.map((slot) => slot.schema.ast)]),
  1078. )
  1079. const artifacts = document.artifacts.flatMap((artifact) => {
  1080. if (artifact._tag === "Import") return [artifact.importDeclaration]
  1081. if (artifact._tag === "Enum") return [artifact.generation.runtime]
  1082. return [`const ${artifact.identifier} = ${artifact.generation.runtime}`]
  1083. })
  1084. const references = [
  1085. ...document.references.nonRecursives.map(({ $ref, code }) => `const ${$ref} = ${code.runtime}`),
  1086. ...Object.entries(document.references.recursives).map(
  1087. ([$ref, code]) => `type ${$ref} = ${code.Type}\nconst ${$ref}: Schema.Codec<${$ref}> = ${code.runtime}`,
  1088. ),
  1089. ]
  1090. let fieldIndex = slots.length
  1091. const declarations = slots.map((slot, index) => {
  1092. const tagged = classes.get(index)
  1093. if (tagged === undefined) return `const ${slot.name} = ${document.codes[index].runtime}`
  1094. const fields = tagged.fields
  1095. .map(([name]) => `${JSON.stringify(name)}: ${document.codes[fieldIndex++].runtime}`)
  1096. .join(", ")
  1097. const annotations = Object.entries({
  1098. httpApiStatus: resolveHttpApiStatus(slot.schema.ast),
  1099. "~httpApiEncoding": resolveHttpApiEncoding(slot.schema.ast),
  1100. }).filter((entry) => entry[1] !== undefined)
  1101. const annotate =
  1102. annotations.length === 0
  1103. ? ""
  1104. : `.annotate({ ${annotations.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`).join(", ")} })`
  1105. return `class ${slot.name}Class extends Schema.TaggedErrorClass<${slot.name}Class>(${JSON.stringify(tagged.identifier)})(${JSON.stringify(tagged.tag)}, { ${fields} }) {}\nconst ${slot.name} = ${slot.name}Class${annotate}`
  1106. })
  1107. return [...artifacts, ...references, ...declarations].join("\n\n")
  1108. }
  1109. function renderClient(groups: ReadonlyArray<Group>) {
  1110. const imports = groups
  1111. .map((group, index) => `import { adaptGroup${index}, Group${index} } from ${JSON.stringify(`./${group.module}`)}`)
  1112. .join("\n")
  1113. const api = `HttpApi.make("generated")${groups.map((_, index) => `.add(Group${index})`).join("")}`
  1114. const fields = groups.flatMap((group, index) => {
  1115. if (!group.endpoints[0]?.topLevel) {
  1116. return [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`]
  1117. }
  1118. const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }`
  1119. return [`...adaptGroup${index}(${raw})`]
  1120. })
  1121. return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n`
  1122. }