Skip to content
Source
Sign inSign up
Open History to see the last commit

Generator.ts

677 lines27.5 KB

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 Arr from "effect/Array"
9import * as Console from "effect/Console"
10import * as Context from "effect/Context"
11import * as Data from "effect/Data"
12import * as Effect from "effect/Effect"
13import type * as FileSystem from "effect/FileSystem"
14import * as JsonPointer from "effect/JsonPointer"
15import * as JsonSchema from "effect/JsonSchema"
16import * as Layer from "effect/Layer"
17import * as Path_ from "effect/Path"
18import * as Predicate from "effect/Predicate"
19import * as Record from "effect/Record"
20import type * as Schema from "effect/Schema"
21import type { DiscoveredProvider } from "./Discovery.ts"
22​
23/**
24 * Error during code generation.
25 *
26 * **Example** (Creating a generation error)
27 *
28 * ```ts import.meta.vitest
29 * import * as Generator from "@effect/ai-codegen/Generator"
30 *
31 * const error = new Generator.GenerationError({
32 * provider: "openai",
33 * cause: new Error("Invalid spec")
34 * })
35 *
36 * error._tag // => "GenerationError"
37 * error.provider // => "openai"
38 * ```
39 *
40 * @category errors
41 * @since 4.0.0
42 */
43export class GenerationError extends Data.TaggedError("GenerationError")<{
44 readonly provider: string
45 readonly cause: unknown
46}> {}
47​
48/**
49 * Error during patch application.
50 *
51 * **Example** (Creating a patch error)
52 *
53 * ```ts import.meta.vitest
54 * import * as Generator from "@effect/ai-codegen/Generator"
55 *
56 * const error = new Generator.PatchError({
57 * provider: "openai",
58 * cause: new Error("Invalid patch")
59 * })
60 *
61 * error._tag // => "PatchError"
62 * error.provider // => "openai"
63 * ```
64 *
65 * @category errors
66 * @since 4.0.0
67 */
68export class PatchError extends Data.TaggedError("PatchError")<{
69 readonly provider: string
70 readonly cause: unknown
71}> {}
72​
73/**
74 * Service for generating Effect code from OpenAPI specs.
75 *
76 * @category services
77 * @since 4.0.0
78 */
79export interface CodeGenerator {
80 readonly generate: (
81 provider: DiscoveredProvider,
82 spec: unknown
83 ) => Effect.Effect<string, GenerationError | PatchError, FileSystem.FileSystem | Path_.Path>
85​
86/**
87 * Service tag for generating Effect client code from OpenAPI specifications.
88 *
89 * @category services
90 * @since 4.0.0
91 */
92export const CodeGenerator: Context.Service<CodeGenerator, CodeGenerator> = Context.Service(
93 "@effect/ai-codegen/CodeGenerator"
95​
96/** A bare `{ "type": "string" }` branch - the open half of an open enum. */
97const isOpenStringBranch = (branch: unknown): boolean =>
98 Predicate.isObject(branch) && branch.type === "string" && Object.keys(branch).length === 1
99​
100/** The value of a `{ "const": "..." }` branch, when it is a string const. */
101const constBranchValue = (branch: unknown): string | undefined =>
102 Predicate.isObject(branch) && Predicate.isString(branch.const) ? branch.const : undefined
103​
104/**
105 * Collapse an open enum's const branches into a single `enum` branch.
106 *
107 * Stainless-generated specs encode an open enum as
108 * `anyOf: [{ type: "string" }, { const: "a" }, { const: "b" }]` - "any string is valid, and these are
109 * the known values". Left as-is, each const becomes its own `Schema.Literal` member, so consumers
110 * cannot recover the literal union from the generated schema (the union's `Type` is just `string`).
111 *
112 * Rewriting the const branches to `anyOf: [{ type: "string" }, { enum: ["a", "b"] }]` emits
113 * `Schema.Union([Schema.String, Schema.Literals(["a", "b"])])` instead, which decodes any string while
114 * still exposing the known values as `members[1]` for autocomplete.
115 */
116const normalizeOpenEnum = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
117 const anyOf = js.anyOf
118 if (!Array.isArray(anyOf) || anyOf.length < 2) return js
119​
120 const [head, ...tail] = anyOf
121 if (!isOpenStringBranch(head)) return js
122​
123 const literals: Array<string> = []
124 for (const branch of tail) {
125 const value = constBranchValue(branch)
126 if (value === undefined) return js
127 literals.push(value)
128 }
129​
130 return { ...js, anyOf: [head, { enum: literals }] }
132​
133const isStringArray = (u: unknown): u is ReadonlyArray<string> => Array.isArray(u) && u.every(Predicate.isString)
134​
135const requiredKey = (branch: unknown): string | undefined =>
136 Predicate.isObject(branch) && Object.keys(branch).length === 1 && isStringArray(branch.required) &&
137 branch.required.length === 1
138 ? branch.required[0]
139 : undefined
140​
141const choiceMode = (js: JsonSchema.JsonSchema): "anyOf" | "oneOf" | undefined => {
142 if (Array.isArray(js.oneOf)) return js.anyOf === undefined ? "oneOf" : undefined
143 return Array.isArray(js.anyOf) ? "anyOf" : undefined
145​
146/** The choice keyword of a schema made only of that choice and annotations. */
147const bareChoice = (js: JsonSchema.JsonSchema): "anyOf" | "oneOf" | undefined => {
148 const mode = choiceMode(js)
149 return mode !== undefined && Object.keys(js).every((key) => key === mode || isAnnotation(key)) ? mode : undefined
151​
152const absent = (keys: ReadonlyArray<string>): JsonSchema.JsonSchema => ({
153 properties: Object.fromEntries(keys.map((key) => [key, false]))
154})
155​
156const jsonTypes = ["null", "boolean", "object", "array", "number", "string"]
157​
158/** Returns the exact type complement, or `undefined` if it cannot be expressed. */
159const typeComplement = (not: { readonly [x: string]: unknown }, current: unknown): unknown => {
160 if (Object.keys(not).length !== 1) return undefined
161 const excluded = Predicate.isString(not.type) ? [not.type] : not.type
162 // `integer` is a subset of `number`, so excluding it alone leaves no type-level complement
163 if (!isStringArray(excluded) || excluded.includes("integer") && !excluded.includes("number")) return undefined
164 const types = current === undefined ? jsonTypes : Predicate.isString(current) ? [current] : current
165 if (!isStringArray(types)) return undefined
166 const remaining = types.filter((type) => !excluded.includes(type === "integer" ? "number" : type))
167 return remaining.length === 0 ? undefined : remaining.length === 1 ? remaining[0] : remaining
169​
170/** Rewrites a negated character class as an anchored positive pattern. */
171const negatedClassPattern = (not: { readonly [x: string]: unknown }): string | undefined => {
172 if (Object.keys(not).length !== 1 || !Predicate.isString(not.pattern)) return undefined
173 const match = /^\[\^((?:[^\\[\]]|\\.)+)\]$/.exec(not.pattern)
174 return match === null ? undefined : `^[${match[1]}]*$`
176​
177/** Rewrites supported negations as positive constraints; leaves other forms for the importer to reject. */
178const rewriteNot = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
179 const not = js.not
180 if (!Predicate.isObject(not)) return js
181 const { not: _, ...rest } = js
182​
183 const type = typeComplement(not, rest.type)
184 if (type !== undefined) return { ...rest, type }
185​
186 const pattern = negatedClassPattern(not)
187 if (pattern !== undefined) {
188 if (rest.pattern === undefined) return { ...rest, pattern }
189 // The existing pattern already restricts every character to the same class
190 if (rest.pattern === pattern || rest.pattern === pattern.replace(/\*\$$/, "+$")) return rest
191 return { ...rest, allOf: [...(Array.isArray(rest.allOf) ? rest.allOf : []), { pattern }] }
192 }
193​
194 // `required` holds for any non-object, so its negation only has a key-absence equivalent on objects
195 if (!isStringArray(not.required) || rest.type !== "object") return js
196 const required = not.required
197 const notKeys = Object.keys(not)
198 if (notKeys.length === 1 && required.length === 2 && rest.oneOf === undefined && Array.isArray(rest.anyOf)) {
199 const keys = rest.anyOf.map(requiredKey)
200 if (keys.length === 2 && required.every((key) => keys.includes(key)) && keys[0] !== keys[1]) {
201 const { anyOf, ...base } = rest
202 return { ...base, oneOf: anyOf }
203 }
204 }
205 if (rest.anyOf !== undefined || rest.oneOf !== undefined) return js
206 if (notKeys.length === 1) return { ...rest, anyOf: required.map((key) => absent([key])) }
207 const alternatives = Array.isArray(not.anyOf) ? not.anyOf.map(requiredKey) : []
208 if (notKeys.length === 2 && alternatives.length > 0 && isStringArray(alternatives)) {
209 return { ...rest, anyOf: [...required.map((key) => absent([key])), absent(alternatives)] }
210 }
211 return js
213​
214/** Without `then`, `if`/`else` reduces to `if or else`. */
215const rewriteConditional = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
216 if (js.if === undefined || js.else === undefined || js.then !== undefined || js.anyOf !== undefined) return js
217 const { else: otherwise, if: condition, ...rest } = js
218 return { ...rest, anyOf: [condition, otherwise] }
220​
221const definitionKey = ($ref: string): string | undefined => {
222 const path = $ref.startsWith("#") ? JsonPointer.parseUriFragment($ref) : undefined
223 return path !== undefined && path.length === 2 && path[0] === "$defs" ? path[1] : undefined
225​
226const isNullableMarker = (member: unknown): boolean =>
227 Predicate.isObject(member) && member.nullable === true && Object.keys(member).length === 1
228​
229/** Converts legacy `nullable: true` markers, including standalone `allOf` members, to null unions. */
230const rewriteNullable = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
231 const allOf = Array.isArray(js.allOf) ? js.allOf : undefined
232 if (js.nullable !== true && !allOf?.some(isNullableMarker)) return js
233 const { nullable: _, ...rest } = js
234 const members = allOf?.filter((member) => !isNullableMarker(member))
235 const { allOf: _allOf, ...base } = rest
236 const schema = members === undefined ? rest : members.length === 0 ? base : { ...base, allOf: members }
237 const annotations = pick(schema, isAnnotation)
238 return { ...annotations, anyOf: [pick(schema, (key) => !isAnnotation(key)), { type: "null" }] }
240​
241const objectKeys = new Set(["type", "properties", "required", "additionalProperties"])
242const annotationKeys = new Set([
243 "title",
244 "description",
245 "deprecated",
246 "readOnly",
247 "writeOnly",
248 "default",
249 "examples",
250 "$comment",
251 "discriminator"
252])
253​
254const isAnnotation = (key: string): boolean => annotationKeys.has(key) || key.startsWith("x-")
255​
256const pick = (js: JsonSchema.JsonSchema, predicate: (key: string) => boolean): JsonSchema.JsonSchema =>
257 Record.filter(js, (_, key) => predicate(key))
258​
259/** Object constraints and annotations only; no typed `additionalProperties`. */
260const isPlainObject = (js: JsonSchema.JsonSchema): boolean =>
261 (js.type === undefined || js.type === "object") &&
262 (js.additionalProperties === undefined || Predicate.isBoolean(js.additionalProperties)) &&
263 (js.properties === undefined || Predicate.isObject(js.properties)) &&
264 (js.required === undefined || isStringArray(js.required)) &&
265 Object.keys(js).every((key) => objectKeys.has(key) || isAnnotation(key))
266​
267const excludesObject = (js: JsonSchema.JsonSchema): boolean =>
268 Predicate.isString(js.type) ? js.type !== "object" : isStringArray(js.type) && !js.type.includes("object")
269​
270const isObjectSchema = (js: JsonSchema.JsonSchema): boolean =>
271 js.type === "object" || (isPlainObject(js) && (js.properties ?? js.required) !== undefined)
272​
273const declaredKeys = (js: JsonSchema.JsonSchema): ReadonlyArray<string> => [
274 ...Object.keys((js.properties as object | undefined) ?? {}),
275 ...(js.required as ReadonlyArray<string> | undefined ?? [])
277​
278/**
279 * Intersects object constraints. Explicit closures forbid keys declared only by the other member.
280 * Generator-added closures come later, so every closure here is from the spec.
281 */
282const mergeObjects = (left: JsonSchema.JsonSchema, right: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
283 const leftProperties = (left.properties as { readonly [x: string]: unknown } | undefined) ?? {}
284 const rightProperties = (right.properties as { readonly [x: string]: unknown } | undefined) ?? {}
285 const properties: { [x: string]: unknown } = { ...leftProperties }
286 for (const [key, schema] of Object.entries(rightProperties)) {
287 const current = properties[key]
288 properties[key] = current === undefined || JSON.stringify(current) === JSON.stringify(schema)
289 ? schema
290 : current === false || schema === false
291 ? false
292 : { allOf: [current, schema] }
293 }
294 for (
295 const [closed, other, closedProperties] of [[left, right, leftProperties], [right, left, rightProperties]] as const
296 ) {
297 if (closed.additionalProperties !== false) continue
298 for (const key of declaredKeys(other)) {
299 if (!Object.hasOwn(closedProperties, key)) properties[key] = false
300 }
301 }
302 const required = Arr.union(
303 left.required as ReadonlyArray<string> ?? [],
304 right.required as ReadonlyArray<string> ?? []
305 )
306 const additionalProperties = left.additionalProperties === false || right.additionalProperties === false
307 ? false
308 : left.additionalProperties ?? right.additionalProperties
309 return {
310 ...(left.type === "object" || right.type === "object" ? { type: "object" } : {}),
311 ...(Object.keys(properties).length > 0 ? { properties } : {}),
312 ...(required.length > 0 ? { required } : {}),
313 ...(additionalProperties !== undefined ? { additionalProperties } : {})
314 }
316​
317type Choice = {
318 readonly _tag: "Choice"
319 readonly mode: "anyOf" | "oneOf"
320 readonly branches: ReadonlyArray<JsonSchema.JsonSchema>
322​
323type Part = { readonly _tag: "Object"; readonly schema: JsonSchema.JsonSchema } | Choice
324​
325/** Object constraints do not apply to a non-object, which is dropped only when an object is required. */
326const withNonObject = (
327 nonObject: JsonSchema.JsonSchema,
328 other: JsonSchema.JsonSchema
329): ReadonlyArray<JsonSchema.JsonSchema> | undefined =>
330 isPlainObject(other) ? (other.type === "object" ? [] : [nonObject]) : undefined
331​
332/** Intersects branches: `[]` for incompatible types, `undefined` if an exact merge is unsupported. */
333const mergeBranch = (
334 object: JsonSchema.JsonSchema,
335 branch: JsonSchema.JsonSchema
336): ReadonlyArray<JsonSchema.JsonSchema> | undefined => {
337 if (excludesObject(object)) return withNonObject(object, branch)
338 if (excludesObject(branch)) return withNonObject(branch, object)
339 if (!isPlainObject(object) || !isPlainObject(branch)) return undefined
340 return [{ ...pick(branch, isAnnotation), ...mergeObjects(object, branch) }]
342​
343const withBranches = (choice: Choice, branches: ReadonlyArray<JsonSchema.JsonSchema> | undefined): Part | undefined =>
344 branches === undefined ? undefined : { ...choice, branches }
345​
346const mergeBranches = (
347 branches: ReadonlyArray<JsonSchema.JsonSchema>,
348 merge: (branch: JsonSchema.JsonSchema) => ReadonlyArray<JsonSchema.JsonSchema> | undefined
349): ReadonlyArray<JsonSchema.JsonSchema> | undefined => {
350 const out: Array<JsonSchema.JsonSchema> = []
351 for (const branch of branches) {
352 const merged = merge(branch)
353 if (merged === undefined) return undefined
354 out.push(...merged)
355 }
356 return out.length === 0 ? undefined : out
358​
359/** Applies object constraints to each branch, dropping non-objects only when an object is required. */
360const mergeIntoChoice = (object: JsonSchema.JsonSchema, choice: Choice): Part | undefined => {
361 return withBranches(choice, mergeBranches(choice.branches, (branch) => mergeBranch(object, branch)))
363​
364/**
365 * Intersects choices. Two `anyOf` lists form all branch pairs; `oneOf` nests the other choice
366 * inside each branch to preserve exclusivity. Nested choices cannot be merged further.
367 */
368const mergeChoices = (left: Choice, right: Choice): Part | undefined => {
369 if (right.branches.length === 1 && isPlainObject(right.branches[0])) return mergeIntoChoice(right.branches[0], left)
370 if (left.branches.length === 1 && isPlainObject(left.branches[0])) return mergeIntoChoice(left.branches[0], right)
371 if (left.mode === "anyOf" && right.mode === "anyOf") {
372 return withBranches(
373 left,
374 mergeBranches(left.branches, (outer) => mergeBranches(right.branches, (inner) => mergeBranch(outer, inner)))
375 )
376 }
377 const [outer, inner] = left.mode === "oneOf" ? [left, right] : [right, left]
378 return withBranches(
379 outer,
380 mergeBranches(outer.branches, (branch) => {
381 const nested = mergeBranches(inner.branches, (alternative) => mergeBranch(alternative, branch))
382 return nested === undefined ? undefined : [{ ...pick(branch, isAnnotation), [inner.mode]: nested }]
383 })
384 )
386​
387const mergeParts = (left: Part, right: Part): Part | undefined => {
388 if (left._tag === "Object") {
389 return right._tag === "Object"
390 ? { _tag: "Object", schema: mergeObjects(left.schema, right.schema) }
391 : mergeIntoChoice(left.schema, right)
392 }
393 if (right._tag === "Object") return mergeIntoChoice(right.schema, left)
394 return mergeChoices(left, right)
396​
397/**
398 * Merges object compositions before automatic closing, distributes constraints into choices,
399 * and removes redundant object wrappers. Unsupported compositions are left for the importer.
400 */
401const makeNormalizer = (resolve: (key: string) => JsonSchema.JsonSchema | undefined) => {
402 const resolving = new Set<string>()
403​
404 const prepare = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema =>
405 rewriteNot(rewriteConditional(rewriteNullable(js)))
406​
407 const dereference = (member: unknown): JsonSchema.JsonSchema | undefined => {
408 if (!Predicate.isObject(member)) return undefined
409 if (!Predicate.isString(member.$ref)) return compose(prepare(member))
410 const { $ref, ...siblings } = member
411 const key = definitionKey($ref)
412 if (key === undefined || resolving.has(key) || !Object.keys(siblings).every(isAnnotation)) return undefined
413 const definition = resolve(key)
414 if (definition === undefined) return undefined
415 resolving.add(key)
416 try {
417 return { ...compose(prepare(definition)), ...siblings }
418 } finally {
419 resolving.delete(key)
420 }
421 }
422​
423 const toPart = (member: unknown): Part | undefined => {
424 const schema = dereference(member)
425 if (schema === undefined) return undefined
426 if (isPlainObject(schema)) return { _tag: "Object", schema }
427 const mode = bareChoice(schema)
428 if (mode === undefined) return undefined
429 const branches: Array<JsonSchema.JsonSchema> = []
430 for (const branch of schema[mode] as ReadonlyArray<unknown>) {
431 const resolved = dereference(branch)
432 if (resolved === undefined || !(isPlainObject(resolved) || excludesObject(resolved))) return undefined
433 branches.push(resolved)
434 }
435 return { _tag: "Choice", mode, branches }
436 }
437​
438 const compose = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
439 // Sibling choices must both hold.
440 const members = [
441 ...(Array.isArray(js.allOf) ? js.allOf : []),
442 ...(Array.isArray(js.anyOf) ? [{ anyOf: js.anyOf }] : []),
443 ...(Array.isArray(js.oneOf) ? [{ oneOf: js.oneOf }] : [])
444 ]
445 const hasOwn = js.properties !== undefined || js.required !== undefined
446 // Preserve references when no intersection is needed.
447 if ((hasOwn ? 1 : 0) + members.length < 2) return js
448 const { allOf: _allOf, anyOf: _anyOf, oneOf: _oneOf, ...rest } = js
449 if (!isPlainObject(rest)) return js
450 const own = pick(rest, (key) => objectKeys.has(key))
451 let part: Part = { _tag: "Object", schema: own }
452 for (const member of members) {
453 const next = toPart(member)
454 const merged: Part | undefined = next === undefined ? undefined : mergeParts(part, next)
455 if (merged === undefined) return js
456 part = merged
457 }
458 const annotations = pick(rest, isAnnotation)
459 return part._tag === "Object"
460 ? { ...annotations, ...part.schema }
461 : { ...annotations, [part.mode]: part.branches }
462 }
463​
464 const dropRedundantObjectType = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
465 if (js.type !== "object") return js
466 const { type: _, ...rest } = js
467 const mode = bareChoice(rest)
468 if (mode === undefined) return js
469 const branches = rest[mode] as ReadonlyArray<unknown>
470 return branches.every((branch) => {
471 const resolved = dereference(branch)
472 return resolved !== undefined && isObjectSchema(resolved)
473 })
474 ? rest
475 : js
476 }
477​
478 return (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => dropRedundantObjectType(compose(prepare(js)))
480​
481/** Resolves component schemas using the same dialect conversion as the OpenAPI generator. */
482const makeResolver = (spec: Schema.Json) => {
483 const components =
484 Predicate.isObject(spec) && Predicate.isObject(spec.components) && Predicate.isObject(spec.components.schemas)
485 ? spec.components.schemas
486 : {}
487 const openapi = Predicate.isObject(spec) && Predicate.isString(spec.openapi) ? spec.openapi : ""
488 const convert = openapi.trim().startsWith("3.0") ? JsonSchema.fromSchemaOpenApi3_0 : JsonSchema.fromSchemaOpenApi3_1
489 const cache = new Map<string, JsonSchema.JsonSchema | undefined>()
490 return (key: string): JsonSchema.JsonSchema | undefined => {
491 if (!cache.has(key)) {
492 const definition = Object.hasOwn(components, key) ? components[key] : undefined
493 cache.set(key, Predicate.isObject(definition) ? convert(definition).schema : undefined)
494 }
495 return cache.get(key)
496 }
498​
499/** Keep free-form objects open and close compositions only after normalization. */
500const shouldClose = (js: JsonSchema.JsonSchema): boolean =>
501 js.type === "object" &&
502 Predicate.isObject(js.properties) && Object.keys(js.properties).length > 0 &&
503 js.additionalProperties === undefined &&
504 js.allOf === undefined && js.anyOf === undefined && js.oneOf === undefined
505​
506/** Closes an object, declaring any required keys it leaves undeclared so they stay valid. */
507const closeObject = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
508 const properties = js.properties as { readonly [x: string]: unknown }
509 const undeclared = isStringArray(js.required) ? js.required.filter((key) => !Object.hasOwn(properties, key)) : []
510 return {
511 ...js,
512 properties: { ...properties, ...Object.fromEntries(undeclared.map((key) => [key, {}])) },
513 additionalProperties: false
514 }
516​
517/**
518 * Layer providing the CodeGenerator service.
519 *
520 * @category layers
521 * @since 4.0.0
522 */
523export const layer: Layer.Layer<
524 CodeGenerator,
525 never,
526 OpenApiGenerator.OpenApiGenerator | FileSystem.FileSystem | Path_.Path
527> = Effect.gen(function*() {
528 const openApiGen = yield* OpenApiGenerator.OpenApiGenerator
529 const pathService = yield* Path_.Path
530​
531 const applyPatches = Effect.fn("applyPatches")(function*(
532 provider: DiscoveredProvider,
533 spec: Schema.Json
534 ) {
535 const patchInputs = provider.config.patchList
536 if (patchInputs.length === 0) {
537 return spec
538 }
539​
540 // Parse all patches, resolving file paths relative to the provider package
541 const parsedPatches = yield* Effect.forEach(patchInputs, (input) => {
542 // If it looks like a file path and is not absolute, resolve relative to package
543 const trimmed = input.trimStart()
544 const resolvedInput = !trimmed.startsWith("[") && !pathService.isAbsolute(trimmed)
545 ? pathService.join(provider.packagePath, trimmed)
546 : trimmed
547 return OpenApiPatch.parsePatchInput(resolvedInput).pipe(
548 Effect.map((patch) => ({ source: resolvedInput, patch }))
549 )
550 }).pipe(
551 Effect.mapError((cause) => new PatchError({ provider: provider.name, cause }))
552 )
553​
554 // Apply all patches to the spec
555 return yield* OpenApiPatch.applyPatches(parsedPatches, spec).pipe(
556 Effect.mapError((cause) => new PatchError({ provider: provider.name, cause }))
557 )
558 })
559​
560 const filterPaths = (spec: Schema.Json, prefixes: ReadonlyArray<string> | undefined): Schema.Json => {
561 const { paths } = spec as { readonly paths?: Record<string, Schema.Json> }
562 if (prefixes === undefined || paths === undefined) {
563 return spec
564 }
565 return {
566 ...spec as Record<string, Schema.Json>,
567 paths: Record.filter(paths, (_, path) => prefixes.some((prefix) => path.startsWith(prefix)))
568 }
569 }
570​
571 // Replaces an operation's 4xx and 5xx responses with one `4XX` response that uses the error
572 // envelope component they share. Statuses that narrow the envelope inline are dropped, so any
573 // envelope decodes and the status code decides the error.
574 const collapseOperationErrors = (operation: Schema.Json, method: string, path: string): Schema.Json => {
575 const { responses } = operation as { readonly responses?: Record<string, Schema.Json> }
576 if (responses === undefined) return operation
577 const errors = Object.keys(responses).filter((status) => /^[45]/.test(status))
578 if (errors.length === 0) return operation
579 const refs = new Set(
580 errors
581 .map((status) => (responses[status] as any)?.content?.["application/json"]?.schema?.$ref)
582 .filter(Predicate.isString)
583 )
584 if (refs.size !== 1) {
585 throw new Error(`Expected one shared error envelope for ${method.toUpperCase()} ${path}, found ${refs.size}`)
586 }
587 return {
588 ...operation as Record<string, Schema.Json>,
589 responses: {
590 ...Record.filter(responses, (_, status) => !errors.includes(status)),
591 "4XX": { description: "Error", content: { "application/json": { schema: { $ref: [...refs][0] } } } }
592 }
593 }
594 }
595​
596 const collapseErrorResponses = (spec: Schema.Json): Schema.Json => {
597 const { paths } = spec as { readonly paths?: Record<string, Record<string, Schema.Json>> }
598 if (paths === undefined) return spec
599 return {
600 ...spec as Record<string, Schema.Json>,
601 paths: Record.map(
602 paths,
603 (item, path) => Record.map(item, (operation, method) => collapseOperationErrors(operation, method, path))
604 )
605 }
606 }
607​
608 const generate = Effect.fn("generate")(function*(
609 provider: DiscoveredProvider,
610 spec: unknown
611 ) {
612 // Apply patches if any are configured, then keep only the included paths
613 let patchedSpec = filterPaths(yield* applyPatches(provider, spec as Schema.Json), provider.config.includePaths)
614 if (provider.config.collapseErrorResponses) {
615 patchedSpec = yield* Effect.try({
616 try: () => collapseErrorResponses(patchedSpec),
617 catch: (cause) => new GenerationError({ provider: provider.name, cause })
618 })
619 }
620​
621 const excludeAnnotations = provider.config.excludeAnnotationsList
622 const disableAdditionalProperties = provider.config.shouldDisableAdditionalProperties
623​
624 const exclude = excludeAnnotations ? new Set(excludeAnnotations) : undefined
625 const normalize = makeNormalizer(makeResolver(patchedSpec))
626 const onEnter = (js: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
627 const out = { ...normalize(normalizeOpenEnum(js)) }
628 if (exclude) {
629 for (const key of exclude) delete out[key]
630 }
631 return disableAdditionalProperties && shouldClose(out) ? closeObject(out) : out
632 }
633​
634 const warnings: Array<OpenApiGenerator.OpenApiGeneratorWarning> = []
635 const code = yield* openApiGen
636 .generate(patchedSpec as unknown as Parameters<typeof openApiGen.generate>[0], {
637 name: provider.config.clientName,
638 format: provider.config.isTypeOnly ? "httpclient-type-only" : "httpclient",
639 includeSchemas: provider.config.includeSchemas,
640 onEnter,
641 onWarning: (warning) => {
642 warnings.push(warning)
643 }
644 })
645 .pipe(
646 Effect.mapError((cause) => new GenerationError({ provider: provider.name, cause }))
647 )
648 yield* Effect.forEach(
649 warnings,
650 (warning) => Console.error(OpenApiGenerator.formatWarning(warning)),
651 { discard: true }
652 )
653 return code
654 })
655​
656 return { generate }
657}).pipe(Layer.effect(CodeGenerator))
658​
659/**
660 * Layer providing the CodeGenerator with schema transformer (default).
661 *
662 * @category layers
663 * @since 4.0.0
664 */
665export const layerSchema: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe(
666 Layer.provide(OpenApiGenerator.layerTransformerSchema)
668​
669/**
670 * Layer providing the CodeGenerator with TypeScript-only transformer.
671 *
672 * @category layers
673 * @since 4.0.0
674 */
675export const layerTypeScript: Layer.Layer<CodeGenerator, never, FileSystem.FileSystem | Path_.Path> = layer.pipe(
676 Layer.provide(OpenApiGenerator.layerTransformerTs)