fix(client): generate standalone promise DTOs
This commit is contained in:
parent
feaec7a6be
commit
b87bb4486f
4 changed files with 2489 additions and 3917 deletions
|
|
@ -1,43 +1,15 @@
|
||||||
import type {
|
type Client = ReturnType<typeof import("./generated/client.js").make>
|
||||||
AgentApi as EffectAgentApi,
|
|
||||||
CommandApi as EffectCommandApi,
|
|
||||||
EventApi as EffectEventApi,
|
|
||||||
IntegrationApi as EffectIntegrationApi,
|
|
||||||
ModelApi as EffectModelApi,
|
|
||||||
PluginApi as EffectPluginApi,
|
|
||||||
ProviderApi as EffectProviderApi,
|
|
||||||
ReferenceApi as EffectReferenceApi,
|
|
||||||
SessionApi as EffectSessionApi,
|
|
||||||
SkillApi as EffectSkillApi,
|
|
||||||
} from "../effect/api/api.js"
|
|
||||||
import type { Effect, Stream } from "effect"
|
|
||||||
|
|
||||||
type PromisifyOperation<Operation> = Operation extends (
|
export type AgentApi = Client["agent"]
|
||||||
...args: infer Args
|
export type CommandApi = Client["command"]
|
||||||
) => Effect.Effect<infer Success, unknown, unknown>
|
export type EventApi = Client["event"]
|
||||||
? (...args: Args) => Promise<Success>
|
export type IntegrationApi = Client["integration"]
|
||||||
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
|
export type ModelApi = Client["model"]
|
||||||
? (...args: Args) => AsyncIterable<Success>
|
export type PluginApi = Client["plugin"]
|
||||||
: Operation extends (...args: infer _Args) => unknown
|
export type ProviderApi = Client["provider"]
|
||||||
? Operation
|
export type ReferenceApi = Client["reference"]
|
||||||
: Operation extends object
|
export type SessionApi = Client["session"]
|
||||||
? PromisifyApi<Operation>
|
export type SkillApi = Client["skill"]
|
||||||
: Operation
|
|
||||||
|
|
||||||
type PromisifyApi<Api> = {
|
|
||||||
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
|
|
||||||
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
|
|
||||||
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
|
|
||||||
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
|
|
||||||
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
|
|
||||||
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
|
|
||||||
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
|
|
||||||
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
|
|
||||||
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
|
|
||||||
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
|
|
||||||
|
|
||||||
export interface CatalogApi {
|
export interface CatalogApi {
|
||||||
readonly provider: ProviderApi
|
readonly provider: ProviderApi
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -580,6 +580,17 @@ function renderPromiseTypes(
|
||||||
types.set(projected.ast, type)
|
types.set(projected.ast, type)
|
||||||
return type
|
return type
|
||||||
}
|
}
|
||||||
|
const outputMarkers = new Map<SchemaAST.AST, string>()
|
||||||
|
const outputSchemas: Array<Schema.Top> = []
|
||||||
|
const outputTypeOf = (schema: Schema.Top) => {
|
||||||
|
const projected = Schema.toEncoded(schema)
|
||||||
|
const cached = outputMarkers.get(projected.ast)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const marker = `__PROMISE_TYPE_${outputSchemas.length}__`
|
||||||
|
outputSchemas.push(projected)
|
||||||
|
outputMarkers.set(projected.ast, marker)
|
||||||
|
return marker
|
||||||
|
}
|
||||||
const errors = new Map(
|
const errors = new Map(
|
||||||
groups.flatMap((group) =>
|
groups.flatMap((group) =>
|
||||||
group.endpoints.flatMap((endpoint) =>
|
group.endpoints.flatMap((endpoint) =>
|
||||||
|
|
@ -618,26 +629,42 @@ function renderPromiseTypes(
|
||||||
const successSchema = endpoint.successes[0]
|
const successSchema = endpoint.successes[0]
|
||||||
const success =
|
const success =
|
||||||
outputTypes?.[clientOperationKey(group, endpoint)]?.name ??
|
outputTypes?.[clientOperationKey(group, endpoint)]?.name ??
|
||||||
typeOf(
|
outputTypeOf(
|
||||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||||
? successSchema.sseMode === "data"
|
? successSchema.sseMode === "data"
|
||||||
? streamEncodedDataSchema(successSchema)
|
? streamEncodedDataSchema(successSchema)
|
||||||
: successSchema.events
|
: successSchema.events
|
||||||
: successSchema,
|
: successSchema,
|
||||||
)
|
)
|
||||||
const output = mutableOutputs ? mutableType(success) : success
|
|
||||||
return [
|
return [
|
||||||
...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
||||||
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${output})["data"]` : output}`,
|
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
const json = operations.includes("JsonValue")
|
const reservedNames = new Set([
|
||||||
|
"ClientError",
|
||||||
|
"JsonValue",
|
||||||
|
...errors.keys(),
|
||||||
|
...groups.flatMap((group) =>
|
||||||
|
group.endpoints.flatMap((endpoint) => {
|
||||||
|
const prefix = promiseTypePrefix(group.identifier, endpoint.clientPath)
|
||||||
|
return [`${prefix}Input`, `${prefix}Output`]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...Object.values(outputTypes ?? {}).map((output) => output.name),
|
||||||
|
])
|
||||||
|
const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames)
|
||||||
|
const resolve = (source: string) =>
|
||||||
|
rendered.types.reduce((result, type, index) => result.replaceAll(`__PROMISE_TYPE_${index}__`, type), source)
|
||||||
|
const resolvedErrors = errorTypes.map(resolve)
|
||||||
|
const resolvedOperations = resolve(operations)
|
||||||
|
const json = [...rendered.definitions, ...resolvedErrors, resolvedOperations].some((type) => type.includes("JsonValue"))
|
||||||
? `export type JsonValue = null | boolean | number | string | ${mutableOutputs ? "Array<JsonValue> | { [key: string]: JsonValue }" : "ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"}`
|
? `export type JsonValue = null | boolean | number | string | ${mutableOutputs ? "Array<JsonValue> | { [key: string]: JsonValue }" : "ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"}`
|
||||||
: ""
|
: ""
|
||||||
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||||
return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
return [...imports, json, ...rendered.definitions, ...resolvedErrors, resolvedOperations].filter(Boolean).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutableType(type: string) {
|
function mutableType(type: string) {
|
||||||
|
|
@ -770,6 +797,49 @@ function identifierPart(value: string) {
|
||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
|
||||||
|
if (schemas.length === 0) return { types: [], definitions: [] }
|
||||||
|
const document = SchemaRepresentation.toCodeDocument(
|
||||||
|
SchemaRepresentation.fromASTs(schemas.map((schema) => schema.ast) as [SchemaAST.AST, ...Array<SchemaAST.AST>]),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
document.artifacts.some(
|
||||||
|
(artifact) =>
|
||||||
|
artifact._tag !== "Import" || artifact.importDeclaration !== 'import type * as Brand from "effect/Brand"',
|
||||||
|
) ||
|
||||||
|
Object.keys(document.references.recursives).length > 0
|
||||||
|
) {
|
||||||
|
throw new GenerationError({ reason: "Referenced Promise types are not implemented" })
|
||||||
|
}
|
||||||
|
const names = new Map<string, string>()
|
||||||
|
const usedNames = new Set(reservedNames)
|
||||||
|
for (const reference of document.references.nonRecursives) {
|
||||||
|
const seed = identifierPart(reference.$ref)
|
||||||
|
const name = uniqueTypeName(seed, usedNames)
|
||||||
|
names.set(reference.$ref, name)
|
||||||
|
usedNames.add(name)
|
||||||
|
}
|
||||||
|
const render = (type: string) => {
|
||||||
|
for (const [reference, name] of names) {
|
||||||
|
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
|
||||||
|
type = type.replace(new RegExp(pattern, "g"), name)
|
||||||
|
}
|
||||||
|
const output = type.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "").replaceAll("Schema.Json", "JsonValue")
|
||||||
|
return mutable ? mutableType(output) : output
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
types: document.codes.map((code) => render(code.Type)),
|
||||||
|
definitions: document.references.nonRecursives.map(
|
||||||
|
(reference) => `export type ${names.get(reference.$ref)} = ${render(reference.code.Type)}`,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTypeName(seed: string, used: ReadonlySet<string>, suffix = 1): string {
|
||||||
|
const name = suffix === 1 ? seed : `${seed}${suffix}`
|
||||||
|
return used.has(name) ? uniqueTypeName(seed, used, suffix + 1) : name
|
||||||
|
}
|
||||||
|
|
||||||
function structuralType(schema: Schema.Top) {
|
function structuralType(schema: Schema.Top) {
|
||||||
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast]))
|
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast]))
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
expect(types).not.toContain("Brand")
|
expect(types).not.toContain("Brand")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("inlines non-recursive references in Promise wire types", () => {
|
test("retains non-recursive references in Promise wire types", () => {
|
||||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
||||||
const output = emitPromise(
|
const output = emitPromise(
|
||||||
compileContract(
|
compileContract(
|
||||||
|
|
@ -508,9 +508,9 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||||
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
|
expect(types).toContain('export type Referenced = { readonly "value": string }')
|
||||||
)
|
expect(types).toContain('export type SessionGetOutput = ({ readonly "data": Referenced })["data"]')
|
||||||
})
|
})
|
||||||
|
|
||||||
test("emits mutable Promise outputs without restricting inputs", () => {
|
test("emits mutable Promise outputs without restricting inputs", () => {
|
||||||
|
|
@ -531,7 +531,7 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
expect(types).toContain('export type SessionCreateOutput = ({ "data": Array<{ "values": Array<string> }> })["data"]')
|
expect(types).toContain('export type SessionCreateOutput = ({ "data": Array<{ "values": Array<string> }> })["data"]')
|
||||||
})
|
})
|
||||||
|
|
||||||
test("expands Promise references only at identifier boundaries", () => {
|
test("retains distinct Promise references at identifier boundaries", () => {
|
||||||
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
|
const Session = Schema.Struct({ name: Schema.Literal("Session"), id: Schema.String }).annotate({
|
||||||
identifier: "Session",
|
identifier: "Session",
|
||||||
})
|
})
|
||||||
|
|
@ -546,9 +546,24 @@ describe("HttpApiCodegen.generate", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||||
'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
|
expect(types).toContain('export type Session = { readonly "name": "Session", readonly "id": string }')
|
||||||
|
expect(types).toContain("export type SessionID = string")
|
||||||
|
expect(types).toContain('readonly "session": Session, readonly "sessionID": SessionID')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disambiguates flattened Promise reference names", () => {
|
||||||
|
const First = Schema.String.annotate({ identifier: "ExampleName" })
|
||||||
|
const Second = Schema.String.annotate({ identifier: "Example_Name" })
|
||||||
|
const output = emitPromise(
|
||||||
|
compileContract(
|
||||||
|
api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ first: First, second: Second }) })),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||||
|
|
||||||
|
expect(types).toContain("export type ExampleName = string")
|
||||||
|
expect(types).toContain("export type ExampleName2 = string")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("emits Effect Json schemas as standalone Promise types", () => {
|
test("emits Effect Json schemas as standalone Promise types", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue