1/**
2 * OpenAPI-to-HttpClient code generation for the OpenAPI generator.
3 *
4 * This module defines the `OpenApiTransformer` service used to render parsed
5 * OpenAPI operations into Effect `HttpClient` modules. It provides both
6 * schema-backed and type-only transformer implementations, including generated
7 * imports, public client interfaces, operation implementations, typed API
8 * errors, optional response handling, and helpers for server-sent events and
9 * binary response streams.
10 *
11 * @since 4.0.0
12 */
13import * as Context from "effect/Context"
14import * as Layer from "effect/Layer"
15import * as Predicate from "effect/Predicate"
16import type { ParsedOpenApi, ParsedOperation, ParsedOperationHttpClientResponses } from "./ParsedOperation.ts"
17import * as Utils from "./Utils.ts"
18
19/**
20 * Service used by the OpenAPI generator to render parsed operations as an
21 * Effect HttpClient module.
22 *
23 * **Details**
24 *
25 * A transformer owns the code-generation dialect for imports, public client
26 * types, and the implementation body. The generator swaps implementations to
27 * choose between schema-backed clients and type-only clients.
28 *
29 * @category services
30 * @since 4.0.0
31 */
32export class OpenApiTransformer extends Context.Service<
33 OpenApiTransformer,
34 {
35 readonly imports: (importName: string, parsed: ParsedOpenApi) => string
36 readonly toTypes: (importName: string, name: string, parsed: ParsedOpenApi) => string
37 readonly toImplementation: (importName: string, name: string, parsed: ParsedOpenApi) => string
38 }
39>()("OpenApiTransformer") {}
40
41interface ImportRequirements {
42 readonly eventStream: boolean
43 readonly eventStreamData: boolean
44 readonly eventStreamSchema: boolean
45 readonly octetStream: boolean
46}
47
48const computeImportRequirements = (operations: ReadonlyArray<ParsedOperation>): ImportRequirements => {
49 let eventStream = false
50 let eventStreamData = false
51 let eventStreamSchema = false
52 let octetStream = false
53 for (const op of operations) {
54 const responses = op.httpClientResponses
55 if (responses.sseSchema) {
56 eventStream = true
57 if (responses.sseSchemaMode === "event") {
58 eventStreamSchema = true
59 } else {
60 eventStreamData = true
61 }
62 }
63 if (responses.binarySuccessStatuses.size > 0) {
64 octetStream = true
65 }
66 }
67 return { eventStream, eventStreamData, eventStreamSchema, octetStream }
68}
69
70const requiresStreaming = (requirements: ImportRequirements): boolean =>
71 requirements.eventStream || requirements.octetStream
72
73const hasResponseVariants = (responses: ParsedOperationHttpClientResponses): boolean =>
74 responses.binarySuccessStatuses.size > 0 ||
75 responses.voidSuccessStatuses.size > 0 ||
76 responses.voidErrorStatuses.size > 0
77
78const normalizeSuccessStatus = (
79 responses: ParsedOperationHttpClientResponses,
80 status: string
81): string => {
82 const successCount = responses.successSchemas.size +
83 responses.binarySuccessStatuses.size +
84 responses.voidSuccessStatuses.size
85 return successCount === 1 && status.startsWith("2") ? "2xx" : status
86}
87
88const requestToImpl = (operation: ParsedOperation, pipeline: Array<string>, streaming = false): string => {
89 const request = `HttpClientRequest.${operation.method}`
90 const pipe = `.pipe(\n ${pipeline.join(",\n ")}\n )`
91 if (operation.pathIds.length === 0) {
92 return `${request}(${operation.pathTemplate})${pipe}`
93 }
94 const effect = `__makePathRequest(${request}, [${
95 operation.pathIds.join(", ")
96 }], () => ${operation.pathTemplate}).pipe(
97 Effect.${streaming ? "map" : "flatMap"}((request) => request${pipe})
98 )`
99 return streaming ? `Stream.unwrap(${effect})` : effect
100}
101
102/**
103 * Create the transformer used for schema-backed HttpClient output.
104 *
105 * **Details**
106 *
107 * Generated clients import Effect Schema values and use them at runtime to
108 * decode successful responses and typed API errors. Request parameters and
109 * payloads are typed against each schema's encoded representation.
110 *
111 * @category code generation
112 * @since 4.0.0
113 */
114export const makeTransformerSchema = () => {
115 const operationsToInterface = (
116 _importName: string,
117 name: string,
118 operations: ReadonlyArray<ParsedOperation>
119 ) => {
120 const methods: Array<string> = []
121 for (const op of operations) {
122 methods.push(operationToMethod(name, op))
123 if (op.httpClientResponses.sseSchema) {
124 methods.push(operationToSseMethod(name, op))
125 }
126 if (op.httpClientResponses.binarySuccessStatuses.size > 0) {
127 methods.push(operationToBinaryMethod(name, op))
128 }
129 }
130 return `export interface ${name} {
131 readonly httpClient: HttpClient.HttpClient
132 ${methods.join("\n ")}
133}
134
135${clientErrorSource(name)}`
136 }
137
138 const operationToMethod = (name: string, operation: ParsedOperation) => {
139 const responses = operation.httpClientResponses
140 const args: Array<string> = []
141 if (operation.pathIds.length > 0) {
142 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
143 }
144
145 const options: Array<string> = []
146 if (operation.params) {
147 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
148 const type = `typeof ${operation.params}.Encoded${operation.paramsOptional ? " | undefined" : ""}`
149 options.push(`${key}: ${type}`)
150 }
151 if (operation.payload) {
152 const key = `readonly payload`
153 const type = `typeof ${operation.payload}.Encoded`
154 options.push(`${key}: ${type}`)
155 }
156 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
157
158 const successTypes = new Set(Array.from(responses.successSchemas.values(), (schema) => `typeof ${schema}.Type`))
159 if (responses.binarySuccessStatuses.size > 0) {
160 successTypes.add("Uint8Array")
161 }
162 if (responses.voidSuccessStatuses.size > 0) {
163 successTypes.add("void")
164 }
165 const success = successTypes.size > 0 ? Array.from(successTypes).join(" | ") : "void"
166 const errors = ["HttpClientError.HttpClientError", "SchemaError"]
167 if (responses.errorSchemas.size > 0) {
168 Utils.spreadElementsInto(
169 Array.from(responses.errorSchemas.values()).map(
170 (schema) => `${name}Error<"${schema}", typeof ${schema}.Type>`
171 ),
172 errors
173 )
174 }
175 for (const status of responses.voidErrorStatuses) {
176 errors.push(`${name}Error<"${status}", undefined>`)
177 }
178
179 const jsdoc = Utils.toComment(operation.description)
180 const methodKey = `readonly "${operation.id}"`
181 return `${jsdoc}${methodKey}: ${operationSignatures(args, options, Boolean(hasOptions), success, errors)}`
182 }
183
184 const operationToSseMethod = (_name: string, operation: ParsedOperation) => {
185 const responses = operation.httpClientResponses
186 const args: Array<string> = []
187 if (operation.pathIds.length > 0) {
188 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
189 }
190
191 const options: Array<string> = []
192 if (operation.params) {
193 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
194 const type = `typeof ${operation.params}.Encoded${operation.paramsOptional ? " | undefined" : ""}`
195 options.push(`${key}: ${type}`)
196 }
197 if (operation.payload) {
198 options.push(`readonly payload: typeof ${operation.payload}.Encoded`)
199 }
200
201 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
202 if (hasOptions) {
203 args.push(`options: { ${options.join("; ")} }`)
204 } else if (options.length > 0) {
205 args.push(`options: { ${options.join("; ")} } | undefined`)
206 }
207
208 const jsdoc = Utils.toComment(operation.description)
209 const methodKey = `readonly "${operation.id}Sse"`
210 const parameters = args.join(", ")
211 const value = responses.sseSchemaMode === "event"
212 ? `typeof ${responses.sseSchema}.Type`
213 : `{ readonly event: string; readonly id: string | undefined; readonly data: typeof ${responses.sseSchema}.Type }`
214 const returnType =
215 `Stream.Stream<${value}, HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError, typeof ${responses.sseSchema}.DecodingServices>`
216 return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}`
217 }
218
219 const operationToBinaryMethod = (_name: string, operation: ParsedOperation) => {
220 const args: Array<string> = []
221 if (operation.pathIds.length > 0) {
222 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
223 }
224
225 const options: Array<string> = []
226 if (operation.params) {
227 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
228 const type = `typeof ${operation.params}.Encoded${operation.paramsOptional ? " | undefined" : ""}`
229 options.push(`${key}: ${type}`)
230 }
231 if (operation.payload) {
232 options.push(`readonly payload: typeof ${operation.payload}.Encoded`)
233 }
234
235 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
236 if (hasOptions) {
237 args.push(`options: { ${options.join("; ")} }`)
238 } else if (options.length > 0) {
239 args.push(`options: { ${options.join("; ")} } | undefined`)
240 }
241
242 const jsdoc = Utils.toComment(operation.description)
243 const methodKey = `readonly "${operation.id}Stream"`
244 const parameters = args.join(", ")
245 const returnType = `Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`
246 return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}`
247 }
248
249 const operationsToImpl = (
250 importName: string,
251 name: string,
252 operations: ReadonlyArray<ParsedOperation>
253 ) => {
254 const requirements = computeImportRequirements(operations)
255 const implMethods: Array<string> = []
256 for (const op of operations) {
257 implMethods.push(operationToImpl(name, op))
258 if (op.httpClientResponses.sseSchema) {
259 implMethods.push(operationToSseImpl(importName, op))
260 }
261 if (op.httpClientResponses.binarySuccessStatuses.size > 0) {
262 implMethods.push(operationToBinaryImpl(op))
263 }
264 }
265
266 const helpers: Array<string> = [commonSource]
267 if (operations.some((operation) => operation.pathIds.length > 0)) {
268 helpers.push(pathRequestSource)
269 }
270 if (requiresStreaming(requirements)) {
271 helpers.push(executeStreamRequestSource)
272 }
273 if (requirements.eventStreamData) {
274 helpers.push(sseRequestSource(importName))
275 }
276 if (requirements.eventStreamSchema) {
277 helpers.push(sseEventRequestSource)
278 }
279 if (requirements.octetStream) {
280 helpers.push(decodeBinarySource)
281 helpers.push(binaryRequestSource)
282 }
283 if (operations.some((operation) => operation.httpClientResponses.voidErrorStatuses.size > 0)) {
284 helpers.push(decodeVoidErrorSource(name))
285 }
286 return `export interface OperationConfig {
287 /**
288 * Whether or not the response should be included in the value returned from
289 * an operation.
290 *
291 * If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
292 * where \`A\` is the success type of the operation.
293 *
294 * If set to \`false\`, only the success type of the operation will be returned.
295 */
296 readonly includeResponse?: boolean | undefined
297}
298
299/**
300 * A utility type which optionally includes the response in the return result
301 * of an operation based upon the value of the \`includeResponse\` configuration
302 * option.
303 */
304export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
305 readonly includeResponse: true
306} ? [A, HttpClientResponse.HttpClientResponse]
307 : Config extends { readonly includeResponse?: false | undefined } | undefined ? A
308 : A | [A, HttpClientResponse.HttpClientResponse]
309
310export const make = (
311 httpClient: HttpClient.HttpClient,
312 options: {
313 readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
314 } = {}
315): ${name} => {
316 ${helpers.join("\n ")}
317 const decodeSuccess =
318 <Schema extends ${importName}.Constraint>(schema: Schema) =>
319 (response: HttpClientResponse.HttpClientResponse) =>
320 HttpClientResponse.schemaBodyJson(schema)(response)
321 const decodeError =
322 <const Tag extends string, Schema extends ${importName}.Constraint>(tag: Tag, schema: Schema) =>
323 (response: HttpClientResponse.HttpClientResponse) =>
324 Effect.flatMap(
325 HttpClientResponse.schemaBodyJson(schema)(response),
326 (cause) => Effect.fail(${name}Error(tag, cause, response)),
327 )
328 return {
329 httpClient,
330 ${implMethods.join(",\n ")}
331 }
332}`
333 }
334
335 const operationToImpl = (name: string, operation: ParsedOperation) => {
336 const responses = operation.httpClientResponses
337 const params = [
338 ...operation.pathIds,
339 `options: Parameters<${name}["${operation.id}"]>[${operation.pathIds.length}]`
340 ].join(", ")
341
342 const pipeline: Array<string> = []
343
344 if (operation.params) {
345 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
346
347 if (operation.urlParams.length > 0) {
348 const props = operation.urlParams.map(
349 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
350 )
351 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
352 }
353 if (operation.headers.length > 0) {
354 const props = operation.headers.map(
355 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
356 )
357 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
358 }
359 }
360
361 const payloadVarName = "options.payload"
362 if (operation.payloadFormData) {
363 pipeline.push(`HttpClientRequest.bodyFormDataRecord(${payloadVarName} as any)`)
364 } else if (operation.payloadFormUrlEncoded) {
365 pipeline.push(`HttpClientRequest.bodyUrlParams(${payloadVarName} as any)`)
366 } else if (operation.payload) {
367 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(${payloadVarName})`)
368 }
369
370 const decodes: Array<string> = []
371 responses.successSchemas.forEach((schema, status) => {
372 decodes.push(`"${normalizeSuccessStatus(responses, status)}": decodeSuccess(${schema})`)
373 })
374 responses.binarySuccessStatuses.forEach((status) => {
375 decodes.push(`"${normalizeSuccessStatus(responses, status)}": decodeBinary`)
376 })
377 responses.errorSchemas.forEach((schema, status) => {
378 decodes.push(`"${status}": decodeError("${schema}", ${schema})`)
379 })
380 responses.voidSuccessStatuses.forEach((status) => {
381 decodes.push(`"${status}": () => Effect.void`)
382 })
383 responses.voidErrorStatuses.forEach((status) => {
384 decodes.push(`"${status}": decodeVoidError("${status}")`)
385 })
386 decodes.push(`orElse: unexpectedStatus`)
387
388 const configAccessor = resolveConfigAccessor(operation, "options", "config")
389 pipeline.push(`withResponse(${configAccessor})(HttpClientResponse.matchStatus({
390 ${decodes.join(",\n ")}
391 }))`)
392
393 return (
394 `"${operation.id}": (${params}) => ` +
395 requestToImpl(operation, pipeline)
396 )
397 }
398
399 const operationToSseImpl = (_importName: string, operation: ParsedOperation) => {
400 const responses = operation.httpClientResponses
401 const args: Array<string> = [...operation.pathIds]
402 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
403 if (hasOptions || operation.params || operation.payload) {
404 args.push("options")
405 }
406 const params = args.join(", ")
407
408 const pipeline: Array<string> = []
409
410 if (operation.params) {
411 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
412 if (operation.urlParams.length > 0) {
413 const props = operation.urlParams.map(
414 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
415 )
416 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
417 }
418 if (operation.headers.length > 0) {
419 const props = operation.headers.map(
420 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
421 )
422 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
423 }
424 }
425
426 if (operation.payloadFormData) {
427 pipeline.push(`HttpClientRequest.bodyFormDataRecord(options.payload as any)`)
428 } else if (operation.payloadFormUrlEncoded) {
429 pipeline.push(`HttpClientRequest.bodyUrlParams(options.payload as any)`)
430 } else if (operation.payload) {
431 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(options.payload)`)
432 }
433
434 pipeline.push(`${responses.sseSchemaMode === "event" ? "sseEventRequest" : "sseRequest"}(${responses.sseSchema})`)
435
436 return (
437 `"${operation.id}Sse": (${params}) => ` +
438 requestToImpl(operation, pipeline, true)
439 )
440 }
441
442 const operationToBinaryImpl = (operation: ParsedOperation) => {
443 const args: Array<string> = [...operation.pathIds]
444 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
445 if (hasOptions || operation.params || operation.payload) {
446 args.push("options")
447 }
448 const params = args.join(", ")
449
450 const pipeline: Array<string> = []
451
452 if (operation.params) {
453 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
454 if (operation.urlParams.length > 0) {
455 const props = operation.urlParams.map(
456 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
457 )
458 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
459 }
460 if (operation.headers.length > 0) {
461 const props = operation.headers.map(
462 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
463 )
464 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
465 }
466 }
467
468 if (operation.payloadFormData) {
469 pipeline.push(`HttpClientRequest.bodyFormDataRecord(options.payload as any)`)
470 } else if (operation.payloadFormUrlEncoded) {
471 pipeline.push(`HttpClientRequest.bodyUrlParams(options.payload as any)`)
472 } else if (operation.payload) {
473 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(options.payload)`)
474 }
475
476 pipeline.push(`binaryRequest`)
477
478 return (
479 `"${operation.id}Stream": (${params}) => ` +
480 requestToImpl(operation, pipeline, true)
481 )
482 }
483
484 return OpenApiTransformer.of({
485 imports: (importName, parsed) => {
486 const operations = parsed.operations
487 const requirements = computeImportRequirements(operations)
488 const imports = [
489 `import * as Data from "effect/Data"`,
490 `import * as Effect from "effect/Effect"`,
491 `import type { SchemaError } from "effect/Schema"`,
492 `import * as ${importName} from "effect/Schema"`
493 ]
494 if (requiresStreaming(requirements)) {
495 imports.push(`import * as Stream from "effect/Stream"`)
496 }
497 if (requirements.eventStream) {
498 imports.push(`import * as Sse from "effect/encoding/Sse"`)
499 }
500 // HttpClient needs to be a value import when streaming is used (for filterStatusOk)
501 if (requiresStreaming(requirements)) {
502 imports.push(`import * as HttpClient from "effect/http/HttpClient"`)
503 } else {
504 imports.push(`import type * as HttpClient from "effect/http/HttpClient"`)
505 }
506 imports.push(
507 `import * as HttpClientError from "effect/http/HttpClientError"`,
508 `import * as HttpClientRequest from "effect/http/HttpClientRequest"`,
509 `import * as HttpClientResponse from "effect/http/HttpClientResponse"`
510 )
511 return imports.join("\n")
512 },
513 toTypes: (importName, name, parsed) => operationsToInterface(importName, name, parsed.operations),
514 toImplementation: (importName, name, parsed) => operationsToImpl(importName, name, parsed.operations)
515 })
516}
517
518/**
519 * Layer that provides the schema-backed OpenApiTransformer service.
520 *
521 * **When to use**
522 *
523 * Use when you use this layer when generated HttpClient code should perform runtime response
524 * decoding with generated Effect Schema values.
525 *
526 * @category layers
527 * @since 4.0.0
528 */
529export const layerTransformerSchema = Layer.sync(
530 OpenApiTransformer,
531 makeTransformerSchema
532)
533
534/**
535 * Create the transformer used for type-only HttpClient output.
536 *
537 * **Details**
538 *
539 * Generated clients reference emitted TypeScript types directly and do not
540 * import schema decoders for JSON response bodies. Responses are typed as the
541 * declared operation result while preserving generated error and stream method
542 * shapes.
543 *
544 * @category code generation
545 * @since 4.0.0
546 */
547export const makeTransformerTs = () => {
548 const operationsToInterface = (
549 _importName: string,
550 name: string,
551 operations: ReadonlyArray<ParsedOperation>
552 ) => {
553 const methods: Array<string> = []
554 for (const op of operations) {
555 methods.push(operationToMethod(name, op))
556 if (op.httpClientResponses.sseSchema) {
557 methods.push(operationToSseMethod(op))
558 }
559 if (op.httpClientResponses.binarySuccessStatuses.size > 0) {
560 methods.push(operationToBinaryMethod(op))
561 }
562 }
563 return `export interface ${name} {
564 readonly httpClient: HttpClient.HttpClient
565 ${methods.join("\n ")}
566}
567
568${clientErrorSource(name)}`
569 }
570
571 const operationToMethod = (name: string, operation: ParsedOperation) => {
572 const responses = operation.httpClientResponses
573 const args: Array<string> = []
574 if (operation.pathIds.length > 0) {
575 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
576 }
577
578 const options: Array<string> = []
579 if (operation.params) {
580 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
581 const type = `${operation.params}${operation.paramsOptional ? " | undefined" : ""}`
582 options.push(`${key}: ${type}`)
583 }
584 if (operation.payload) {
585 options.push(`readonly payload: ${operation.payload}`)
586 }
587 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
588
589 const successTypes = new Set(responses.successSchemas.values())
590 if (responses.binarySuccessStatuses.size > 0) {
591 successTypes.add("Uint8Array")
592 }
593 if (responses.voidSuccessStatuses.size > 0) {
594 successTypes.add("void")
595 }
596 const success = successTypes.size > 0 ? Array.from(successTypes).join(" | ") : "void"
597
598 const errors = ["HttpClientError.HttpClientError"]
599 if (responses.errorSchemas.size > 0) {
600 for (const schema of responses.errorSchemas.values()) {
601 errors.push(`${name}Error<"${schema}", ${schema}>`)
602 }
603 }
604 for (const status of responses.voidErrorStatuses) {
605 errors.push(`${name}Error<"${status}", undefined>`)
606 }
607
608 const jsdoc = Utils.toComment(operation.description)
609 const methodKey = `readonly "${operation.id}"`
610 return `${jsdoc}${methodKey}: ${operationSignatures(args, options, Boolean(hasOptions), success, errors)}`
611 }
612
613 const operationToSseMethod = (operation: ParsedOperation) => {
614 const responses = operation.httpClientResponses
615 const args: Array<string> = []
616 if (operation.pathIds.length > 0) {
617 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
618 }
619
620 const options: Array<string> = []
621 if (operation.params) {
622 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
623 const type = `${operation.params}${operation.paramsOptional ? " | undefined" : ""}`
624 options.push(`${key}: ${type}`)
625 }
626 if (operation.payload) {
627 options.push(`readonly payload: ${operation.payload}`)
628 }
629
630 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
631 if (hasOptions) {
632 args.push(`options: { ${options.join("; ")} }`)
633 } else if (options.length > 0) {
634 args.push(`options: { ${options.join("; ")} } | undefined`)
635 }
636
637 const jsdoc = Utils.toComment(operation.description)
638 const methodKey = `readonly "${operation.id}Sse"`
639 const parameters = args.join(", ")
640 const returnType = `Stream.Stream<${responses.sseSchema}, HttpClientError.HttpClientError>`
641 return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}`
642 }
643
644 const operationToBinaryMethod = (operation: ParsedOperation) => {
645 const args: Array<string> = []
646 if (operation.pathIds.length > 0) {
647 Utils.spreadElementsInto(operation.pathIds.map((id) => `${id}: string`), args)
648 }
649
650 const options: Array<string> = []
651 if (operation.params) {
652 const key = `readonly params${operation.paramsOptional ? "?" : ""}`
653 const type = `${operation.params}${operation.paramsOptional ? " | undefined" : ""}`
654 options.push(`${key}: ${type}`)
655 }
656 if (operation.payload) {
657 options.push(`readonly payload: ${operation.payload}`)
658 }
659
660 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
661 if (hasOptions) {
662 args.push(`options: { ${options.join("; ")} }`)
663 } else if (options.length > 0) {
664 args.push(`options: { ${options.join("; ")} } | undefined`)
665 }
666
667 const jsdoc = Utils.toComment(operation.description)
668 const methodKey = `readonly "${operation.id}Stream"`
669 const parameters = args.join(", ")
670 const returnType = `Stream.Stream<Uint8Array, HttpClientError.HttpClientError>`
671 return `${jsdoc}${methodKey}: (${parameters}) => ${returnType}`
672 }
673
674 const operationsToImpl = (
675 _importName: string,
676 name: string,
677 operations: ReadonlyArray<ParsedOperation>
678 ) => {
679 const requirements = computeImportRequirements(operations)
680 const implMethods: Array<string> = []
681 for (const op of operations) {
682 implMethods.push(operationToImpl(name, op))
683 if (op.httpClientResponses.sseSchema) {
684 implMethods.push(operationToSseImpl(op))
685 }
686 if (op.httpClientResponses.binarySuccessStatuses.size > 0) {
687 implMethods.push(operationToBinaryImpl(op))
688 }
689 }
690
691 const helpers: Array<string> = [commonSource]
692 if (operations.some((operation) => operation.pathIds.length > 0)) {
693 helpers.push(pathRequestSource)
694 }
695 if (requiresStreaming(requirements)) {
696 helpers.push(executeStreamRequestSource)
697 }
698 if (requirements.eventStream) {
699 helpers.push(sseRequestSourceTs)
700 }
701 const withResponseVariants = operations.some((op) => hasResponseVariants(op.httpClientResponses))
702 if (withResponseVariants) {
703 helpers.push(decodeBinarySource)
704 helpers.push(decodeVoidErrorSource(name))
705 }
706 if (requirements.octetStream) {
707 helpers.push(binaryRequestSourceTs)
708 }
709
710 return `export interface OperationConfig {
711 /**
712 * Whether or not the response should be included in the value returned from
713 * an operation.
714 *
715 * If set to \`true\`, a tuple of \`[A, HttpClientResponse]\` will be returned,
716 * where \`A\` is the success type of the operation.
717 *
718 * If set to \`false\`, only the success type of the operation will be returned.
719 */
720 readonly includeResponse?: boolean | undefined
721}
722
723/**
724 * A utility type which optionally includes the response in the return result
725 * of an operation based upon the value of the \`includeResponse\` configuration
726 * option.
727 */
728export type WithOptionalResponse<A, Config extends OperationConfig | undefined> = Config extends {
729 readonly includeResponse: true
730} ? [A, HttpClientResponse.HttpClientResponse]
731 : Config extends { readonly includeResponse?: false | undefined } | undefined ? A
732 : A | [A, HttpClientResponse.HttpClientResponse]
733
734export const make = (
735 httpClient: HttpClient.HttpClient,
736 options: {
737 readonly transformClient?: ((client: HttpClient.HttpClient) => Effect.Effect<HttpClient.HttpClient>) | undefined
738 } = {}
739): ${name} => {
740 ${helpers.join("\n ")}
741 const decodeSuccess = <A>(response: HttpClientResponse.HttpClientResponse) =>
742 response.json as Effect.Effect<A, HttpClientError.HttpClientError>
743 const decodeVoid = (_response: HttpClientResponse.HttpClientResponse) =>
744 Effect.void
745 const decodeError =
746 <Tag extends string, E>(tag: Tag) =>
747 (
748 response: HttpClientResponse.HttpClientResponse,
749 ): Effect.Effect<
750 never,
751 ${name}Error<Tag, E> | HttpClientError.HttpClientError
752 > =>
753 Effect.flatMap(
754 response.json as Effect.Effect<E, HttpClientError.HttpClientError>,
755 (cause) => Effect.fail(${name}Error(tag, cause, response)),
756 )
757 ${onRequestSource(withResponseVariants)}
758 return {
759 httpClient,
760 ${implMethods.join(",\n ")}
761 }
762}`
763 }
764
765 const operationToImpl = (name: string, operation: ParsedOperation) => {
766 const responses = operation.httpClientResponses
767 const params = [
768 ...operation.pathIds,
769 `options: Parameters<${name}["${operation.id}"]>[${operation.pathIds.length}]`
770 ].join(", ")
771
772 const pipeline: Array<string> = []
773
774 if (operation.params) {
775 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
776
777 if (operation.urlParams.length > 0) {
778 const props = operation.urlParams.map(
779 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
780 )
781 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
782 }
783 if (operation.headers.length > 0) {
784 const props = operation.headers.map(
785 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
786 )
787 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
788 }
789 }
790
791 const payloadAccessor = "options.payload"
792 if (operation.payloadFormData) {
793 pipeline.push(`HttpClientRequest.bodyFormDataRecord(${payloadAccessor} as any)`)
794 } else if (operation.payloadFormUrlEncoded) {
795 pipeline.push(`HttpClientRequest.bodyUrlParams(${payloadAccessor} as any)`)
796 } else if (operation.payload) {
797 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(${payloadAccessor})`)
798 }
799
800 const successCodes = Array.from(
801 responses.successSchemas.keys(),
802 (status) => JSON.stringify(normalizeSuccessStatus(responses, status))
803 ).join(", ")
804 const errorCodes = responses.errorSchemas.size > 0 &&
805 Object.fromEntries(responses.errorSchemas.entries())
806 const configAccessor = resolveConfigAccessor(operation, "options", "config")
807 const requestArgs = [`[${successCodes}]`]
808 if (hasResponseVariants(responses)) {
809 const responseCodes = {
810 binary: Array.from(
811 responses.binarySuccessStatuses,
812 (status) => normalizeSuccessStatus(responses, status)
813 ),
814 voidSuccess: Array.from(responses.voidSuccessStatuses),
815 voidError: Array.from(responses.voidErrorStatuses)
816 }
817 requestArgs.push(errorCodes ? JSON.stringify(errorCodes) : "undefined", JSON.stringify(responseCodes))
818 } else if (errorCodes) {
819 requestArgs.push(JSON.stringify(errorCodes))
820 }
821 pipeline.push(`onRequest(${configAccessor})(${requestArgs.join(", ")})`)
822
823 return (
824 `"${operation.id}": (${params}) => ` +
825 requestToImpl(operation, pipeline)
826 )
827 }
828
829 const operationToSseImpl = (operation: ParsedOperation) => {
830 const args: Array<string> = [...operation.pathIds]
831 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
832 if (hasOptions || operation.params || operation.payload) {
833 args.push("options")
834 }
835 const params = args.join(", ")
836
837 const pipeline: Array<string> = []
838
839 if (operation.params) {
840 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
841 if (operation.urlParams.length > 0) {
842 const props = operation.urlParams.map(
843 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
844 )
845 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
846 }
847 if (operation.headers.length > 0) {
848 const props = operation.headers.map(
849 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
850 )
851 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
852 }
853 }
854
855 if (operation.payloadFormData) {
856 pipeline.push(`HttpClientRequest.bodyFormDataRecord(options.payload as any)`)
857 } else if (operation.payloadFormUrlEncoded) {
858 pipeline.push(`HttpClientRequest.bodyUrlParams(options.payload as any)`)
859 } else if (operation.payload) {
860 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(options.payload)`)
861 }
862
863 pipeline.push(`sseRequest`)
864
865 return (
866 `"${operation.id}Sse": (${params}) => ` +
867 requestToImpl(operation, pipeline, true)
868 )
869 }
870
871 const operationToBinaryImpl = (operation: ParsedOperation) => {
872 const args: Array<string> = [...operation.pathIds]
873 const hasOptions = (operation.params && !operation.paramsOptional) || operation.payload
874 if (hasOptions || operation.params || operation.payload) {
875 args.push("options")
876 }
877 const params = args.join(", ")
878
879 const pipeline: Array<string> = []
880
881 if (operation.params) {
882 const paramsAccessor = resolveParamsAccessor(operation, "options", "params")
883 if (operation.urlParams.length > 0) {
884 const props = operation.urlParams.map(
885 (param) => `"${param}": ${paramsAccessor}["${param}"] as any`
886 )
887 pipeline.push(`HttpClientRequest.setUrlParams({ ${props.join(", ")} })`)
888 }
889 if (operation.headers.length > 0) {
890 const props = operation.headers.map(
891 (param) => `"${param}": ${paramsAccessor}["${param}"] ?? undefined`
892 )
893 pipeline.push(`HttpClientRequest.setHeaders({ ${props.join(", ")} })`)
894 }
895 }
896
897 if (operation.payloadFormData) {
898 pipeline.push(`HttpClientRequest.bodyFormDataRecord(options.payload as any)`)
899 } else if (operation.payloadFormUrlEncoded) {
900 pipeline.push(`HttpClientRequest.bodyUrlParams(options.payload as any)`)
901 } else if (operation.payload) {
902 pipeline.push(`HttpClientRequest.bodyJsonUnsafe(options.payload)`)
903 }
904
905 pipeline.push(`binaryRequest`)
906
907 return (
908 `"${operation.id}Stream": (${params}) => ` +
909 requestToImpl(operation, pipeline, true)
910 )
911 }
912
913 return OpenApiTransformer.of({
914 imports: (_importName, parsed) => {
915 const operations = parsed.operations
916 const requirements = computeImportRequirements(operations)
917 const imports = [
918 `import * as Data from "effect/Data"`,
919 `import * as Effect from "effect/Effect"`
920 ]
921 if (requiresStreaming(requirements)) {
922 imports.push(
923 `import * as Stream from "effect/Stream"`,
924 `import * as HttpClient from "effect/http/HttpClient"`
925 )
926 } else {
927 imports.push(`import type * as HttpClient from "effect/http/HttpClient"`)
928 }
929 imports.push(
930 `import * as HttpClientError from "effect/http/HttpClientError"`,
931 `import * as HttpClientRequest from "effect/http/HttpClientRequest"`,
932 `import * as HttpClientResponse from "effect/http/HttpClientResponse"`
933 )
934 return imports.join("\n")
935 },
936 toTypes: (importName, name, parsed) => operationsToInterface(importName, name, parsed.operations),
937 toImplementation: (importName, name, parsed) => operationsToImpl(importName, name, parsed.operations)
938 })
939}
940
941/**
942 * Layer that provides the type-only OpenApiTransformer service.
943 *
944 * **When to use**
945 *
946 * Use when you use this layer for the `httpclient-type-only` generator format, where the
947 * generated client relies on TypeScript types instead of runtime Schema
948 * decoding.
949 *
950 * @category layers
951 * @since 4.0.0
952 */
953export const layerTransformerTs = Layer.sync(
954 OpenApiTransformer,
955 makeTransformerTs
956)
957
958const pathRequestSource = `const __encodePathParam = encodeURIComponent
959 const __makePathRequest = (
960 method: (url: string) => HttpClientRequest.HttpClientRequest,
961 parameters: ReadonlyArray<string>,
962 getPath: () => string,
963 ) => Effect.suspend(() => {
964 const fail = (description: string, cause?: unknown) => Effect.fail(
965 new HttpClientError.HttpClientError({
966 reason: new HttpClientError.InvalidUrlError({
967 request: method(""),
968 cause,
969 description,
970 }),
971 }),
972 )
973 if (parameters.some((value) => value === "" || /^(?:\\.|%2e){1,2}$/i.test(value))) {
974 return fail("Path parameters must be non-empty and cannot be dot segments")
975 }
976 let path: string
977 try {
978 path = getPath()
979 } catch (cause) {
980 return fail("Failed to encode path parameter", cause)
981 }
982 if (path.split("/").some((segment) => /^(?:\\.|%2e){1,2}$/i.test(segment))) {
983 return fail("Request paths cannot contain dot segments")
984 }
985 return Effect.succeed(method(path))
986 })`
987
988const commonSource = `const unexpectedStatus = (response: HttpClientResponse.HttpClientResponse) =>
989 Effect.flatMap(
990 Effect.orElseSucceed(response.json, () => "Unexpected status code"),
991 (description) =>
992 Effect.fail(
993 new HttpClientError.HttpClientError({
994 reason: new HttpClientError.StatusCodeError({
995 request: response.request,
996 response,
997 description: typeof description === "string" ? description : JSON.stringify(description),
998 }),
999 }),
1000 ),
1001 )
1002 const withResponse = <Config extends OperationConfig>(config: Config | undefined) => (
1003 f: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<any, any>,
1004 ): (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<any, any> => {
1005 const withOptionalResponse = (
1006 config?.includeResponse
1007 ? (response: HttpClientResponse.HttpClientResponse) => Effect.map(f(response), (a) => [a, response])
1008 : (response: HttpClientResponse.HttpClientResponse) => f(response)
1009 ) as any
1010 return options?.transformClient
1011 ? (request) =>
1012 Effect.flatMap(
1013 Effect.flatMap(options.transformClient!(httpClient), (client) => client.execute(request)),
1014 withOptionalResponse
1015 )
1016 : (request) => Effect.flatMap(httpClient.execute(request), withOptionalResponse)
1017 }`
1018
1019const decodeBinarySource = `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>
1020 Effect.map(response.arrayBuffer, (buffer) => new Uint8Array(buffer))`
1021
1022const decodeVoidErrorSource = (name: string) =>
1023 `const decodeVoidError = <const Tag extends string>(tag: Tag) =>
1024 (response: HttpClientResponse.HttpClientResponse) =>
1025 Effect.fail(${name}Error(tag, undefined, response))`
1026
1027const onRequestSource = (withResponseVariants: boolean) => {
1028 const responseCodesParam = withResponseVariants
1029 ? `
1030 responseCodes: {
1031 readonly binary: ReadonlyArray<string>
1032 readonly voidSuccess: ReadonlyArray<string>
1033 readonly voidError: ReadonlyArray<string>
1034 } = { binary: [], voidSuccess: [], voidError: [] },`
1035 : ""
1036 const responseCodesCases = withResponseVariants
1037 ? `
1038 for (const code of responseCodes.binary) {
1039 cases[code] = decodeBinary
1040 }
1041 for (const code of responseCodes.voidSuccess) {
1042 cases[code] = decodeVoid
1043 }
1044 for (const code of responseCodes.voidError) {
1045 cases[code] = decodeVoidError(code)
1046 }`
1047 : ""
1048 const voidFallbackCondition = withResponseVariants
1049 ? "successCodes.length === 0 && responseCodes.binary.length === 0 && responseCodes.voidSuccess.length === 0"
1050 : "successCodes.length === 0"
1051 return `const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
1052 successCodes: ReadonlyArray<string>,
1053 errorCodes?: Record<string, string>,${responseCodesParam}
1054 ) => {
1055 const cases: any = { orElse: unexpectedStatus }
1056 for (const code of successCodes) {
1057 cases[code] = decodeSuccess
1058 }
1059 if (errorCodes) {
1060 for (const [code, tag] of Object.entries(errorCodes)) {
1061 cases[code] = decodeError(tag)
1062 }
1063 }${responseCodesCases}
1064 if (${voidFallbackCondition}) {
1065 cases["2xx"] = decodeVoid
1066 }
1067 return withResponse(config)(HttpClientResponse.matchStatus(cases) as any)
1068 }`
1069}
1070
1071const executeStreamRequestSource = `const executeStreamRequest = (request: HttpClientRequest.HttpClientRequest) =>
1072 Effect.suspend(() =>
1073 options.transformClient
1074 ? Effect.flatMap(options.transformClient(httpClient), (client) => HttpClient.filterStatusOk(client).execute(request))
1075 : HttpClient.filterStatusOk(httpClient).execute(request)
1076 )`
1077
1078const sseRequestSource = (_importName: string) =>
1079 `const sseRequest = <
1080 Type,
1081 DecodingServices
1082 >(
1083 schema: Schema.ConstraintDecoder<Type, DecodingServices>
1084 ) =>
1085 (
1086 request: HttpClientRequest.HttpClientRequest
1087 ): Stream.Stream<
1088 { readonly event: string; readonly id: string | undefined; readonly data: Type },
1089 HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError,
1090 DecodingServices
1091 > =>
1092 executeStreamRequest(request).pipe(
1093 Effect.map((response) => response.stream),
1094 Stream.unwrap,
1095 Stream.decodeText(),
1096 Stream.pipeThroughChannel(Sse.decodeDataSchema(schema))
1097 )`
1098
1099const sseEventRequestSource = `const sseEventRequest = <S extends Sse.EventCodec>(schema: S) =>
1100 (
1101 request: HttpClientRequest.HttpClientRequest
1102 ): Stream.Stream<
1103 S["Type"],
1104 HttpClientError.HttpClientError | SchemaError | Sse.Retry | Sse.SseError,
1105 S["DecodingServices"]
1106 > =>
1107 executeStreamRequest(request).pipe(
1108 Effect.map((response) => response.stream),
1109 Stream.unwrap,
1110 Stream.decodeText(),
1111 Stream.pipeThroughChannel(Sse.decodeSchema(schema))
1112 )`
1113
1114const binaryRequestSource =
1115 `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream<Uint8Array, HttpClientError.HttpClientError> =>
1116 executeStreamRequest(request).pipe(
1117 Effect.map((response) => response.stream),
1118 Stream.unwrap
1119 )`
1120
1121// Type-only mode helpers (no schema decoding)
1122const sseRequestSourceTs =
1123 `const sseRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream<unknown, HttpClientError.HttpClientError> =>
1124 executeStreamRequest(request).pipe(
1125 Effect.map((response) => response.stream),
1126 Stream.unwrap,
1127 Stream.decodeText(),
1128 Stream.splitLines,
1129 Stream.filter((line) => line.startsWith("data: ")),
1130 Stream.map((line) => JSON.parse(line.slice(6)))
1131 )`
1132
1133const binaryRequestSourceTs =
1134 `const binaryRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream<Uint8Array, HttpClientError.HttpClientError> =>
1135 executeStreamRequest(request).pipe(
1136 Effect.map((response) => response.stream),
1137 Stream.unwrap
1138 )`
1139
1140const clientErrorSource = (
1141 name: string
1142) =>
1143 `export interface ${name}Error<Tag extends string, E> {
1144 readonly _tag: Tag
1145 readonly request: HttpClientRequest.HttpClientRequest
1146 readonly response: HttpClientResponse.HttpClientResponse
1147 readonly cause: E
1148}
1149
1150class ${name}ErrorImpl extends Data.Error<{
1151 _tag: string
1152 cause: any
1153 request: HttpClientRequest.HttpClientRequest
1154 response: HttpClientResponse.HttpClientResponse
1155}> {}
1156
1157export const ${name}Error = <Tag extends string, E>(
1158 tag: Tag,
1159 cause: E,
1160 response: HttpClientResponse.HttpClientResponse,
1161): ${name}Error<Tag, E> =>
1162 new ${name}ErrorImpl({
1163 _tag: tag,
1164 cause,
1165 response,
1166 request: response.request,
1167 }) as any`
1168
1169const operationSignatures = (
1170 args: ReadonlyArray<string>,
1171 options: ReadonlyArray<string>,
1172 optionsRequired: boolean,
1173 success: string,
1174 errors: ReadonlyArray<string>
1175): string => {
1176 const signature = (configOptional: boolean) => {
1177 const config = configOptional ? "Config | undefined" : "Config"
1178 const fields = [...options, `readonly config${configOptional ? "?" : ""}: ${config}`].join("; ")
1179 const parameters = [...args, `options: { ${fields} }${configOptional && !optionsRequired ? " | undefined" : ""}`]
1180 return ` <Config extends OperationConfig | undefined = undefined>(${
1181 parameters.join(", ")
1182 }): Effect.Effect<WithOptionalResponse<${success}, ${config}>, ${errors.join(" | ")}>;`
1183 }
1184 // Infer the whole config from a required property before allowing omitted config.
1185 // An optional property would otherwise discard undefined during inference.
1186 return `{
1187${signature(false)}
1188${signature(true)}
1189 }`
1190}
1191
1192const resolveConfigAccessor = (operation: ParsedOperation, rootKey: string, configKey: string): string => {
1193 // If an operation payload is defined, then the root object must exist
1194 if (Predicate.isNotUndefined(operation.payload)) {
1195 return `${rootKey}.${configKey}`
1196 }
1197
1198 // If operation parameters are defined and non-optional, then the root object must exist
1199 if (Predicate.isNotUndefined(operation.params) && !operation.paramsOptional) {
1200 return `${rootKey}.${configKey}`
1201 }
1202
1203 // User-specified arguments are allowed but are not required, so the root object is optional
1204 return `${rootKey}?.${configKey}`
1205}
1206
1207const resolveParamsAccessor = (operation: ParsedOperation, rootKey: string, paramsKey: string): string => {
1208 // If an operation payload is not defined and parameters are optional, then the
1209 // root object may or may not exist and parameters must be marked as optional
1210 if (Predicate.isUndefined(operation.payload) && operation.paramsOptional) {
1211 return `${rootKey}?.${paramsKey}?.`
1212 }
1213
1214 // If parameters are optional, they must be marked as optional
1215 if (operation.paramsOptional) {
1216 return `${rootKey}.${paramsKey}?.`
1217 }
1218
1219 return `${rootKey}.${paramsKey}`
1220}