| 1 | /** |
| 2 | * Code generator service wrapping @effect/openapi-generator. |
| 3 | * |
| 4 | * @since 4.0.0 |
| 5 | */ |
| 6 | import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator" |
| 7 | import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch" |
| 8 | import * as Context from "effect/Context" |
| 9 | import * as Data from "effect/Data" |
| 10 | import * as Effect from "effect/Effect" |
| 11 | import type * as FileSystem from "effect/FileSystem" |
| 12 | import type * as JsonSchema from "effect/JsonSchema" |
| 13 | import * as Layer from "effect/Layer" |
| 14 | import * as Path_ from "effect/Path" |
| 15 | import * as Predicate from "effect/Predicate" |
| 16 | import type * as Schema from "effect/Schema" |
| 17 | import type { DiscoveredProvider } from "./Discovery.ts" |
| 18 | |
| 19 | /** |
| 20 | * Error during code generation. |
| 21 | * |
| 22 | * **Example** (Creating a generation error) |
| 23 | * |
| 24 | * ```ts import.meta.vitest |
| 25 | * import * as Generator from "@effect/ai-codegen/Generator" |
| 26 | * |
| 27 | * const error = new Generator.GenerationError({ |
| 28 | * provider: "openai", |
| 29 | * cause: new Error("Invalid spec") |
| 30 | * }) |
| 31 | * |
| 32 | * error._tag // => "GenerationError" |
| 33 | * error.provider // => "openai" |
| 34 | * ``` |
| 35 | * |
| 36 | * @category errors |
| 37 | * @since 4.0.0 |
| 38 | */ |
| 39 | export class GenerationError extends Data.TaggedError("GenerationError")<{ |
| 40 | readonly provider: string |
| 41 | readonly cause: unknown |
| 42 | }> {} |
| 43 | |
| 44 | /** |
| 45 | * Error during patch application. |
| 46 | * |
| 47 | * **Example** (Creating a patch error) |
| 48 | * |
| 49 | * ```ts import.meta.vitest |
| 50 | * import * as Generator from "@effect/ai-codegen/Generator" |
| 51 | * |
| 52 | * const error = new Generator.PatchError({ |
| 53 | * provider: "openai", |
| 54 | * cause: new Error("Invalid patch") |
| 55 | * }) |
| 56 | * |
| 57 | * error._tag // => "PatchError" |
| 58 | * error.provider // => "openai" |
| 59 | * ``` |
| 60 | * |
| 61 | * @category errors |
| 62 | * @since 4.0.0 |
| 63 | */ |
| 64 | export class PatchError extends Data.TaggedError("PatchError")<{ |
| 65 | readonly provider: string |
| 66 | readonly cause: unknown |
| 67 | }> {} |
| 68 | |
| 69 | /** |
| 70 | * Service for generating Effect code from OpenAPI specs. |
| 71 | * |
| 72 | * @category services |
| 73 | * @since 4.0.0 |
| 74 | */ |
| 75 | export interface CodeGenerator { |
| 76 | readonly generate: ( |
| 77 | provider: DiscoveredProvider, |
| 78 | spec: unknown |
| 79 | ) => Effect.Effect<string, GenerationError | PatchError, FileSystem.FileSystem | Path_.Path> |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * Service tag for generating Effect client code from OpenAPI specifications. |
| 84 | * |
| 85 | * @category services |
| 86 | * @since 4.0.0 |
| 87 | */ |
| 88 | export const CodeGenerator: Context.Service<CodeGenerator, CodeGenerator> = Context.Service( |
| 89 | "@effect/ai-codegen/CodeGenerator" |
| 90 | ) |
| 91 | |
| 92 | const isRecord = (u: unknown): u is { readonly [x: string]: unknown } => |
| 93 | Predicate.isObjectOrArray(u) && !Array.isArray(u) |
| 94 | |
| 95 | /** A bare `{ "type": "string" }` branch - the open half of an open enum. */ |
| 96 | const isOpenStringBranch = (branch: unknown): boolean => |
| 97 | isRecord(branch) && branch.type === "string" && Object.keys(branch).length === 1 |
| 98 | |
| 99 | /** The value of a `{ "const": "..." }` branch, when it is a string const. */ |
| 100 | const constBranchValue = (branch: unknown): string | undefined => |
| 101 | isRecord(branch) && Predicate.isString(branch.const) ? branch.const : undefined |
| 102 | |
| 103 | /** |
| 104 | * Collapse an open enum's const branches into a single `enum` branch. |
| 105 | * |
| 106 | * Stainless-generated specs encode an open enum as |
| 107 | * `anyOf: [{ type: "string" }, { const: "a" }, { const: "b" }]` - "any string is valid, and these are |
| 108 | * the known values". Left as-is, each const becomes its own `Schema.Literal` member, so consumers |
| 109 | * cannot recover the literal union from the generated schema (the union's `Type` is just `string`). |
| 110 | * |
| 111 | * Rewriting the const branches to `anyOf: [{ type: "string" }, { enum: ["a", "b"] }]` emits |
| 112 | * `Schema.Union([Schema.String, Schema.Literals(["a", "b"])])` instead, which decodes any string while |
| 113 | * still exposing the known values as `members[1]` for autocomplete. |
| 114 | */ |
| 115 | const normalizeOpenEnum = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { |
| 116 | const anyOf = js.anyOf |
| 117 | if (!Array.isArray(anyOf) || anyOf.length < 2) return js |
| 118 | |
| 119 | const [head, ...tail] = anyOf |
| 120 | if (!isOpenStringBranch(head)) return js |
| 121 | |
| 122 | const literals: Array<string> = [] |
| 123 | for (const branch of tail) { |
| 124 | const value = constBranchValue(branch) |
| 125 | if (value === undefined) return js |
| 126 | literals.push(value) |
| 127 | } |
| 128 | |
| 129 | return { ...js, anyOf: [head, { enum: literals }] } |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Layer providing the CodeGenerator service. |
| 134 | * |
| 135 | * @category layers |
| 136 | * @since 4.0.0 |
| 137 | */ |
| 138 | export const layer: Layer.Layer< |
| 139 | CodeGenerator, |
| 140 | never, |
| 141 | OpenApiGenerator.OpenApiGenerator | FileSystem.FileSystem | Path_.Path |
| 142 | > = Effect.gen(function*() { |
| 143 | const openApiGen = yield* OpenApiGenerator.OpenApiGenerator |
| 144 | const pathService = yield* Path_.Path |
| 145 | |
| 146 | const applyPatches = Effect.fn("applyPatches")(function*( |
| 147 | provider: DiscoveredProvider, |
| 148 | spec: Schema.Json |
| 149 | ) { |
| 150 | const patchInputs = provider.config.patchList |
| 151 | if (patchInputs.length === 0) { |
| 152 | return spec |
| 153 | } |
| 154 | |
| 155 | // Parse all patches, resolving file paths relative to the provider package |
| 156 | const parsedPatches = yield* Effect.forEach(patchInputs, (input) => { |
| 157 | // If it looks like a file path and is not absolute, resolve relative to package |
| 158 | const trimmed = input.trimStart() |
| 159 | const resolvedInput = !trimmed.startsWith("[") && !pathService.isAbsolute(trimmed) |
| 160 | ? pathService.join(provider.packagePath, trimmed) |
| 161 | : trimmed |
| 162 | return OpenApiPatch.parsePatchInput(resolvedInput).pipe( |
| 163 | Effect.map((patch) => ({ source: resolvedInput, patch })) |
| 164 | ) |
| 165 | }).pipe( |
| 166 | Effect.mapError((cause) => new PatchError({ provider: provider.name, cause })) |
| 167 | ) |
| 168 | |
| 169 | // Apply all patches to the spec |
| 170 | return yield* OpenApiPatch.applyPatches(parsedPatches, spec).pipe( |
| 171 | Effect.mapError((cause) => new PatchError({ provider: provider.name, cause })) |
| 172 | ) |
| 173 | }) |
| 174 | |
| 175 | const generate = Effect.fn("generate")(function*( |
| 176 | provider: DiscoveredProvider, |
| 177 | spec: unknown |
| 178 | ) { |
| 179 | // Apply patches if any are configured |
| 180 | const patchedSpec = yield* applyPatches(provider, spec as Schema.Json) |
| 181 | |
| 182 | const excludeAnnotations = provider.config.excludeAnnotationsList |
| 183 | const disableAdditionalProperties = provider.config.shouldDisableAdditionalProperties |
| 184 | |
| 185 | const exclude = excludeAnnotations ? new Set(excludeAnnotations) : undefined |
| 186 | const onEnter = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { |
| 187 | const out = { ...normalizeOpenEnum(js) } |
| 188 | if (exclude) { |
| 189 | for (const key of exclude) delete out[key] |
| 190 | } |
| 191 | if (disableAdditionalProperties && out.type === "object") { |
| 192 | out.additionalProperties = false |
| 193 | } |
| 194 | return out |
| 195 | } |
| 196 | |
| 197 | return yield* openApiGen |
| 198 | .generate(patchedSpec as unknown as Parameters<typeof openApiGen.generate>[0], { |
| 199 | name: provider.config.clientName, |
| 200 | format: provider.config.isTypeOnly ? "httpclient-type-only" : "httpclient", |
| 201 | onEnter |
| 202 | }) |
| 203 | .pipe( |
| 204 | Effect.mapError((cause) => new GenerationError({ provider: provider.name, cause })) |
| 205 | ) |
| 206 | }) |
| 207 | |
| 208 | return { generate } |
| 209 | }).pipe(Layer.effect(CodeGenerator)) |
| 210 | |
| 211 | /** |
| 212 | * Layer providing the CodeGenerator with schema transformer (default). |
| 213 | * |
| 214 | * @category layers |
| 215 | * @since 4.0.0 |
| 216 | */ |
| 217 | export const layerSchema: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe( |
| 218 | Layer.provide(OpenApiGenerator.layerTransformerSchema) |
| 219 | ) |
| 220 | |
| 221 | /** |
| 222 | * Layer providing the CodeGenerator with TypeScript-only transformer. |
| 223 | * |
| 224 | * @category layers |
| 225 | * @since 4.0.0 |
| 226 | */ |
| 227 | export const layerTypeScript: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe( |
| 228 | Layer.provide(OpenApiGenerator.layerTransformerTs) |
| 229 | ) |