1/**
2 * Code generator service wrapping @effect/openapi-generator.
3 *
4 * @since 4.0.0
5 */
6import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
7import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
8import * as Context from "effect/Context"
9import * as Data from "effect/Data"
10import * as Effect from "effect/Effect"
11import type * as FileSystem from "effect/FileSystem"
12import type * as JsonSchema from "effect/JsonSchema"
13import * as Layer from "effect/Layer"
14import * as Path_ from "effect/Path"
15import * as Predicate from "effect/Predicate"
16import type * as Schema from "effect/Schema"
17import 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 */
39export 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 */
64export 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 */
75export 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 */
88export const CodeGenerator: Context.Service<CodeGenerator, CodeGenerator> = Context.Service(
89 "@effect/ai-codegen/CodeGenerator"
90)
91
92const 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. */
96const 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. */
100const 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 */
115const 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 */
138export 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 */
217export 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 */
227export const layerTypeScript: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe(
228 Layer.provide(OpenApiGenerator.layerTransformerTs)
229)