@effect/openapi-generator 4.0.0-beta.40 → 4.0.0-beta.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,9 @@ import * as Layer from "effect/Layer"
4
4
  import * as Predicate from "effect/Predicate"
5
5
  import * as ServiceMap from "effect/ServiceMap"
6
6
  import * as String from "effect/String"
7
- import type { OpenAPISpec, OpenAPISpecMethodName, OpenAPISpecPathItem } from "effect/unstable/httpapi/OpenApi"
7
+ import type { OpenAPISecurityScheme, OpenAPISpec, OpenAPISpecMethodName } from "effect/unstable/httpapi/OpenApi"
8
8
  import SwaggerToOpenApi from "swagger2openapi"
9
+ import * as HttpApiTransformer from "./HttpApiTransformer.ts"
9
10
  import * as JsonSchemaGenerator from "./JsonSchemaGenerator.ts"
10
11
  import * as OpenApiTransformer from "./OpenApiTransformer.ts"
11
12
  import * as ParsedOperation from "./ParsedOperation.ts"
@@ -16,20 +17,49 @@ export class OpenApiGenerator extends ServiceMap.Service<
16
17
  { readonly generate: (spec: OpenAPISpec, options: OpenApiGenerateOptions) => Effect.Effect<string> }
17
18
  >()("OpenApiGenerator") {}
18
19
 
20
+ export type OpenApiGeneratorFormat = "httpclient" | "httpclient-type-only" | "httpapi"
21
+
22
+ export type OpenApiGeneratorWarningCode =
23
+ | "cookie-parameter-dropped"
24
+ | "additional-tags-dropped"
25
+ | "sse-operation-skipped"
26
+ | "response-headers-ignored"
27
+ | "optional-request-body-approximated"
28
+ | "default-response-remapped"
29
+ | "security-and-downgraded"
30
+ | "no-body-method-request-body-skipped"
31
+ | "naming-collision"
32
+
33
+ export interface OpenApiGeneratorWarning {
34
+ readonly code: OpenApiGeneratorWarningCode
35
+ readonly message: string
36
+ readonly path?: string | undefined
37
+ readonly method?: OpenAPISpecMethodName | undefined
38
+ readonly operationId?: string | undefined
39
+ }
40
+
19
41
  export interface OpenApiGenerateOptions {
20
42
  /**
21
- * The name to give to the generated client.
43
+ * The name to give to the generated output.
22
44
  */
23
45
  readonly name: string
24
46
  /**
25
- * When `true`, will **only** generate types based on the provided OpenApi
26
- * specification (without corresponding schemas).
47
+ * The output format to generate.
27
48
  */
28
- readonly typeOnly: boolean
49
+ readonly format: OpenApiGeneratorFormat
29
50
  /**
30
51
  * Hook to transform each JSON Schema node before processing.
31
52
  */
32
53
  readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
54
+ /**
55
+ * Callback to receive non-fatal generation warnings.
56
+ */
57
+ readonly onWarning?: ((warning: OpenApiGeneratorWarning) => void) | undefined
58
+ }
59
+
60
+ interface HttpApiMultipartSchemaRefs {
61
+ readonly singleFile: string
62
+ readonly files: string
33
63
  }
34
64
 
