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