feat(client): generate complete protocol client (#34164)

This commit is contained in:
Kit Langton 2026-06-27 22:25:40 -04:00 committed by GitHub
commit dfeb1b5051
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2614 additions and 201 deletions

View file

@ -50,7 +50,7 @@ export type Endpoint = {
readonly operation: Operation
readonly input: ReadonlyArray<InputField & { readonly optional: boolean }>
readonly unwrapData: boolean
readonly errors: ReadonlyArray<Schema.Top>
readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }>
readonly successes: ReadonlyArray<Schema.Top>
readonly effectPortable: boolean
}
@ -75,7 +75,11 @@ 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>> },
options?: {
readonly groupNames?: Readonly<Record<string, string>>
readonly endpointNames?: Readonly<Record<string, string>>
readonly omitEndpoints?: ReadonlySet<string>
},
): Contract {
const endpoints: Array<Endpoint> = []
const portable = new Map<SchemaAST.AST, boolean>()
@ -83,6 +87,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
HttpApi.reflect(api, {
onGroup() {},
onEndpoint({ endpoint, errors, group, middleware }) {
if (options?.omitEndpoints?.has(endpoint.name)) return
const groupName = options?.groupNames?.[group.identifier] ?? group.identifier
const name = `${groupName}.${endpoint.name}`
const required = Array.from(middleware).find((item) => item.requiredForClient)
@ -103,8 +108,8 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
}
const payloads = sourcePayloads.map((schema) => normalizeTransport(schema, "payload", endpoint, name)!)
const success = normalizeTransport(successSchemas[0], "success", endpoint, name)!
const errorSchemas = Array.from(errors.values()).flatMap((schemas) =>
schemas.map((schema) => normalizeTransport(schema, "error", endpoint, name)!),
const errorSchemas = Array.from(errors).flatMap(([status, schemas]) =>
schemas.map((schema) => ({ status, ...normalizeTransport(schema, "error", endpoint, name)! })),
)
const inputs = [
...inputFields(params?.schema, "params", name),
@ -124,7 +129,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
...(headers === undefined ? [] : [[`${name}.headers`, headers.schema] as const]),
...payloads.map((item) => [`${name}.payload`, item.schema] as const),
...responseSchemas(success.schema, `${name}.success`),
...errorSchemas.map((item) => [`${name}.error`, item.schema] as const),
...errorSchemas.map((item) => [`${name}.error.${item.status}`, item.schema] as const),
]
const effectPortable =
[params, query, headers, ...payloads, success, ...errorSchemas].every(
@ -146,11 +151,11 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
input: inputs,
unwrapData: isDataEnvelope(success.schema),
successes: [success.schema],
errors: errorSchemas.map((item) => item.schema),
errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })),
effectPortable,
operation: {
group: groupName,
name: clientEndpointName(endpoint.name),
name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name),
input: inputs.map(({ name, source }) => ({ name, source })),
inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required",
success: isStreamSchema(success.schema)
@ -287,10 +292,10 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
}
for (const error of endpoint.errors) {
if (taggedErrorFields(error) === undefined) {
throw new GenerationError({ reason: `Promise error must be tagged: ${name}` })
if (declaredErrorFields(error.schema) === undefined) {
throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
}
if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") {
if ((resolveHttpApiEncoding(error.schema.ast)?._tag ?? "Json") !== "Json") {
throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` })
}
}
@ -332,7 +337,7 @@ function renderImportedEffectFiles(
const fields = item.input.filter((field) => field.source === source)
if (fields.length === 0) return []
return [
`${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : "."}${field.name}`).join(", ")} }`,
`${source}: { ${fields.map((field) => `${JSON.stringify(field.name)}: input${item.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(field.name)}]`).join(", ")} }`,
]
})
.join(", ")
@ -429,8 +434,8 @@ function renderPromiseTypes(
const errors = new Map(
groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) =>
endpoint.errors.flatMap((schema) => {
const tagged = taggedErrorFields(schema)
endpoint.errors.flatMap((error) => {
const tagged = declaredErrorFields(error.schema)
return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
}),
),
@ -440,7 +445,7 @@ function renderPromiseTypes(
const fields = error.fields
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
.join("; ")
return `export type ${error.identifier} = { readonly _tag: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && "_tag" in value && value._tag === ${JSON.stringify(error.tag)}`
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)}`
})
const operations = groups
.flatMap((group) =>
@ -499,7 +504,8 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
? "requestOptions?: RequestOptions"
: `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
const path = promisePath(endpoint.endpoint.path, endpoint.input)
const access = (name: string) => `input${endpoint.operation.inputMode === "optional" ? "?." : "."}${name}`
const access = (name: string) =>
`input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
const part = (source: InputField["source"]) => {
const inputs = endpoint.input.filter((field) => field.source === source)
return inputs.length === 0
@ -511,11 +517,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
endpoint.headers === undefined ? undefined : `headers: ${part("headers")}`,
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
].filter((value): value is string => value !== undefined)
const declaredStatuses = [
...new Set(
endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined),
),
]
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
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"} }`
if (endpoint.operation.success === "stream") {
const success = endpoint.successes[0]
@ -567,9 +569,12 @@ function structuralType(schema: Schema.Top) {
)
const expand = (type: string, seen = new Set<string>()): string => {
for (const [reference, value] of references) {
if (!type.includes(reference)) continue
if (seen.has(reference)) throw new GenerationError({ reason: "Recursive Promise types are not implemented" })
type = type.replaceAll(reference, `(${expand(value, new Set([...seen, reference]))})`)
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
if (!new RegExp(pattern).test(type)) continue
if (seen.has(reference)) {
throw new GenerationError({ reason: `Recursive Promise types are not implemented: ${reference}` })
}
type = type.replace(new RegExp(pattern, "g"), `(${expand(value, new Set([...seen, reference]))})`)
}
return type
}
@ -579,6 +584,7 @@ function structuralType(schema: Schema.Top) {
}
function promisePath(path: string, input: ReadonlyArray<InputField>) {
if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` })
const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name))
const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean)
const template = segments
@ -932,18 +938,26 @@ function serializable(value: unknown): boolean {
}
function taggedErrorFields(schema: Schema.Top) {
const fields = declaredErrorFields(schema)
return fields?.key === "_tag" ? fields : undefined
}
function declaredErrorFields(schema: Schema.Top) {
if (!SchemaAST.isDeclaration(schema.ast) || schema.ast.annotations?.["~effect/Schema/Class"] === undefined) {
return undefined
}
const fields = schema.ast.typeParameters[0]
if (!SchemaAST.isObjects(fields) || fields.indexSignatures.length > 0) return undefined
const tag = fields.propertySignatures.find((field) => field.name === "_tag")?.type
const key = fields.propertySignatures.find((field) => field.name === "_tag" || field.name === "name")?.name
if (key !== "_tag" && key !== "name") return undefined
const tag = fields.propertySignatures.find((field) => field.name === key)?.type
if (tag === undefined || !SchemaAST.isLiteral(tag) || typeof tag.literal !== "string") return undefined
return {
key,
tag: tag.literal,
identifier: SchemaAST.resolveIdentifier(schema.ast) ?? tag.literal,
fields: fields.propertySignatures.flatMap((field) =>
field.name === "_tag" || typeof field.name !== "string"
field.name === key || typeof field.name !== "string"
? []
: [[field.name, Schema.make(field.type), SchemaAST.isOptional(field.type)] as const],
),
@ -1014,7 +1028,7 @@ function renderGroup(group: Group, groupIndex: number) {
const headers = addSlot(endpointHeaders, `${prefix}Headers`)
const payloads = endpointPayloads.map((schema, index) => addSlot(schema, `${prefix}Payload${index}`)!)
const success = renderSuccess(successes[0], `${prefix}Success`)
const errorSlots = errors.map((schema, index) => addSlot(schema, `${prefix}Error${index}`)!)
const errorSlots = errors.map((error, index) => addSlot(error.schema, `${prefix}Error${index}`)!)
const options = [
params === undefined ? undefined : `params: ${params.name}`,
query === undefined ? undefined : `query: ${query.name}`,