35
65
  const methodNames: ReadonlyArray<OpenAPISpecMethodName> = [
@@ -48,6 +78,7 @@ export const make = Effect.gen(function*() {
48
78
  function*(spec: OpenAPISpec, options: OpenApiGenerateOptions) {
49
79
  const generator = JsonSchemaGenerator.make()
50
80
  const openApiTransformer = yield* OpenApiTransformer.OpenApiTransformer
81
+ const emitWarning = makeWarningEmitter(options)
51
82
 
52
83
  // If we receive a Swagger 2.0 spec, convert it to an OpenApi 3.0 spec
53
84
  if (isSwaggerSpec(spec)) {
@@ -63,210 +94,915 @@ export const make = Effect.gen(function*() {
63
94
  return current
64
95
  }
65
96
 
66
- const operations: Array<ParsedOperation.ParsedOperation> = []
97
+ const multipartSchemaRefs = options.format === "httpapi"
98
+ ? makeHttpApiMultipartSchemaRefs(spec.components?.schemas ?? {})
99
+ : undefined
67
100
 
68
- function handlePath(path: string, methods: OpenAPISpecPathItem): void {
69
- for (const method of methodNames) {
70
- const operation = methods[method]
101
+ const parsed = parseOpenApi(spec, generator, resolveRef, options.format, emitWarning, multipartSchemaRefs)
71
102
 
72
- if (Predicate.isUndefined(operation)) {
73
- continue
103
+ // TODO: make a CLI option ?
104
+ const importName = "Schema"
105
+ const source = getDialect(spec)
106
+ const generation = options.format === "httpapi"
107
+ ? generator.generateHttpApi(
108
+ source,
109
+ withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs),
110
+ {
111
+ onEnter: options.onEnter,
112
+ multipartSchemaRefs
113
+ }
114
+ )
115
+ : generator.generate(
116
+ source,
117
+ spec.components?.schemas ?? {},
118
+ options.format === "httpclient-type-only",
119
+ {
120
+ onEnter: options.onEnter
74
121
  }
122
+ )
123
+
124
+ if (options.format === "httpapi") {
125
+ const needsMultipartImport = generation.includes("Multipart.")
126
+ return String.stripMargin(
127
+ `|${HttpApiTransformer.imports(importName, { multipart: needsMultipartImport })}
128
+ |${generation}
129
+ |${HttpApiTransformer.toImplementation(importName, options.name, parsed)}`
130
+ )
131
+ }
132
+
133
+ return String.stripMargin(
134
+ `|${openApiTransformer.imports(importName, parsed)}
135
+ |${generation}
136
+ |${openApiTransformer.toImplementation(importName, options.name, parsed)}
137
+ |
138
+ |${openApiTransformer.toTypes(importName, options.name, parsed)}`
139
+ )
140
+ },
141
+ (effect, _, options) =>
142
+ Effect.provideServiceEffect(
143
+ effect,
144
+ OpenApiTransformer.OpenApiTransformer,
145
+ options.format === "httpclient-type-only"
146
+ ? Effect.sync(OpenApiTransformer.makeTransformerTs)
147
+ : Effect.sync(OpenApiTransformer.makeTransformerSchema)
148
+ )
149
+ )
75
150
 
76
- const id = operation.operationId
77
- ? Utils.camelize(operation.operationId)
78
- : `${method.toUpperCase()}${path}`
151
+ return { generate } as const
152
+ })
79
153
 
80
- const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
154
+ type WarningEmitter = (warning: OpenApiGeneratorWarning) => void
81
155
 
82
- const { pathIds, pathTemplate } = processPath(path)
156
+ const makeWarningEmitter = (options: OpenApiGenerateOptions): WarningEmitter => (warning) => {
157
+ options.onWarning?.(warning)
158
+ }
83
159
 
84
- const op = ParsedOperation.makeDeepMutable({
85
- id,
86
- method,
87
- description,
88
- pathIds,
89
- pathTemplate
90
- })
160
+ const parseOpenApi = (
161
+ spec: OpenAPISpec,
162
+ generator: ReturnType<typeof JsonSchemaGenerator.make>,
163
+ resolveRef: (ref: string) => unknown,
164
+ format: OpenApiGeneratorFormat,
165
+ emitWarning: WarningEmitter,
166
+ multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined
167
+ ): ParsedOperation.ParsedOpenApi => {
168
+ const operations: Array<ParsedOperation.ParsedOperation> = []
169
+ const reservedSchemaNames = new Set<string>(Object.keys(spec.components?.schemas ?? {}))
170
+ const isHttpApi = format === "httpapi"
171
+ const securitySchemes = isHttpApi ? parseSecuritySchemes(spec, resolveRef) : []
91
172
 
92
- const schemaId = Utils.identifier(operation.operationId ?? path)
173
+ const addSchema = (
174
+ baseName: string,
175
+ schema: JsonSchema.JsonSchema,
176
+ operation: ParsedOperation.ParsedOperation
177
+ ): string => {
178
+ let candidate = baseName
179
+ let index = 2
180
+ while (reservedSchemaNames.has(candidate)) {
181
+ candidate = `${baseName}${index}`
182
+ index += 1
183
+ }
184
+ if (candidate !== baseName) {
185
+ warnForOperation(emitWarning, operation, {
186
+ code: "naming-collision",
187
+ message: `Schema name "${baseName}" collided with an existing name and was renamed to "${candidate}".`
188
+ })
189
+ }
190
+ reservedSchemaNames.add(candidate)
191
+ return generator.addSchema(candidate, schema)
192
+ }
93
193
 
94
- const validParameters = operation.parameters?.filter((param) => {
95
- return param.in !== "path" && param.in !== "cookie"
96
- }) ?? []
194
+ for (const [path, methods] of Object.entries(spec.paths)) {
195
+ for (const method of methodNames) {
196
+ const operation = methods[method]
97
197
 
98
- if (validParameters.length > 0) {
99
- const schema = {
100
- type: "object" as JsonSchema.Type,
101
- properties: {} as Record<string, any>,
102
- required: [] as Array<string>,
103
- additionalProperties: false
104
- }
198
+ if (Predicate.isUndefined(operation)) {
199
+ continue
200
+ }
105
201
 
106
- for (let parameter of validParameters) {
107
- if ("$ref" in parameter) {
108
- parameter = resolveRef(parameter.$ref as string)
109
- }
110
-
111
- if (parameter.in === "path") {
112
- continue
113
- }
114
-
115
- const paramSchema = parameter.schema
116
- const added: Array<string> = []
117
- if ("properties" in paramSchema && Predicate.isObject(paramSchema.properties)) {
118
- const required = "required" in paramSchema
119
- ? paramSchema.required as Array<string>
120
- : []
121
-
122
- for (const [name, propSchema] of Object.entries(paramSchema.properties)) {
123
- const adjustedName = `${parameter.name}[${name}]`
124
- schema.properties[adjustedName] = propSchema
125
- if (required.includes(name)) {
126
- schema.required.push(adjustedName)
127
- }
128
- added.push(adjustedName)
129
- }
130
- } else {
131
- schema.properties[parameter.name] = parameter.schema
132
- if (parameter.required) {
133
- schema.required.push(parameter.name)
134
- }
135
- added.push(parameter.name)
136
- }
137
-
138
- if (parameter.in === "query") {
139
- Utils.spreadElementsInto(added, op.urlParams)
140
- } else if (parameter.in === "header") {
141
- Utils.spreadElementsInto(added, op.headers)
142
- } else if (parameter.in === "cookie") {
143
- Utils.spreadElementsInto(added, op.cookies)
144
- }
145
- }
202
+ const id = operation.operationId
203
+ ? Utils.camelize(operation.operationId)
204
+ : `${method.toUpperCase()}${path}`
146
205
 
147
- op.params = generator.addSchema(
148
- `${schemaId}Params`,
149
- schema
150
- )
206
+ const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
151
207
 
152
- op.paramsOptional = !schema.required || schema.required.length === 0
153
- }
208
+ const { pathIds, pathTemplate } = processPath(path)
154
209
 
155
- if (Predicate.isNotUndefined(operation.requestBody?.content?.["application/json"]?.schema)) {
156
- op.payload = generator.addSchema(
157
- `${schemaId}RequestJson`,
158
- operation.requestBody.content["application/json"].schema
159
- )
160
- }
210
+ const op = ParsedOperation.makeDeepMutable({
211
+ id,
212
+ method,
213
+ description,
214
+ pathIds,
215
+ pathTemplate
216
+ })
217
+ op.path = path
218
+ op.operationId = Utils.nonEmptyString(operation.operationId)
219
+ op.tags = [...(operation.tags ?? [])]
220
+ if (isHttpApi && op.tags.length > 1) {
221
+ warnForOperation(emitWarning, op, {
222
+ code: "additional-tags-dropped",
223
+ message: `Additional tags (${op.tags.slice(1).join(", ")}) were dropped. Only the first tag ("${
224
+ op.tags[0]
225
+ }") is used for grouping.`
226
+ })
227
+ }
228
+ op.metadata = {
229
+ summary: Utils.nonEmptyString(operation.summary),
230
+ description: Utils.nonEmptyString(operation.description),
231
+ deprecated: operation.deprecated === true,
232
+ externalDocs: operation.externalDocs
233
+ }
234
+ op.effectiveSecurity = cloneSecurityRequirements(operation.security ?? spec.security ?? [])
235
+ if (isHttpApi) {
236
+ warnForAndSecurityRequirements(emitWarning, op)
237
+ }
161
238
 
162
- if (Predicate.isNotUndefined(operation.requestBody?.content?.["multipart/form-data"]?.schema)) {
163
- op.payload = generator.addSchema(
164
- `${schemaId}RequestFormData`,
165
- operation.requestBody.content["multipart/form-data"].schema
166
- )
167
- op.payloadFormData = true
239
+ const schemaId = Utils.identifier(operation.operationId ?? path)
240
+
241
+ const pathParameters = Predicate.isObject(methods) && Array.isArray((methods as any).parameters)
242
+ ? (methods as any).parameters as ReadonlyArray<unknown>
243
+ : undefined
244
+ const parameters = resolveOperationParameters(
245
+ pathParameters,
246
+ Array.isArray(operation.parameters) ? operation.parameters : undefined,
247
+ resolveRef
248
+ )
249
+ for (const parameter of parameters) {
250
+ const parsedParameter: ParsedOperation.ParsedOperationParameter = {
251
+ name: parameter.name,
252
+ in: parameter.in,
253
+ required: parameter.required === true,
254
+ description: Utils.nonEmptyString(parameter.description),
255
+ schema: parameter.schema
256
+ }
257
+ switch (parameter.in) {
258
+ case "path": {
259
+ op.parameters.path.push(parsedParameter)
260
+ break
261
+ }
262
+ case "query": {
263
+ op.parameters.query.push(parsedParameter)
264
+ break
265
+ }
266
+ case "header": {
267
+ op.parameters.header.push(parsedParameter)
268
+ break
168
269
  }
270
+ case "cookie": {
271
+ op.parameters.cookie.push(parsedParameter)
272
+ warnForOperation(emitWarning, op, {
273
+ code: "cookie-parameter-dropped",
274
+ message:
275
+ `Cookie parameter "${parameter.name}" was dropped because non-security cookie parameters are not supported.`
276
+ })
277
+ break
278
+ }
279
+ }
280
+ }
169
281
 
170
- let defaultSchema: string | undefined
171
- for (const entry of Object.entries(operation.responses ?? {})) {
172
- const status = entry[0]
173
- let response = entry[1]
282
+ const requestBody = resolveReference(operation.requestBody, resolveRef)
283
+ if (isHttpApi && !methodSupportsRequestBody(op.method) && Predicate.isObject(requestBody)) {
284
+ warnForOperation(emitWarning, op, {
285
+ code: "no-body-method-request-body-skipped",
286
+ message: `Operation was skipped because ${op.method.toUpperCase()} does not support request bodies.`
287
+ })
288
+ continue
289
+ }
174
290
 
175
- while ("$ref" in response) {
176
- response = resolveRef(response.$ref as string)
177
- }
291
+ const resolvedResponses = Object.entries(operation.responses ?? {}).map(
292
+ ([status, response]) => [status, resolveReference(response, resolveRef)] as const
293
+ )
294
+ const hasExplicitSuccessResponse = resolvedResponses.some(([status]) => {
295
+ if (!/^\d{3}$/.test(status)) {
296
+ return false
297
+ }
298
+ return Number(status) < 400
299
+ })
178
300
 
179
- if (Predicate.isNotUndefined(response.content?.["application/json"]?.schema)) {
180
- const schemaName = generator.addSchema(
181
- `${schemaId}${status}`,
182
- response.content["application/json"].schema
183
- )
301
+ if (isHttpApi && hasSuccessfulSseResponse(resolvedResponses, hasExplicitSuccessResponse)) {
302
+ warnForOperation(emitWarning, op, {
303
+ code: "sse-operation-skipped",
304
+ message:
305
+ "Operation was skipped because successful text/event-stream responses are not supported in HttpApi generation."
306
+ })
307
+ continue
308
+ }
184
309
 
185
- if (status === "default") {
186
- defaultSchema = schemaName
187
- continue
188
- }
189
-
190
- const statusLower = status.toLowerCase()
191
- const statusMajorNumber = Number(status[0])
192
- if (Number.isNaN(statusMajorNumber)) {
193
- continue
194
- }
195
- if (statusMajorNumber < 4) {
196
- op.successSchemas.set(statusLower, schemaName)
197
- } else {
198
- op.errorSchemas.set(statusLower, schemaName)
199
- }
200
- }
310
+ const validParameters = parameters.filter((parameter) => parameter.in !== "path" && parameter.in !== "cookie")
201
311
 
202
- // Handle SSE streaming responses (text/event-stream)
203
- if (
204
- Predicate.isUndefined(op.sseSchema) &&
205
- Predicate.isNotUndefined(response.content?.["text/event-stream"]?.schema)
206
- ) {
207
- const statusMajorNumber = Number(status[0])
208
- if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
209
- op.sseSchema = generator.addSchema(
210
- `${schemaId}${status}Sse`,
211
- response.content["text/event-stream"].schema
212
- )
213
- }
214
- }
312
+ const combinedParameterSchema = buildParameterSchema(validParameters, (parameter, added) => {
313
+ if (parameter.in === "query") {
314
+ Utils.spreadElementsInto(added, op.urlParams)
315
+ } else if (parameter.in === "header") {
316
+ Utils.spreadElementsInto(added, op.headers)
317
+ } else if (parameter.in === "cookie") {
318
+ Utils.spreadElementsInto(added, op.cookies)
319
+ }
320
+ })
321
+
322
+ if (combinedParameterSchema !== undefined) {
323
+ op.params = addSchema(`${schemaId}Params`, combinedParameterSchema.schema, op)
324
+ op.paramsOptional = combinedParameterSchema.optional
325
+ }
326
+
327
+ if (isHttpApi) {
328
+ const pathParameterSchema = buildParameterSchema(op.parameters.path)
329
+ if (pathParameterSchema !== undefined) {
330
+ op.pathSchema = addSchema(`${schemaId}PathParams`, pathParameterSchema.schema, op)
331
+ }
332
+
333
+ const queryParameterSchema = buildParameterSchema(op.parameters.query)
334
+ if (queryParameterSchema !== undefined) {
335
+ op.querySchema = addSchema(`${schemaId}Query`, queryParameterSchema.schema, op)
336
+ op.querySchemaOptional = queryParameterSchema.optional
337
+ }
338
+
339
+ const headerParameterSchema = buildParameterSchema(op.parameters.header)
340
+ if (headerParameterSchema !== undefined) {
341
+ op.headersSchema = addSchema(`${schemaId}Headers`, headerParameterSchema.schema, op)
342
+ op.headersSchemaOptional = headerParameterSchema.optional
343
+ }
344
+ }
345
+
346
+ if (Predicate.isNotUndefined(requestBody) && Predicate.isObject(requestBody)) {
347
+ const content = Predicate.isObject(requestBody.content)
348
+ ? requestBody.content as Record<string, any>
349
+ : {}
350
+ const requestSchemaNames = new Map<string, string>()
351
+ op.requestBody = {
352
+ required: requestBody.required === true,
353
+ contentTypes: Object.keys(content)
354
+ }
355
+ if (isHttpApi && requestBody.required === false) {
356
+ warnForOperation(emitWarning, op, {
357
+ code: "optional-request-body-approximated",
358
+ message: "Optional request body was approximated by adding a no-content payload alternative."
359
+ })
360
+ }
361
+
362
+ if (Predicate.isNotUndefined(content["application/json"]?.schema)) {
363
+ op.payload = addSchema(`${schemaId}RequestJson`, content["application/json"].schema, op)
364
+ requestSchemaNames.set("application/json", op.payload)
365
+ }
366
+
367
+ if (Predicate.isNotUndefined(content["multipart/form-data"]?.schema)) {
368
+ op.payload = addSchema(
369
+ `${schemaId}RequestFormData`,
370
+ transformMultipartSchema(content["multipart/form-data"].schema, multipartSchemaRefs, resolveRef),
371
+ op
372
+ )
373
+ op.payloadFormData = true
374
+ requestSchemaNames.set("multipart/form-data", op.payload)
375
+ }
215
376
 
216
- // Handle binary streaming responses (application/octet-stream)
217
- if (Predicate.isNotUndefined(response.content?.["application/octet-stream"])) {
218
- const statusMajorNumber = Number(status[0])
219
- if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
220
- op.binaryResponse = true
221
- }
377
+ if (isHttpApi) {
378
+ const representableRequestBody: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
379
+ for (const [contentType, mediaType] of Object.entries(content)) {
380
+ if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
381
+ continue
382
+ }
383
+ const encoding = getRequestMediaTypeEncoding(contentType)
384
+ if (encoding === undefined) {
385
+ continue
386
+ }
387
+ let schemaName = requestSchemaNames.get(contentType)
388
+ if (schemaName === undefined) {
389
+ const schema = encoding === "multipart"
390
+ ? transformMultipartSchema(mediaType.schema as JsonSchema.JsonSchema, multipartSchemaRefs, resolveRef)
391
+ : mediaType.schema as JsonSchema.JsonSchema
392
+ schemaName = addSchema(
393
+ `${schemaId}Request${mediaTypeToSuffix(contentType)}`,
394
+ schema,
395
+ op
396
+ )
397
+ requestSchemaNames.set(contentType, schemaName)
222
398
  }
399
+ representableRequestBody.push({
400
+ contentType,
401
+ encoding,
402
+ schema: schemaName
403
+ })
404
+ }
405
+ op.requestBodyRepresentable = representableRequestBody
406
+ }
407
+ }
223
408
 
224
- if (Predicate.isUndefined(response.content)) {
225
- if (status !== "default") {
226
- op.voidSchemas.add(status.toLowerCase())
227
- }
409
+ let defaultSchema: string | undefined
410
+ for (const [status, response] of resolvedResponses) {
411
+ if (!Predicate.isObject(response)) {
412
+ continue
413
+ }
414
+
415
+ const parsedStatus = isHttpApi
416
+ ? remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
417
+ : status
418
+ if (isHttpApi && status === "default") {
419
+ warnForOperation(emitWarning, op, {
420
+ code: "default-response-remapped",
421
+ message: `Default response was remapped to status ${parsedStatus} for HttpApi generation.`
422
+ })
423
+ }
424
+
425
+ const content = Predicate.isObject(response.content)
426
+ ? response.content as Record<string, any>
427
+ : undefined
428
+ if (isHttpApi && Predicate.isNotUndefined(response.headers)) {
429
+ warnForOperation(emitWarning, op, {
430
+ code: "response-headers-ignored",
431
+ message: `Response headers on status ${status} were ignored in HttpApi generation.`
432
+ })
433
+ }
434
+ const representable: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
435
+
436
+ let jsonSchemaName: string | undefined
437
+ const jsonResponseSchema = content?.["application/json"]?.schema
438
+ if (Predicate.isNotUndefined(jsonResponseSchema)) {
439
+ jsonSchemaName = addSchema(`${schemaId}${status}`, jsonResponseSchema, op)
440
+ if (isHttpApi) {
441
+ representable.push({
442
+ contentType: "application/json",
443
+ encoding: "json",
444
+ schema: jsonSchemaName
445
+ })
446
+ }
447
+ }
448
+
449
+ if (isHttpApi) {
450
+ for (const [contentType, mediaType] of Object.entries(content ?? {})) {
451
+ if (contentType === "application/json") {
452
+ continue
228
453
  }
454
+ if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
455
+ continue
456
+ }
457
+ const encoding = getResponseMediaTypeEncoding(contentType)
458
+ if (encoding === undefined) {
459
+ continue
460
+ }
461
+ const schemaName = addSchema(
462
+ `${schemaId}${status}${mediaTypeToSuffix(contentType)}`,
463
+ mediaType.schema as JsonSchema.JsonSchema,
464
+ op
465
+ )
466
+ representable.push({
467
+ contentType,
468
+ encoding,
469
+ schema: schemaName
470
+ })
229
471
  }
472
+ }
473
+
474
+ const isEmptyResponse = Predicate.isUndefined(content) || Object.keys(content).length === 0
475
+ const parsedResponse = {
476
+ status: parsedStatus,
477
+ description: Utils.nonEmptyString(response.description),
478
+ contentTypes: Predicate.isNotUndefined(content) ? Object.keys(content) : [],
479
+ hasHeaders: Predicate.isNotUndefined(response.headers),
480
+ isEmpty: isEmptyResponse,
481
+ representable
482
+ }
483
+ op.responses.push(parsedResponse)
484
+ if (status === "default") {
485
+ op.defaultResponse = parsedResponse
486
+ }
487
+
488
+ if (Predicate.isNotUndefined(jsonSchemaName)) {
489
+ const schemaName = jsonSchemaName
230
490
 
231
- if (op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
232
- op.successSchemas.set("2xx", defaultSchema)
491
+ if (status === "default" && !isHttpApi) {
492
+ defaultSchema = schemaName
493
+ continue
233
494
  }
234
495
 
235
- operations.push(op)
496
+ const statusLower = parsedStatus.toLowerCase()
497
+ const statusMajorNumber = Number(parsedStatus[0])
498
+ if (Number.isNaN(statusMajorNumber)) {
499
+ continue
500
+ }
501
+ if (statusMajorNumber < 4) {
502
+ op.successSchemas.set(statusLower, schemaName)
503
+ } else {
504
+ op.errorSchemas.set(statusLower, schemaName)
505
+ }
506
+ }
507
+
508
+ const sseResponseSchema = content?.["text/event-stream"]?.schema
509
+ if (Predicate.isUndefined(op.sseSchema) && Predicate.isNotUndefined(sseResponseSchema)) {
510
+ const statusMajorNumber = Number(parsedStatus[0])
511
+ if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
512
+ op.sseSchema = addSchema(`${schemaId}${status}Sse`, sseResponseSchema, op)
513
+ }
514
+ }
515
+
516
+ if (Predicate.isNotUndefined(content?.["application/octet-stream"])) {
517
+ const statusMajorNumber = Number(parsedStatus[0])
518
+ if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
519
+ op.binaryResponse = true
520
+ }
521
+ }
522
+
523
+ if (isEmptyResponse) {
524
+ if (parsedStatus !== "default") {
525
+ op.voidSchemas.add(parsedStatus.toLowerCase())
526
+ }
236
527
  }
237
528
  }
238
529
 
239
- for (const [path, methods] of Object.entries(spec.paths)) {
240
- handlePath(path, methods)
530
+ if (!isHttpApi && op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
531
+ op.successSchemas.set("2xx", defaultSchema)
532
+ warnForOperation(emitWarning, op, {
533
+ code: "default-response-remapped",
534
+ message: "Default response was remapped to 2xx for the current HttpClient outputs."
535
+ })
241
536
  }
242
537
 
243
- // TODO: make a CLI option ?
244
- const importName = "Schema"
245
- const source = getDialect(spec)
246
- const generation = generator.generate(source, spec.components?.schemas ?? {}, options.typeOnly, {
247
- onEnter: options.onEnter
248
- })
538
+ operations.push(op)
539
+ }
540
+ }
249
541
 
250
- return String.stripMargin(
251
- `|${openApiTransformer.imports(importName, operations)}
252
- |${generation}
253
- |${openApiTransformer.toImplementation(importName, options.name, operations)}
254
- |
255
- |${openApiTransformer.toTypes(importName, options.name, operations)}`
256
- )
542
+ return {
543
+ metadata: {
544
+ title: spec.info.title,
545
+ version: spec.info.version,
546
+ summary: Utils.nonEmptyString(spec.info.summary),
547
+ description: Utils.nonEmptyString(spec.info.description),
548
+ license: spec.info.license,
549
+ servers: spec.servers
257
550
  },
258
- (effect, _, options) =>
259
- Effect.provideServiceEffect(
260
- effect,
261
- OpenApiTransformer.OpenApiTransformer,
262
- options.typeOnly
263
- ? Effect.sync(OpenApiTransformer.makeTransformerTs)
264
- : Effect.sync(OpenApiTransformer.makeTransformerSchema)
265
- )
551
+ tags: (spec.tags ?? []).map((tag) => ({
552
+ name: tag.name,
553
+ description: Utils.nonEmptyString(tag.description),
554
+ externalDocs: tag.externalDocs
555
+ })),
556
+ securitySchemes,
557
+ operations
558
+ }
559
+ }
560
+
561
+ interface OpenApiParameter {
562
+ readonly name: string
563
+ readonly in: "path" | "query" | "header" | "cookie"
564
+ readonly required: boolean
565
+ readonly schema: {}
566
+ readonly description?: string | undefined
567
+ }
568
+
569
+ const isOpenApiParameter = (parameter: unknown): parameter is OpenApiParameter => {
570
+ if (!Predicate.isObject(parameter)) {
571
+ return false
572
+ }
573
+ return (
574
+ typeof parameter.name === "string" &&
575
+ (parameter.in === "path" || parameter.in === "query" || parameter.in === "header" || parameter.in === "cookie")
266
576
  )
577
+ }
267
578
 
268
- return { generate } as const
269
- })
579
+ const resolveOperationParameters = (
580
+ pathParameters: ReadonlyArray<unknown> | undefined,
581
+ operationParameters: ReadonlyArray<unknown> | undefined,
582
+ resolveRef: (ref: string) => unknown
583
+ ): Array<OpenApiParameter> => {
584
+ const resolved = new Map<string, OpenApiParameter>()
585
+ const add = (parameter: unknown): void => {
586
+ const current = resolveReference(parameter, resolveRef)
587
+ if (!isOpenApiParameter(current)) {
588
+ return
589
+ }
590
+
591
+ const key = `${current.in}:${current.name}`
592
+ if (resolved.has(key)) {
593
+ resolved.delete(key)
594
+ }
595
+ resolved.set(key, current)
596
+ }
597
+
598
+ for (const parameter of pathParameters ?? []) {
599
+ add(parameter)
600
+ }
601
+
602
+ for (const parameter of operationParameters ?? []) {
603
+ add(parameter)
604
+ }
605
+
606
+ return [...resolved.values()]
607
+ }
608
+
609
+ const buildParameterSchema = <
610
+ Parameter extends {
611
+ readonly name: string
612
+ readonly required: boolean
613
+ readonly schema: {}
614
+ readonly in?: "path" | "query" | "header" | "cookie" | undefined
615
+ }
616
+ >(
617
+ parameters: ReadonlyArray<Parameter>,
618
+ onAdded?: ((parameter: Parameter, added: Array<string>) => void) | undefined
619
+ ): {
620
+ readonly schema: JsonSchema.JsonSchema
621
+ readonly optional: boolean
622
+ } | undefined => {
623
+ if (parameters.length === 0) {
624
+ return
625
+ }
626
+
627
+ const schema = {
628
+ type: "object" as JsonSchema.Type,
629
+ properties: {} as Record<string, JsonSchema.JsonSchema>,
630
+ required: [] as Array<string>,
631
+ additionalProperties: false
632
+ }
633
+
634
+ for (const parameter of parameters) {
635
+ const paramSchema = parameter.schema as any
636
+ const added: Array<string> = []
637
+ if (
638
+ Predicate.isObject(paramSchema) &&
639
+ "properties" in paramSchema &&
640
+ Predicate.isObject(paramSchema.properties)
641
+ ) {
642
+ const required = "required" in paramSchema
643
+ ? paramSchema.required as Array<string>
644
+ : []
645
+
646
+ for (const [name, propertySchema] of Object.entries(paramSchema.properties)) {
647
+ const adjustedName = `${parameter.name}[${name}]`
648
+ schema.properties[adjustedName] = propertySchema as JsonSchema.JsonSchema
649
+ if (required.includes(name)) {
650
+ schema.required.push(adjustedName)
651
+ }
652
+ added.push(adjustedName)
653
+ }
654
+ } else {
655
+ schema.properties[parameter.name] = parameter.schema as JsonSchema.JsonSchema
656
+ if (parameter.required) {
657
+ schema.required.push(parameter.name)
658
+ }
659
+ added.push(parameter.name)
660
+ }
661
+
662
+ onAdded?.(parameter, added)
663
+ }
664
+
665
+ return {
666
+ schema,
667
+ optional: schema.required.length === 0
668
+ }
669
+ }
670
+
671
+ const mediaTypeToSuffix = (contentType: string): string => {
672
+ const normalized = contentType.toLowerCase()
673
+ switch (normalized) {
674
+ case "application/json":
675
+ return "Json"
676
+ case "multipart/form-data":
677
+ return "FormData"
678
+ case "application/x-www-form-urlencoded":
679
+ return "FormUrlEncoded"
680
+ case "text/plain":
681
+ return "Text"
682
+ case "application/octet-stream":
683
+ return "Binary"
684
+ }
685
+ const suffix = Utils.identifier(contentType)
686
+ return suffix.length > 0 ? suffix : "Body"
687
+ }
688
+
689
+ const makeHttpApiMultipartSchemaRefs = (definitions: JsonSchema.Definitions): HttpApiMultipartSchemaRefs => {
690
+ const names = new Set(Object.keys(definitions))
691
+ const allocate = (base: string): string => {
692
+ let candidate = base
693
+ let index = 2
694
+ while (names.has(candidate)) {
695
+ candidate = `${base}${index}`
696
+ index += 1
697
+ }
698
+ names.add(candidate)
699
+ return candidate
700
+ }
701
+ return {
702
+ singleFile: allocate("__HttpApiMultipartSingleFile"),
703
+ files: allocate("__HttpApiMultipartFiles")
704
+ }
705
+ }
706
+
707
+ const toDefinitionRef = (name: string): string => `#/$defs/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`
708
+
709
+ const withHttpApiMultipartSchemas = (
710
+ definitions: JsonSchema.Definitions,
711
+ multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined
712
+ ): JsonSchema.Definitions => {
713
+ if (multipartSchemaRefs === undefined) {
714
+ return definitions
715
+ }
716
+ return {
717
+ ...definitions,
718
+ [multipartSchemaRefs.singleFile]: {
719
+ type: "string",
720
+ format: "binary"
721
+ },
722
+ [multipartSchemaRefs.files]: {
723
+ type: "array",
724
+ items: {
725
+ $ref: toDefinitionRef(multipartSchemaRefs.singleFile)
726
+ }
727
+ }
728
+ }
729
+ }
730
+
731
+ const transformMultipartSchema = (
732
+ schema: JsonSchema.JsonSchema,
733
+ multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined,
734
+ resolveRef: (ref: string) => unknown
735
+ ): JsonSchema.JsonSchema => {
736
+ if (multipartSchemaRefs === undefined) {
737
+ return schema
738
+ }
739
+
740
+ const singleFileRef = toDefinitionRef(multipartSchemaRefs.singleFile)
741
+ const filesRef = toDefinitionRef(multipartSchemaRefs.files)
742
+ const cache = new Map<string, unknown>()
743
+ const stack = new Set<string>()
744
+
745
+ const visit = (value: unknown): unknown => {
746
+ if (Array.isArray(value)) {
747
+ return value.map(visit)
748
+ }
749
+ if (!Predicate.isObject(value)) {
750
+ return value
751
+ }
752
+
753
+ if (typeof value.$ref === "string" && value.$ref.startsWith("#/components/schemas/")) {
754
+ const cached = cache.get(value.$ref)
755
+ if (cached !== undefined) {
756
+ return cached
757
+ }
758
+ if (stack.has(value.$ref)) {
759
+ return value
760
+ }
761
+ stack.add(value.$ref)
762
+ const transformed = visit(resolveSchemaReference(value.$ref, resolveRef))
763
+ stack.delete(value.$ref)
764
+ cache.set(value.$ref, transformed)
765
+ return transformed
766
+ }
767
+
768
+ if (isMultipartBinaryFile(value)) {
769
+ return { $ref: singleFileRef }
770
+ }
771
+
772
+ const out: Record<string, unknown> = {}
773
+ for (const [key, current] of Object.entries(value)) {
774
+ out[key] = visit(current)
775
+ }
776
+
777
+ if (isMultipartBinaryFiles(out, singleFileRef)) {
778
+ return { $ref: filesRef }
779
+ }
780
+
781
+ return out
782
+ }
783
+
784
+ return visit(schema) as JsonSchema.JsonSchema
785
+ }
786
+
787
+ const resolveSchemaReference = (ref: string, resolveRef: (ref: string) => unknown): unknown => {
788
+ let current: unknown = { $ref: ref }
789
+ const seen = new Set<string>()
790
+ while (Predicate.isObject(current) && typeof current.$ref === "string") {
791
+ if (seen.has(current.$ref)) {
792
+ return current
793
+ }
794
+ seen.add(current.$ref)
795
+ current = resolveRef(current.$ref)
796
+ }
797
+ return current
798
+ }
799
+
800
+ const isMultipartBinaryFile = (value: unknown): value is JsonSchema.JsonSchema =>
801
+ Predicate.isObject(value) &&
802
+ value.type === "string" &&
803
+ (
804
+ (typeof value.format === "string" && value.format.toLowerCase() === "binary") ||
805
+ (typeof value.contentEncoding === "string" && value.contentEncoding.toLowerCase() === "binary")
806
+ )
807
+
808
+ const isMultipartBinaryFiles = (value: Record<string, unknown>, singleFileRef: string): boolean => {
809
+ if (value.type !== "array") {
810
+ return false
811
+ }
812
+ const items = value.items
813
+ return isMultipartBinaryFile(items) || (Predicate.isObject(items) && items.$ref === singleFileRef)
814
+ }
815
+
816
+ const isJsonMediaType = (contentType: string): boolean =>
817
+ contentType === "application/json" ||
818
+ (contentType.startsWith("application/") && contentType.endsWith("+json"))
819
+
820
+ const isTextMediaType = (contentType: string): boolean => contentType.startsWith("text/")
821
+
822
+ const isBinaryMediaType = (contentType: string): boolean =>
823
+ contentType === "application/octet-stream" ||
824
+ (contentType.startsWith("application/") && (contentType.includes("binary") || contentType.endsWith("+octet-stream")))
825
+
826
+ const getRequestMediaTypeEncoding = (
827
+ contentType: string
828
+ ): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
829
+ const normalized = contentType.toLowerCase()
830
+ if (isJsonMediaType(normalized)) {
831
+ return "json"
832
+ }
833
+ if (normalized === "multipart/form-data") {
834
+ return "multipart"
835
+ }
836
+ if (normalized === "application/x-www-form-urlencoded") {
837
+ return "form-url-encoded"
838
+ }
839
+ if (isTextMediaType(normalized)) {
840
+ return "text"
841
+ }
842
+ if (isBinaryMediaType(normalized)) {
843
+ return "binary"
844
+ }
845
+ return
846
+ }
847
+
848
+ const getResponseMediaTypeEncoding = (
849
+ contentType: string
850
+ ): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
851
+ const normalized = contentType.toLowerCase()
852
+ if (isJsonMediaType(normalized)) {
853
+ return "json"
854
+ }
855
+ if (normalized === "application/x-www-form-urlencoded") {
856
+ return "form-url-encoded"
857
+ }
858
+ if (isTextMediaType(normalized)) {
859
+ return "text"
860
+ }
861
+ if (isBinaryMediaType(normalized)) {
862
+ return "binary"
863
+ }
864
+ return
865
+ }
866
+
867
+ const resolveReference = (input: unknown, resolveRef: (ref: string) => unknown): any => {
868
+ let current = input
869
+ while (Predicate.isObject(current) && typeof current.$ref === "string") {
870
+ current = resolveRef(current.$ref)
871
+ }
872
+ return current
873
+ }
874
+
875
+ const cloneSecurityRequirements = (
876
+ security: ReadonlyArray<Record<string, ReadonlyArray<string>>>
877
+ ): Array<ParsedOperation.ParsedOperationSecurityRequirement> =>
878
+ security.map((requirement) =>
879
+ Object.fromEntries(
880
+ Object.entries(requirement).map(([name, scopes]) => [name, [...scopes]])
881
+ )
882
+ )
883
+
884
+ const parseSecuritySchemes = (
885
+ spec: OpenAPISpec,
886
+ resolveRef: (ref: string) => unknown
887
+ ): Array<ParsedOperation.ParsedOpenApiSecurityScheme> => {
888
+ const securitySchemes = spec.components?.securitySchemes ?? {}
889
+ const parsed: Array<ParsedOperation.ParsedOpenApiSecurityScheme> = []
890
+
891
+ for (const [name, value] of Object.entries(securitySchemes)) {
892
+ const scheme = resolveReference(value, resolveRef) as OpenAPISecurityScheme | undefined
893
+ if (!Predicate.isObject(scheme)) {
894
+ continue
895
+ }
896
+
897
+ if (scheme.type === "http") {
898
+ const normalizedScheme = scheme.scheme.toLowerCase()
899
+ if (normalizedScheme === "basic") {
900
+ parsed.push({
901
+ name,
902
+ type: "basic",
903
+ description: Utils.nonEmptyString(scheme.description),
904
+ bearerFormat: undefined,
905
+ key: undefined,
906
+ in: undefined
907
+ })
908
+ } else if (normalizedScheme === "bearer") {
909
+ parsed.push({
910
+ name,
911
+ type: "bearer",
912
+ description: Utils.nonEmptyString(scheme.description),
913
+ bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
914
+ key: undefined,
915
+ in: undefined
916
+ })
917
+ }
918
+ continue
919
+ }
920
+
921
+ if (
922
+ scheme.type === "apiKey" &&
923
+ (scheme.in === "header" || scheme.in === "query" || scheme.in === "cookie") &&
924
+ typeof scheme.name === "string"
925
+ ) {
926
+ parsed.push({
927
+ name,
928
+ type: "apiKey",
929
+ description: Utils.nonEmptyString(scheme.description),
930
+ bearerFormat: undefined,
931
+ key: scheme.name,
932
+ in: scheme.in
933
+ })
934
+ }
935
+ }
936
+
937
+ return parsed
938
+ }
939
+
940
+ const warnForAndSecurityRequirements = (
941
+ emitWarning: WarningEmitter,
942
+ operation: ParsedOperation.ParsedOperation
943
+ ): void => {
944
+ if (operation.effectiveSecurity.some((requirement) => Object.keys(requirement).length === 0)) {
945
+ return
946
+ }
947
+ for (const requirement of operation.effectiveSecurity) {
948
+ const schemes = Object.keys(requirement)
949
+ if (schemes.length <= 1) {
950
+ continue
951
+ }
952
+ warnForOperation(emitWarning, operation, {
953
+ code: "security-and-downgraded",
954
+ message: `Security requirement requiring all of [${
955
+ schemes.join(", ")
956
+ }] was downgraded to a placeholder middleware.`
957
+ })
958
+ }
959
+ }
960
+
961
+ const hasSuccessfulSseResponse = (
962
+ responses: ReadonlyArray<readonly [string, unknown]>,
963
+ hasExplicitSuccessResponse: boolean
964
+ ): boolean => {
965
+ for (const [status, response] of responses) {
966
+ if (!Predicate.isObject(response)) {
967
+ continue
968
+ }
969
+ const content = Predicate.isObject(response.content)
970
+ ? response.content as Record<string, any>
971
+ : undefined
972
+ if (Predicate.isUndefined(content?.["text/event-stream"]?.schema)) {
973
+ continue
974
+ }
975
+
976
+ const remappedStatus = remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
977
+ const statusCode = Number(remappedStatus)
978
+ if (!Number.isNaN(statusCode) && statusCode < 400) {
979
+ return true
980
+ }
981
+ }
982
+ return false
983
+ }
984
+
985
+ const remapDefaultResponseStatusForHttpApi = (status: string, hasExplicitSuccessResponse: boolean): string =>
986
+ status === "default" ? (hasExplicitSuccessResponse ? "500" : "200") : status
987
+
988
+ const methodSupportsRequestBody = (method: OpenAPISpecMethodName): boolean =>
989
+ method !== "get" && method !== "head" && method !== "options" && method !== "trace"
990
+
991
+ const warnForOperation = (
992
+ emitWarning: WarningEmitter,
993
+ operation: ParsedOperation.ParsedOperation,
994
+ warning: {
995
+ readonly code: OpenApiGeneratorWarningCode
996
+ readonly message: string
997
+ }
998
+ ): void => {
999
+ emitWarning({
1000
+ ...warning,
1001
+ path: operation.path,
1002
+ method: operation.method,
1003
+ operationId: operation.operationId
1004
+ })
1005
+ }
270
1006
 
271
1007
  function getDialect(spec: OpenAPISpec): "openapi-3.0" | "openapi-3.1" {
272
1008
  return spec.openapi.trim().startsWith("3.0") ? "openapi-3.0" : "openapi-3.1"