refactor(client): generate from hosted session group

This commit is contained in:
Kit Langton
2026-06-23 12:53:58 -04:00
parent 07a65d5a16
commit 9c20973501
12 changed files with 411 additions and 86 deletions
+36 -10
View File
@@ -40,6 +40,7 @@ export class GenerationError extends Schema.TaggedErrorClass<GenerationError>()(
export type Endpoint = {
readonly group: string
readonly sourceGroup: string
readonly topLevel: boolean
readonly endpoint: HttpApiEndpoint.AnyWithProps
readonly params: Schema.Top | undefined
@@ -56,6 +57,7 @@ export type Endpoint = {
export type Group = {
readonly identifier: string
readonly sourceIdentifier: string
readonly module: string
readonly endpoints: ReadonlyArray<Endpoint>
}
@@ -72,6 +74,7 @@ const manifestName = ".httpapi-codegen.json"
export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
api: HttpApi.HttpApi<Id, Groups>,
options?: { readonly groupNames?: Readonly<Record<string, string>> },
): Contract {
const endpoints: Array<Endpoint> = []
const portable = new Map<SchemaAST.AST, boolean>()
@@ -79,7 +82,8 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
HttpApi.reflect(api, {
onGroup() {},
onEndpoint({ endpoint, errors, group, middleware }) {
const name = `${group.identifier}.${endpoint.name}`
const groupName = options?.groupNames?.[group.identifier] ?? group.identifier
const name = `${groupName}.${endpoint.name}`
const required = Array.from(middleware).find((item) => item.requiredForClient)
if (required !== undefined) {
throw new GenerationError({ reason: `Client middleware requires adapter: ${required.key}` })
@@ -129,7 +133,8 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
}
endpoints.push({
group: group.identifier,
group: groupName,
sourceGroup: group.identifier,
topLevel: group.topLevel,
endpoint,
params: params?.schema,
@@ -142,7 +147,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
errors: errorSchemas.map((item) => item.schema),
effectPortable,
operation: {
group: group.identifier,
group: groupName,
name: clientEndpointName(endpoint.name),
input: inputs.map(({ name, source }) => ({ name, source })),
inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required",
@@ -169,10 +174,13 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
const groups = Array.from(
Map.groupBy(endpoints, (endpoint) => endpoint.group),
([identifier, endpoints], index) => {
if (new Set(endpoints.map((endpoint) => endpoint.sourceGroup)).size > 1) {
throw new GenerationError({ reason: `Client group name collision: ${identifier}` })
}
const base = /^[A-Za-z0-9_-]+$/.test(identifier) ? identifier : `group-${index}`
const module = uniqueModule(base, index, modules)
modules.add(module.toLowerCase())
return { identifier, module, endpoints }
return { identifier, sourceIdentifier: endpoints[0].sourceGroup, module, endpoints }
},
)
const publicNames = new Set<string>()
@@ -211,6 +219,7 @@ export function emitEffectImported(
contract: Contract,
options:
| { readonly module: string; readonly api: string }
| { readonly module: string; readonly group: string }
| { readonly module: string; readonly endpoints: Readonly<Record<string, string>> },
): Output {
return {
@@ -304,10 +313,11 @@ function renderImportedEffectFiles(
groups: ReadonlyArray<Group>,
options:
| { readonly module: string; readonly api: string }
| { readonly module: string; readonly group: string }
| { readonly module: string; readonly endpoints: Readonly<Record<string, string>> },
): Output["files"] {
const adapters = groups.map((group, groupIndex) => {
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.identifier)}]`
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
const methods = group.endpoints.map((item, endpointIndex) => {
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
const request = (["params", "query", "headers", "payload"] as const)
@@ -338,16 +348,20 @@ function renderImportedEffectFiles(
const fields = groups.flatMap((group, index) =>
group.endpoints[0]?.topLevel
? [`...adaptGroup${index}(raw)`]
: [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.identifier)}])`],
: [`${JSON.stringify(group.identifier)}: adaptGroup${index}(raw[${JSON.stringify(group.sourceIdentifier)}])`],
)
const usesStream = groups.some((group) => group.endpoints.some((item) => item.operation.success === "stream"))
const imported = "api" in options
const projection = imported ? undefined : renderImportedProjection(groups, options.endpoints)
const projection = imported
? undefined
: "group" in options
? renderImportedGroup(options.group)
: renderImportedProjection(groups, options.endpoints)
const api = imported ? options.api : "Api"
const imports =
projection === undefined
? `import { ${api} } from ${JSON.stringify(options.module)}`
: `import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
: `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}`
const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : ""
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\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n`
return [
@@ -364,6 +378,13 @@ function renderImportedEffectFiles(
]
}
function renderImportedGroup(group: string) {
return {
imports: [group],
source: `const Api = HttpApi.make("generated").add(${group})\n\n`,
}
}
function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Readonly<Record<string, string>>) {
const imports = groups.flatMap((group) =>
group.endpoints.map((endpoint) => {
@@ -444,7 +465,10 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
}),
)
.join("\n\n")
return [...errorTypes, operations].filter(Boolean).join("\n\n")
const json = operations.includes("JsonValue")
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
: ""
return [json, ...errorTypes, operations].filter(Boolean).join("\n\n")
}
function renderPromiseClient(groups: ReadonlyArray<Group>) {
@@ -536,7 +560,9 @@ function structuralType(schema: Schema.Top) {
}
return type
}
return expand(document.codes[0].Type).replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
return expand(document.codes[0].Type)
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue")
}
function promisePath(path: string, input: ReadonlyArray<InputField>) {
@@ -88,6 +88,48 @@ describe("HttpApiCodegen.generate", () => {
expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
})
test("imports an authoritative group without reconstructing it", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
),
{ module: "@example/api", group: "SessionGroup" },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGroup } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
expect(client).not.toContain("HttpApiGroup")
})
test("separates hosted and consumer group names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session").add(
HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
),
)
const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
expect(contract.groups[0]?.identifier).toBe("sessions")
expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
})
test("rejects consumer group name collisions", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
.add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
"Client group name collision: same",
)
})
test("uses the unqualified endpoint name for the public client", () => {
const contract = compileContract(
api(
@@ -158,6 +200,23 @@ describe("HttpApiCodegen.generate", () => {
)
})
test("emits Effect Json schemas as standalone Promise types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Json,
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain("export type JsonValue =")
expect(types).toContain("{ readonly [key: string]: JsonValue }")
expect(types).not.toContain("Schema.Json")
})
test("emits an optional Promise input when every field is optional", () => {
const output = emitPromise(
compileContract(