فهرست منبع

fix(client): generate standalone promise DTOs

Dax Raad 1 ماه پیش
والد
کامیت
b87bb4486f

+ 11 - 39
packages/client/src/promise/api.ts

@@ -1,43 +1,15 @@
-import type {
-  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 Client = ReturnType<typeof import("./generated/client.js").make>
 
-type PromisifyOperation<Operation> = Operation extends (
-  ...args: infer Args
-) => Effect.Effect<infer Success, unknown, unknown>
-  ? (...args: Args) => Promise<Success>
-  : Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
-    ? (...args: Args) => AsyncIterable<Success>
-    : Operation extends (...args: infer _Args) => unknown
-      ? Operation
-      : Operation extends object
-        ? PromisifyApi<Operation>
-        : 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 type AgentApi = Client["agent"]
+export type CommandApi = Client["command"]
+export type EventApi = Client["event"]
+export type IntegrationApi = Client["integration"]
+export type ModelApi = Client["model"]
+export type PluginApi = Client["plugin"]
+export type ProviderApi = Client["provider"]
+export type ReferenceApi = Client["reference"]
+export type SessionApi = Client["session"]
+export type SkillApi = Client["skill"]
 
 export interface CatalogApi {
   readonly provider: ProviderApi

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2307 - 0
packages/client/src/promise/generated/types.ts


+ 75 - 5
packages/httpapi-codegen/src/index.ts

@@ -580,6 +580,17 @@ function renderPromiseTypes(
     types.set(projected.ast, 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(
     groups.flatMap((group) =>
       group.endpoints.flatMap((endpoint) =>
@@ -618,26 +629,42 @@ function renderPromiseTypes(
         const successSchema = endpoint.successes[0]
         const success =
           outputTypes?.[clientOperationKey(group, endpoint)]?.name ??
-          typeOf(
+          outputTypeOf(
             isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
               ? successSchema.sseMode === "data"
                 ? streamEncodedDataSchema(successSchema)
                 : successSchema.events
               : successSchema,
           )
-        const output = mutableOutputs ? mutableType(success) : success
         return [
           ...(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")
-  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 }"}`
     : ""
   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) {
@@ -770,6 +797,49 @@ function identifierPart(value: string) {
     .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) {
   const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.fromASTs([schema.ast]))
   if (

+ 22 - 7
packages/httpapi-codegen/test/generate.test.ts

@@ -496,7 +496,7 @@ describe("HttpApiCodegen.generate", () => {
     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 output = emitPromise(
       compileContract(
@@ -508,9 +508,9 @@ describe("HttpApiCodegen.generate", () => {
       ),
     )
 
-    expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
-      'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
-    )
+    const types = output.files.find((file) => file.path === "types.ts")?.content
+    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", () => {
@@ -531,7 +531,7 @@ describe("HttpApiCodegen.generate", () => {
     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({
       identifier: "Session",
     })
@@ -546,9 +546,24 @@ describe("HttpApiCodegen.generate", () => {
       ),
     )
 
-    expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
-      'readonly "session": ({ readonly "name": "Session", readonly "id": string })',
+    const types = output.files.find((file) => file.path === "types.ts")?.content
+    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", () => {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است