feat(client): generate complete protocol client
This commit is contained in:
parent
573ab9c24b
commit
7399d57aee
10 changed files with 3932 additions and 138 deletions
|
|
@ -75,7 +75,10 @@ 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>>
|
||||
},
|
||||
): Contract {
|
||||
const endpoints: Array<Endpoint> = []
|
||||
const portable = new Map<SchemaAST.AST, boolean>()
|
||||
|
|
@ -150,7 +153,7 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
|
|||
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)
|
||||
|
|
@ -245,7 +248,13 @@ export function emitPromise(contract: Contract): Output {
|
|||
},
|
||||
{
|
||||
path: "client.ts",
|
||||
content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
|
||||
content: renderPromiseClient(groups)
|
||||
.replace("readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary: boolean\n}")
|
||||
.replace(
|
||||
"return await json(response) as A",
|
||||
'if (descriptor.binary) {\n try {\n return new Uint8Array(await response.arrayBuffer()) as A\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n return await json(response) as A',
|
||||
)
|
||||
.replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
|
||||
},
|
||||
{
|
||||
path: "index.ts",
|
||||
|
|
@ -277,13 +286,13 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
|
|||
}
|
||||
} else if (
|
||||
!HttpApiSchema.isNoContent(success.ast) &&
|
||||
(resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json"
|
||||
!["Json", "Uint8Array"].includes(resolveHttpApiEncoding(success.ast)?._tag ?? "Json")
|
||||
) {
|
||||
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) === undefined) {
|
||||
throw new GenerationError({ reason: `Promise error must have a literal discriminator: ${name}` })
|
||||
}
|
||||
if ((resolveHttpApiEncoding(error.ast)?._tag ?? "Json") !== "Json") {
|
||||
throw new GenerationError({ reason: `Unsupported Promise error encoding: ${name}` })
|
||||
|
|
@ -422,7 +431,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||
groups.flatMap((group) =>
|
||||
group.endpoints.flatMap((endpoint) =>
|
||||
endpoint.errors.flatMap((schema) => {
|
||||
const tagged = taggedErrorFields(schema)
|
||||
const tagged = declaredErrorFields(schema)
|
||||
return tagged === undefined ? [] : [[tagged.tag, tagged] as const]
|
||||
}),
|
||||
),
|
||||
|
|
@ -432,7 +441,7 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
|||
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) =>
|
||||
|
|
@ -505,7 +514,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
|||
endpoint.errors.map((schema) => resolveHttpApiStatus(schema.ast)).filter((status) => status !== undefined),
|
||||
),
|
||||
]
|
||||
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"} }`
|
||||
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"}, binary: ${resolveHttpApiEncoding(endpoint.successes[0].ast)?._tag === "Uint8Array"} }`
|
||||
if (endpoint.operation.success === "stream") {
|
||||
const success = endpoint.successes[0]
|
||||
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
|
||||
|
|
@ -556,9 +565,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
|
||||
}
|
||||
|
|
@ -921,18 +933,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],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -151,6 +151,19 @@ describe("HttpApiCodegen.generate", () => {
|
|||
expect(effect).toContain('raw["session.get"]')
|
||||
})
|
||||
|
||||
test("supports explicit public endpoint names", () => {
|
||||
const source = HttpApi.make("test").add(
|
||||
HttpApiGroup.make("server.permission")
|
||||
.add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String }))
|
||||
.add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })),
|
||||
)
|
||||
const contract = compileContract(source, {
|
||||
endpointNames: { "permission.request.list": "listRequests" },
|
||||
})
|
||||
|
||||
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
|
||||
})
|
||||
|
||||
test("preserves optional keys in Promise error types", () => {
|
||||
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
|
||||
"OptionalError",
|
||||
|
|
@ -166,6 +179,22 @@ describe("HttpApiCodegen.generate", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("supports name-discriminated Promise errors", () => {
|
||||
class NamedError extends Schema.ErrorClass<NamedError>("NamedError")(
|
||||
{ name: Schema.Literal("NamedError"), message: Schema.String },
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(HttpApiEndpoint.get("get", "/session", { success: Schema.NumberFromString, error: NamedError })),
|
||||
),
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('readonly "name": "NamedError"')
|
||||
expect(types).toContain('"name" in value && value["name"] === "NamedError"')
|
||||
})
|
||||
|
||||
test("erases brands from Promise wire types", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
|
@ -200,6 +229,26 @@ describe("HttpApiCodegen.generate", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("expands Promise references only at identifier boundaries", () => {
|
||||
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
|
||||
identifier: "Session",
|
||||
})
|
||||
const SessionID = Schema.String.annotate({ identifier: "SessionID" })
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("get", "/session", {
|
||||
success: Schema.Struct({ session: Session, sessionID: SessionID }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
|
||||
)
|
||||
})
|
||||
|
||||
test("emits Effect Json schemas as standalone Promise types", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
|
@ -260,6 +309,32 @@ describe("HttpApiCodegen.generate", () => {
|
|||
).toThrow("Unsupported Promise stream: session.events")
|
||||
})
|
||||
|
||||
test("executes an emitted binary Promise response", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("read", "/file", {
|
||||
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
|
||||
|
||||
try {
|
||||
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
|
||||
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
|
||||
const client = generated.OpenCode.make({
|
||||
baseUrl: "https://example.com",
|
||||
fetch: async () => new Response(new Uint8Array([1, 2, 3])),
|
||||
})
|
||||
|
||||
expect(await client.session.read()).toEqual(new Uint8Array([1, 2, 3]))
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("executes an emitted Promise GET through fetch", async () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue