@effect/openapi-generator 4.0.0-beta.6 → 4.0.0-beta.62

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.
@@ -1,35 +1,65 @@
1
+ import * as Context from "effect/Context"
1
2
  import * as Effect from "effect/Effect"
2
3
  import type * as JsonSchema from "effect/JsonSchema"
3
4
  import * as Layer from "effect/Layer"
4
5
  import * as Predicate from "effect/Predicate"
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"
12
13
  import * as Utils from "./Utils.ts"
13
14
 
14
- export class OpenApiGenerator extends ServiceMap.Service<
15
+ export class OpenApiGenerator extends Context.Service<
15
16
  OpenApiGenerator,
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,925 @@ 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
+ )
75
123
 
76
- const id = operation.operationId
77
- ? Utils.camelize(operation.operationId)
78
- : `${method.toUpperCase()}${path}`
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
+ }
79
132
 
80
- const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
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
+ )
81
150
 
82
- const { pathIds, pathTemplate } = processPath(path)
151
+ return { generate } as const
152
+ })
83
153
 
84
- const op = ParsedOperation.makeDeepMutable({
85
- id,
86
- method,
87
- description,
88
- pathIds,
89
- pathTemplate
90
- })
154
+ type WarningEmitter = (warning: OpenApiGeneratorWarning) => void
91
155
 
92
- const schemaId = Utils.identifier(operation.operationId ?? path)
156
+ const makeWarningEmitter = (options: OpenApiGenerateOptions): WarningEmitter => (warning) => {
157
+ options.onWarning?.(warning)
158
+ }
93
159
 
94
- const validParameters = operation.parameters?.filter((param) => {
95
- return param.in !== "path" && param.in !== "cookie"
96
- }) ?? []
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) : []
97
172
 
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
- }
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
+ }
105
193
 
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
- }
194
+ for (const [path, methods] of Object.entries(spec.paths)) {
195
+ for (const method of methodNames) {
196
+ const operation = methods[method]
146
197
 
147
- op.params = generator.addSchema(
148
- `${schemaId}Params`,
149
- schema
150
- )
198
+ if (Predicate.isUndefined(operation)) {
199
+ continue
200
+ }
151
201
 
152
- op.paramsOptional = !schema.required || schema.required.length === 0
153
- }
202
+ const id = operation.operationId
203
+ ? Utils.camelize(operation.operationId)
204
+ : `${method.toUpperCase()}${path}`
154
205
 
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
- }
206
+ const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
161
207
 
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
208
+ const { pathIds, pathTemplate } = processPath(path)
209
+
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
+ }
238
+
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
168
261
  }
262
+ case "query": {
263
+ op.parameters.query.push(parsedParameter)
264
+ break
265
+ }
266
+ case "header": {
267
+ op.parameters.header.push(parsedParameter)
268
+ break
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 (Predicate.isNotUndefined(content["application/x-www-form-urlencoded"]?.schema)) {
378
+ op.payload = addSchema(
379
+ `${schemaId}RequestFormUrlEncoded`,
380
+ content["application/x-www-form-urlencoded"].schema,
381
+ op
382
+ )
383
+ op.payloadFormUrlEncoded = true
384
+ requestSchemaNames.set("application/x-www-form-urlencoded", op.payload)
385
+ }
386
+
387
+ if (isHttpApi) {
388
+ const representableRequestBody: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
389
+ for (const [contentType, mediaType] of Object.entries(content)) {
390
+ if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
391
+ continue
222
392
  }
393
+ const encoding = getRequestMediaTypeEncoding(contentType)
394
+ if (encoding === undefined) {
395
+ continue
396
+ }
397
+ let schemaName = requestSchemaNames.get(contentType)
398
+ if (schemaName === undefined) {
399
+ const schema = encoding === "multipart"
400
+ ? transformMultipartSchema(mediaType.schema as JsonSchema.JsonSchema, multipartSchemaRefs, resolveRef)
401
+ : mediaType.schema as JsonSchema.JsonSchema
402
+ schemaName = addSchema(
403
+ `${schemaId}Request${mediaTypeToSuffix(contentType)}`,
404
+ schema,
405
+ op
406
+ )
407
+ requestSchemaNames.set(contentType, schemaName)
408
+ }
409
+ representableRequestBody.push({
410
+ contentType,
411
+ encoding,
412
+ schema: schemaName
413
+ })
414
+ }
415
+ op.requestBodyRepresentable = representableRequestBody
416
+ }
417
+ }
418
+
419
+ let defaultSchema: string | undefined
420
+ for (const [status, response] of resolvedResponses) {
421
+ if (!Predicate.isObject(response)) {
422
+ continue
423
+ }
223
424
 
224
- if (Predicate.isUndefined(response.content)) {
225
- if (status !== "default") {
226
- op.voidSchemas.add(status.toLowerCase())
227
- }
425
+ const parsedStatus = isHttpApi
426
+ ? remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
427
+ : status
428
+ if (isHttpApi && status === "default") {
429
+ warnForOperation(emitWarning, op, {
430
+ code: "default-response-remapped",
431
+ message: `Default response was remapped to status ${parsedStatus} for HttpApi generation.`
432
+ })
433
+ }
434
+
435
+ const content = Predicate.isObject(response.content)
436
+ ? response.content as Record<string, any>
437
+ : undefined
438
+ if (isHttpApi && Predicate.isNotUndefined(response.headers)) {
439
+ warnForOperation(emitWarning, op, {
440
+ code: "response-headers-ignored",
441
+ message: `Response headers on status ${status} were ignored in HttpApi generation.`
442
+ })
443
+ }
444
+ const representable: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
445
+
446
+ let jsonSchemaName: string | undefined
447
+ const jsonResponseSchema = content?.["application/json"]?.schema
448
+ if (Predicate.isNotUndefined(jsonResponseSchema)) {
449
+ jsonSchemaName = addSchema(`${schemaId}${status}`, jsonResponseSchema, op)
450
+ if (isHttpApi) {
451
+ representable.push({
452
+ contentType: "application/json",
453
+ encoding: "json",
454
+ schema: jsonSchemaName
455
+ })
456
+ }
457
+ }
458
+
459
+ if (isHttpApi) {
460
+ for (const [contentType, mediaType] of Object.entries(content ?? {})) {
461
+ if (contentType === "application/json") {
462
+ continue
463
+ }
464
+ if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
465
+ continue
466
+ }
467
+ const encoding = getResponseMediaTypeEncoding(contentType)
468
+ if (encoding === undefined) {
469
+ continue
228
470
  }
471
+ const schemaName = addSchema(
472
+ `${schemaId}${status}${mediaTypeToSuffix(contentType)}`,
473
+ mediaType.schema as JsonSchema.JsonSchema,
474
+ op
475
+ )
476
+ representable.push({
477
+ contentType,
478
+ encoding,
479
+ schema: schemaName
480
+ })
229
481
  }
482
+ }
483
+
484
+ const isEmptyResponse = Predicate.isUndefined(content) || Object.keys(content).length === 0
485
+ const parsedResponse = {
486
+ status: parsedStatus,
487
+ description: Utils.nonEmptyString(response.description),
488
+ contentTypes: Predicate.isNotUndefined(content) ? Object.keys(content) : [],
489
+ hasHeaders: Predicate.isNotUndefined(response.headers),
490
+ isEmpty: isEmptyResponse,
491
+ representable
492
+ }
493
+ op.responses.push(parsedResponse)
494
+ if (status === "default") {
495
+ op.defaultResponse = parsedResponse
496
+ }
497
+
498
+ if (Predicate.isNotUndefined(jsonSchemaName)) {
499
+ const schemaName = jsonSchemaName
230
500
 
231
- if (op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
232
- op.successSchemas.set("2xx", defaultSchema)
501
+ if (status === "default" && !isHttpApi) {
502
+ defaultSchema = schemaName
503
+ continue
504
+ }
505
+
506
+ const statusLower = parsedStatus.toLowerCase()
507
+ const statusMajorNumber = Number(parsedStatus[0])
508
+ if (Number.isNaN(statusMajorNumber)) {
509
+ continue
510
+ }
511
+ if (statusMajorNumber < 4) {
512
+ op.successSchemas.set(statusLower, schemaName)
513
+ } else {
514
+ op.errorSchemas.set(statusLower, schemaName)
515
+ }
516
+ }
517
+
518
+ const sseResponseSchema = content?.["text/event-stream"]?.schema
519
+ if (Predicate.isUndefined(op.sseSchema) && Predicate.isNotUndefined(sseResponseSchema)) {
520
+ const statusMajorNumber = Number(parsedStatus[0])
521
+ if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
522
+ op.sseSchema = addSchema(`${schemaId}${status}Sse`, sseResponseSchema, op)
233
523
  }
524
+ }
234
525
 
235
- operations.push(op)
526
+ if (Predicate.isNotUndefined(content?.["application/octet-stream"])) {
527
+ const statusMajorNumber = Number(parsedStatus[0])
528
+ if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
529
+ op.binaryResponse = true
530
+ }
531
+ }
532
+
533
+ if (isEmptyResponse) {
534
+ if (parsedStatus !== "default") {
535
+ op.voidSchemas.add(parsedStatus.toLowerCase())
536
+ }
236
537
  }
237
538
  }
238
539
 
239
- for (const [path, methods] of Object.entries(spec.paths)) {
240
- handlePath(path, methods)
540
+ if (!isHttpApi && op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
541
+ op.successSchemas.set("2xx", defaultSchema)
542
+ warnForOperation(emitWarning, op, {
543
+ code: "default-response-remapped",
544
+ message: "Default response was remapped to 2xx for the current HttpClient outputs."
545
+ })
241
546
  }
242
547
 
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
- })
548
+ operations.push(op)
549
+ }
550
+ }
249
551
 
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
- )
552
+ return {
553
+ metadata: {
554
+ title: spec.info.title,
555
+ version: spec.info.version,
556
+ summary: Utils.nonEmptyString(spec.info.summary),
557
+ description: Utils.nonEmptyString(spec.info.description),
558
+ license: spec.info.license,
559
+ servers: spec.servers
257
560
  },
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
- )
561
+ tags: (spec.tags ?? []).map((tag) => ({
562
+ name: tag.name,
563
+ description: Utils.nonEmptyString(tag.description),
564
+ externalDocs: tag.externalDocs
565
+ })),
566
+ securitySchemes,
567
+ operations
568
+ }
569
+ }
570
+
571
+ interface OpenApiParameter {
572
+ readonly name: string
573
+ readonly in: "path" | "query" | "header" | "cookie"
574
+ readonly required: boolean
575
+ readonly schema: {}
576
+ readonly description?: string | undefined
577
+ }
578
+
579
+ const isOpenApiParameter = (parameter: unknown): parameter is OpenApiParameter => {
580
+ if (!Predicate.isObject(parameter)) {
581
+ return false
582
+ }
583
+ return (
584
+ typeof parameter.name === "string" &&
585
+ (parameter.in === "path" || parameter.in === "query" || parameter.in === "header" || parameter.in === "cookie")
266
586
  )
587
+ }
267
588
 
268
- return { generate } as const
269
- })
589
+ const resolveOperationParameters = (
590
+ pathParameters: ReadonlyArray<unknown> | undefined,
591
+ operationParameters: ReadonlyArray<unknown> | undefined,
592
+ resolveRef: (ref: string) => unknown
593
+ ): Array<OpenApiParameter> => {
594
+ const resolved = new Map<string, OpenApiParameter>()
595
+ const add = (parameter: unknown): void => {
596
+ const current = resolveReference(parameter, resolveRef)
597
+ if (!isOpenApiParameter(current)) {
598
+ return
599
+ }
600
+
601
+ const key = `${current.in}:${current.name}`
602
+ if (resolved.has(key)) {
603
+ resolved.delete(key)
604
+ }
605
+ resolved.set(key, current)
606
+ }
607
+
608
+ for (const parameter of pathParameters ?? []) {
609
+ add(parameter)
610
+ }
611
+
612
+ for (const parameter of operationParameters ?? []) {
613
+ add(parameter)
614
+ }
615
+
616
+ return [...resolved.values()]
617
+ }
618
+
619
+ const buildParameterSchema = <
620
+ Parameter extends {
621
+ readonly name: string
622
+ readonly required: boolean
623
+ readonly schema: {}
624
+ readonly in?: "path" | "query" | "header" | "cookie" | undefined
625
+ }
626
+ >(
627
+ parameters: ReadonlyArray<Parameter>,
628
+ onAdded?: ((parameter: Parameter, added: Array<string>) => void) | undefined
629
+ ): {
630
+ readonly schema: JsonSchema.JsonSchema
631
+ readonly optional: boolean
632
+ } | undefined => {
633
+ if (parameters.length === 0) {
634
+ return
635
+ }
636
+
637
+ const schema = {
638
+ type: "object" as JsonSchema.Type,
639
+ properties: {} as Record<string, JsonSchema.JsonSchema>,
640
+ required: [] as Array<string>,
641
+ additionalProperties: false
642
+ }
643
+
644
+ for (const parameter of parameters) {
645
+ const paramSchema = parameter.schema as any
646
+ const added: Array<string> = []
647
+ if (
648
+ Predicate.isObject(paramSchema) &&
649
+ "properties" in paramSchema &&
650
+ Predicate.isObject(paramSchema.properties)
651
+ ) {
652
+ const required = "required" in paramSchema
653
+ ? paramSchema.required as Array<string>
654
+ : []
655
+
656
+ for (const [name, propertySchema] of Object.entries(paramSchema.properties)) {
657
+ const adjustedName = `${parameter.name}[${name}]`
658
+ schema.properties[adjustedName] = propertySchema as JsonSchema.JsonSchema
659
+ if (required.includes(name)) {
660
+ schema.required.push(adjustedName)
661
+ }
662
+ added.push(adjustedName)
663
+ }
664
+ } else {
665
+ schema.properties[parameter.name] = parameter.schema as JsonSchema.JsonSchema
666
+ if (parameter.required) {
667
+ schema.required.push(parameter.name)
668
+ }
669
+ added.push(parameter.name)
670
+ }
671
+
672
+ onAdded?.(parameter, added)
673
+ }
674
+
675
+ return {
676
+ schema,
677
+ optional: schema.required.length === 0
678
+ }
679
+ }
680
+
681
+ const mediaTypeToSuffix = (contentType: string): string => {
682
+ const normalized = contentType.toLowerCase()
683
+ switch (normalized) {
684
+ case "application/json":
685
+ return "Json"
686
+ case "multipart/form-data":
687
+ return "FormData"
688
+ case "application/x-www-form-urlencoded":
689
+ return "FormUrlEncoded"
690
+ case "text/plain":
691
+ return "Text"
692
+ case "application/octet-stream":
693
+ return "Binary"
694
+ }
695
+ const suffix = Utils.identifier(contentType)
696
+ return suffix.length > 0 ? suffix : "Body"
697
+ }
698
+
699
+ const makeHttpApiMultipartSchemaRefs = (definitions: JsonSchema.Definitions): HttpApiMultipartSchemaRefs => {
700
+ const names = new Set(Object.keys(definitions))
701
+ const allocate = (base: string): string => {
702
+ let candidate = base
703
+ let index = 2
704
+ while (names.has(candidate)) {
705
+ candidate = `${base}${index}`
706
+ index += 1
707
+ }
708
+ names.add(candidate)
709
+ return candidate
710
+ }
711
+ return {
712
+ singleFile: allocate("__HttpApiMultipartSingleFile"),
713
+ files: allocate("__HttpApiMultipartFiles")
714
+ }
715
+ }
716
+
717
+ const toDefinitionRef = (name: string): string => `#/$defs/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`
718
+
719
+ const withHttpApiMultipartSchemas = (
720
+ definitions: JsonSchema.Definitions,
721
+ multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined
722
+ ): JsonSchema.Definitions => {
723
+ if (multipartSchemaRefs === undefined) {
724
+ return definitions
725
+ }
726
+ return {
727
+ ...definitions,
728
+ [multipartSchemaRefs.singleFile]: {
729
+ type: "string",
730
+ format: "binary"
731
+ },
732
+ [multipartSchemaRefs.files]: {
733
+ type: "array",
734
+ items: {
735
+ $ref: toDefinitionRef(multipartSchemaRefs.singleFile)
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ const transformMultipartSchema = (
742
+ schema: JsonSchema.JsonSchema,
743
+ multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined,
744
+ resolveRef: (ref: string) => unknown
745
+ ): JsonSchema.JsonSchema => {
746
+ if (multipartSchemaRefs === undefined) {
747
+ return schema
748
+ }
749
+
750
+ const singleFileRef = toDefinitionRef(multipartSchemaRefs.singleFile)
751
+ const filesRef = toDefinitionRef(multipartSchemaRefs.files)
752
+ const cache = new Map<string, unknown>()
753
+ const stack = new Set<string>()
754
+
755
+ const visit = (value: unknown): unknown => {
756
+ if (Array.isArray(value)) {
757
+ return value.map(visit)
758
+ }
759
+ if (!Predicate.isObject(value)) {
760
+ return value
761
+ }
762
+
763
+ if (typeof value.$ref === "string" && value.$ref.startsWith("#/components/schemas/")) {
764
+ const cached = cache.get(value.$ref)
765
+ if (cached !== undefined) {
766
+ return cached
767
+ }
768
+ if (stack.has(value.$ref)) {
769
+ return value
770
+ }
771
+ stack.add(value.$ref)
772
+ const transformed = visit(resolveSchemaReference(value.$ref, resolveRef))
773
+ stack.delete(value.$ref)
774
+ cache.set(value.$ref, transformed)
775
+ return transformed
776
+ }
777
+
778
+ if (isMultipartBinaryFile(value)) {
779
+ return { $ref: singleFileRef }
780
+ }
781
+
782
+ const out: Record<string, unknown> = {}
783
+ for (const [key, current] of Object.entries(value)) {
784
+ out[key] = visit(current)
785
+ }
786
+
787
+ if (isMultipartBinaryFiles(out, singleFileRef)) {
788
+ return { $ref: filesRef }
789
+ }
790
+
791
+ return out
792
+ }
793
+
794
+ return visit(schema) as JsonSchema.JsonSchema
795
+ }
796
+
797
+ const resolveSchemaReference = (ref: string, resolveRef: (ref: string) => unknown): unknown => {
798
+ let current: unknown = { $ref: ref }
799
+ const seen = new Set<string>()
800
+ while (Predicate.isObject(current) && typeof current.$ref === "string") {
801
+ if (seen.has(current.$ref)) {
802
+ return current
803
+ }
804
+ seen.add(current.$ref)
805
+ current = resolveRef(current.$ref)
806
+ }
807
+ return current
808
+ }
809
+
810
+ const isMultipartBinaryFile = (value: unknown): value is JsonSchema.JsonSchema =>
811
+ Predicate.isObject(value) &&
812
+ value.type === "string" &&
813
+ (
814
+ (typeof value.format === "string" && value.format.toLowerCase() === "binary") ||
815
+ (typeof value.contentEncoding === "string" && value.contentEncoding.toLowerCase() === "binary")
816
+ )
817
+
818
+ const isMultipartBinaryFiles = (value: Record<string, unknown>, singleFileRef: string): boolean => {
819
+ if (value.type !== "array") {
820
+ return false
821
+ }
822
+ const items = value.items
823
+ return isMultipartBinaryFile(items) || (Predicate.isObject(items) && items.$ref === singleFileRef)
824
+ }
825
+
826
+ const isJsonMediaType = (contentType: string): boolean =>
827
+ contentType === "application/json" ||
828
+ (contentType.startsWith("application/") && contentType.endsWith("+json"))
829
+
830
+ const isTextMediaType = (contentType: string): boolean => contentType.startsWith("text/")
831
+
832
+ const isBinaryMediaType = (contentType: string): boolean =>
833
+ contentType === "application/octet-stream" ||
834
+ (contentType.startsWith("application/") && (contentType.includes("binary") || contentType.endsWith("+octet-stream")))
835
+
836
+ const getRequestMediaTypeEncoding = (
837
+ contentType: string
838
+ ): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
839
+ const normalized = contentType.toLowerCase()
840
+ if (isJsonMediaType(normalized)) {
841
+ return "json"
842
+ }
843
+ if (normalized === "multipart/form-data") {
844
+ return "multipart"
845
+ }
846
+ if (normalized === "application/x-www-form-urlencoded") {
847
+ return "form-url-encoded"
848
+ }
849
+ if (isTextMediaType(normalized)) {
850
+ return "text"
851
+ }
852
+ if (isBinaryMediaType(normalized)) {
853
+ return "binary"
854
+ }
855
+ return
856
+ }
857
+
858
+ const getResponseMediaTypeEncoding = (
859
+ contentType: string
860
+ ): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
861
+ const normalized = contentType.toLowerCase()
862
+ if (isJsonMediaType(normalized)) {
863
+ return "json"
864
+ }
865
+ if (normalized === "application/x-www-form-urlencoded") {
866
+ return "form-url-encoded"
867
+ }
868
+ if (isTextMediaType(normalized)) {
869
+ return "text"
870
+ }
871
+ if (isBinaryMediaType(normalized)) {
872
+ return "binary"
873
+ }
874
+ return
875
+ }
876
+
877
+ const resolveReference = (input: unknown, resolveRef: (ref: string) => unknown): any => {
878
+ let current = input
879
+ while (Predicate.isObject(current) && typeof current.$ref === "string") {
880
+ current = resolveRef(current.$ref)
881
+ }
882
+ return current
883
+ }
884
+
885
+ const cloneSecurityRequirements = (
886
+ security: ReadonlyArray<Record<string, ReadonlyArray<string>>>
887
+ ): Array<ParsedOperation.ParsedOperationSecurityRequirement> =>
888
+ security.map((requirement) =>
889
+ Object.fromEntries(
890
+ Object.entries(requirement).map(([name, scopes]) => [name, [...scopes]])
891
+ )
892
+ )
893
+
894
+ const parseSecuritySchemes = (
895
+ spec: OpenAPISpec,
896
+ resolveRef: (ref: string) => unknown
897
+ ): Array<ParsedOperation.ParsedOpenApiSecurityScheme> => {
898
+ const securitySchemes = spec.components?.securitySchemes ?? {}
899
+ const parsed: Array<ParsedOperation.ParsedOpenApiSecurityScheme> = []
900
+
901
+ for (const [name, value] of Object.entries(securitySchemes)) {
902
+ const scheme = resolveReference(value, resolveRef) as OpenAPISecurityScheme | undefined
903
+ if (!Predicate.isObject(scheme)) {
904
+ continue
905
+ }
906
+
907
+ if (scheme.type === "http") {
908
+ const normalizedScheme = scheme.scheme.toLowerCase()
909
+ if (normalizedScheme === "basic") {
910
+ parsed.push({
911
+ name,
912
+ type: "basic",
913
+ description: Utils.nonEmptyString(scheme.description),
914
+ bearerFormat: undefined,
915
+ key: undefined,
916
+ in: undefined
917
+ })
918
+ } else if (normalizedScheme === "bearer") {
919
+ parsed.push({
920
+ name,
921
+ type: "bearer",
922
+ description: Utils.nonEmptyString(scheme.description),
923
+ bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
924
+ key: undefined,
925
+ in: undefined
926
+ })
927
+ }
928
+ continue
929
+ }
930
+
931
+ if (
932
+ scheme.type === "apiKey" &&
933
+ (scheme.in === "header" || scheme.in === "query" || scheme.in === "cookie") &&
934
+ typeof scheme.name === "string"
935
+ ) {
936
+ parsed.push({
937
+ name,
938
+ type: "apiKey",
939
+ description: Utils.nonEmptyString(scheme.description),
940
+ bearerFormat: undefined,
941
+ key: scheme.name,
942
+ in: scheme.in
943
+ })
944
+ }
945
+ }
946
+
947
+ return parsed
948
+ }
949
+
950
+ const warnForAndSecurityRequirements = (
951
+ emitWarning: WarningEmitter,
952
+ operation: ParsedOperation.ParsedOperation
953
+ ): void => {
954
+ if (operation.effectiveSecurity.some((requirement) => Object.keys(requirement).length === 0)) {
955
+ return
956
+ }
957
+ for (const requirement of operation.effectiveSecurity) {
958
+ const schemes = Object.keys(requirement)
959
+ if (schemes.length <= 1) {
960
+ continue
961
+ }
962
+ warnForOperation(emitWarning, operation, {
963
+ code: "security-and-downgraded",
964
+ message: `Security requirement requiring all of [${
965
+ schemes.join(", ")
966
+ }] was downgraded to a placeholder middleware.`
967
+ })
968
+ }
969
+ }
970
+
971
+ const hasSuccessfulSseResponse = (
972
+ responses: ReadonlyArray<readonly [string, unknown]>,
973
+ hasExplicitSuccessResponse: boolean
974
+ ): boolean => {
975
+ for (const [status, response] of responses) {
976
+ if (!Predicate.isObject(response)) {
977
+ continue
978
+ }
979
+ const content = Predicate.isObject(response.content)
980
+ ? response.content as Record<string, any>
981
+ : undefined
982
+ if (Predicate.isUndefined(content?.["text/event-stream"]?.schema)) {
983
+ continue
984
+ }
985
+
986
+ const remappedStatus = remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
987
+ const statusCode = Number(remappedStatus)
988
+ if (!Number.isNaN(statusCode) && statusCode < 400) {
989
+ return true
990
+ }
991
+ }
992
+ return false
993
+ }
994
+
995
+ const remapDefaultResponseStatusForHttpApi = (status: string, hasExplicitSuccessResponse: boolean): string =>
996
+ status === "default" ? (hasExplicitSuccessResponse ? "500" : "200") : status
997
+
998
+ const methodSupportsRequestBody = (method: OpenAPISpecMethodName): boolean =>
999
+ method !== "get" && method !== "head" && method !== "options" && method !== "trace"
1000
+
1001
+ const warnForOperation = (
1002
+ emitWarning: WarningEmitter,
1003
+ operation: ParsedOperation.ParsedOperation,
1004
+ warning: {
1005
+ readonly code: OpenApiGeneratorWarningCode
1006
+ readonly message: string
1007
+ }
1008
+ ): void => {
1009
+ emitWarning({
1010
+ ...warning,
1011
+ path: operation.path,
1012
+ method: operation.method,
1013
+ operationId: operation.operationId
1014
+ })
1015
+ }
270
1016
 
271
1017
  function getDialect(spec: OpenAPISpec): "openapi-3.0" | "openapi-3.1" {
272
1018
  return spec.openapi.trim().startsWith("3.0") ? "openapi-3.0" : "openapi-3.1"