1202 lines
38 KiB
TypeScript
1202 lines
38 KiB
TypeScript
/**
|
|
* The `OpenApiGenerator` module orchestrates converting OpenAPI and Swagger
|
|
* documents into generated Effect source.
|
|
*
|
|
* It normalizes Swagger 2.0 input, resolves local references, builds the
|
|
* parsed operation model, registers request and response schemas, applies
|
|
* HttpApi-specific adaptations such as multipart helpers and security metadata,
|
|
* emits warnings for unsupported or lossy OpenAPI features, and then delegates
|
|
* final rendering to the HttpClient or HttpApi code generators.
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
import * as Context from "effect/Context"
|
|
import * as Effect from "effect/Effect"
|
|
import type * as JsonSchema from "effect/JsonSchema"
|
|
import * as Layer from "effect/Layer"
|
|
import * as Predicate from "effect/Predicate"
|
|
import * as String from "effect/String"
|
|
import type { OpenAPISecurityScheme, OpenAPISpec, OpenAPISpecMethodName } from "effect/unstable/httpapi/OpenApi"
|
|
import SwaggerToOpenApi from "swagger2openapi"
|
|
import * as HttpApiTransformer from "./HttpApiTransformer.ts"
|
|
import * as JsonSchemaGenerator from "./JsonSchemaGenerator.ts"
|
|
import * as OpenApiTransformer from "./OpenApiTransformer.ts"
|
|
import * as ParsedOperation from "./ParsedOperation.ts"
|
|
import * as Utils from "./Utils.ts"
|
|
|
|
/**
|
|
* Service for turning OpenAPI or Swagger specifications into generated Effect
|
|
* HTTP client or HttpApi source code.
|
|
*
|
|
* @category services
|
|
* @since 4.0.0
|
|
*/
|
|
export class OpenApiGenerator extends Context.Service<
|
|
OpenApiGenerator,
|
|
{ readonly generate: (spec: OpenAPISpec, options: OpenApiGenerateOptions) => Effect.Effect<string> }
|
|
>()("OpenApiGenerator") {}
|
|
|
|
/**
|
|
* Output targets supported by the OpenAPI generator.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export type OpenApiGeneratorFormat = "httpclient" | "httpclient-type-only" | "httpapi"
|
|
|
|
/**
|
|
* Stable identifiers for non-fatal OpenAPI generation warnings.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export type OpenApiGeneratorWarningCode =
|
|
| "cookie-parameter-dropped"
|
|
| "additional-tags-dropped"
|
|
| "sse-operation-skipped"
|
|
| "response-headers-ignored"
|
|
| "optional-request-body-approximated"
|
|
| "default-response-remapped"
|
|
| "security-and-downgraded"
|
|
| "no-body-method-request-body-skipped"
|
|
| "naming-collision"
|
|
|
|
/**
|
|
* Describes a non-fatal issue encountered while mapping an OpenAPI operation to
|
|
* generated Effect source.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface OpenApiGeneratorWarning {
|
|
readonly code: OpenApiGeneratorWarningCode
|
|
readonly message: string
|
|
readonly path?: string | undefined
|
|
readonly method?: OpenAPISpecMethodName | undefined
|
|
readonly operationId?: string | undefined
|
|
}
|
|
|
|
/**
|
|
* Options that control one OpenAPI generation run.
|
|
*
|
|
* @category options
|
|
* @since 4.0.0
|
|
*/
|
|
export interface OpenApiGenerateOptions {
|
|
/**
|
|
* The name to give to the generated output.
|
|
*/
|
|
readonly name: string
|
|
/**
|
|
* The output format to generate.
|
|
*/
|
|
readonly format: OpenApiGeneratorFormat
|
|
/**
|
|
* Hook to transform each JSON Schema node before processing.
|
|
*/
|
|
readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
|
|
/**
|
|
* Callback to receive non-fatal generation warnings.
|
|
*/
|
|
readonly onWarning?: ((warning: OpenApiGeneratorWarning) => void) | undefined
|
|
}
|
|
|
|
interface HttpApiMultipartSchemaRefs {
|
|
readonly singleFile: string
|
|
readonly files: string
|
|
}
|
|
|
|
const methodNames: ReadonlyArray<OpenAPISpecMethodName> = [
|
|
"get",
|
|
"put",
|
|
"post",
|
|
"delete",
|
|
"options",
|
|
"head",
|
|
"patch",
|
|
"trace"
|
|
]
|
|
|
|
/**
|
|
* Constructs the OpenAPI generator service implementation.
|
|
*
|
|
* @category constructors
|
|
* @since 4.0.0
|
|
*/
|
|
export const make = Effect.gen(function*() {
|
|
const generate = Effect.fn(
|
|
function*(spec: OpenAPISpec, options: OpenApiGenerateOptions) {
|
|
const generator = JsonSchemaGenerator.make()
|
|
const openApiTransformer = yield* OpenApiTransformer.OpenApiTransformer
|
|
const emitWarning = makeWarningEmitter(options)
|
|
|
|
// If we receive a Swagger 2.0 spec, convert it to an OpenApi 3.0 spec
|
|
if (isSwaggerSpec(spec)) {
|
|
spec = yield* convertSwaggerSpec(spec)
|
|
}
|
|
|
|
function resolveRef(ref: string) {
|
|
const parts = ref.split("/").slice(1)
|
|
let current: any = spec
|
|
for (const part of parts) {
|
|
current = current[part]
|
|
}
|
|
return current
|
|
}
|
|
|
|
const multipartSchemaRefs = options.format === "httpapi"
|
|
? makeHttpApiMultipartSchemaRefs(spec.components?.schemas ?? {})
|
|
: undefined
|
|
|
|
const parsed = parseOpenApi(spec, generator, resolveRef, options.format, emitWarning, multipartSchemaRefs)
|
|
|
|
// TODO: make a CLI option ?
|
|
const importName = "Schema"
|
|
const source = getDialect(spec)
|
|
const generation = options.format === "httpapi"
|
|
? generator.generateHttpApi(
|
|
source,
|
|
withHttpApiMultipartSchemas(spec.components?.schemas ?? {}, multipartSchemaRefs),
|
|
{
|
|
onEnter: options.onEnter,
|
|
multipartSchemaRefs
|
|
}
|
|
)
|
|
: generator.generate(
|
|
source,
|
|
spec.components?.schemas ?? {},
|
|
options.format === "httpclient-type-only",
|
|
{
|
|
onEnter: options.onEnter
|
|
}
|
|
)
|
|
|
|
if (options.format === "httpapi") {
|
|
const needsMultipartImport = generation.includes("Multipart.")
|
|
return String.stripMargin(
|
|
`|${HttpApiTransformer.imports(importName, { multipart: needsMultipartImport })}
|
|
|${generation}
|
|
|${HttpApiTransformer.toImplementation(importName, options.name, parsed)}`
|
|
)
|
|
}
|
|
|
|
return String.stripMargin(
|
|
`|${openApiTransformer.imports(importName, parsed)}
|
|
|${generation}
|
|
|${openApiTransformer.toImplementation(importName, options.name, parsed)}
|
|
|
|
|
|${openApiTransformer.toTypes(importName, options.name, parsed)}`
|
|
)
|
|
},
|
|
(effect, _, options) =>
|
|
Effect.provideServiceEffect(
|
|
effect,
|
|
OpenApiTransformer.OpenApiTransformer,
|
|
options.format === "httpclient-type-only"
|
|
? Effect.sync(OpenApiTransformer.makeTransformerTs)
|
|
: Effect.sync(OpenApiTransformer.makeTransformerSchema)
|
|
)
|
|
)
|
|
|
|
return { generate } as const
|
|
})
|
|
|
|
type WarningEmitter = (warning: OpenApiGeneratorWarning) => void
|
|
|
|
const makeWarningEmitter = (options: OpenApiGenerateOptions): WarningEmitter => (warning) => {
|
|
options.onWarning?.(warning)
|
|
}
|
|
|
|
const parseOpenApi = (
|
|
spec: OpenAPISpec,
|
|
generator: ReturnType<typeof JsonSchemaGenerator.make>,
|
|
resolveRef: (ref: string) => unknown,
|
|
format: OpenApiGeneratorFormat,
|
|
emitWarning: WarningEmitter,
|
|
multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined
|
|
): ParsedOperation.ParsedOpenApi => {
|
|
const operations: Array<ParsedOperation.ParsedOperation> = []
|
|
const reservedSchemaNames = new Set<string>(Object.keys(spec.components?.schemas ?? {}))
|
|
const isHttpApi = format === "httpapi"
|
|
const securitySchemes = isHttpApi ? parseSecuritySchemes(spec, resolveRef) : []
|
|
|
|
const addSchema = (
|
|
baseName: string,
|
|
schema: JsonSchema.JsonSchema,
|
|
operation: ParsedOperation.ParsedOperation
|
|
): string => {
|
|
let candidate = baseName
|
|
let index = 2
|
|
while (reservedSchemaNames.has(candidate)) {
|
|
candidate = `${baseName}${index}`
|
|
index += 1
|
|
}
|
|
if (candidate !== baseName) {
|
|
warnForOperation(emitWarning, operation, {
|
|
code: "naming-collision",
|
|
message: `Schema name "${baseName}" collided with an existing name and was renamed to "${candidate}".`
|
|
})
|
|
}
|
|
reservedSchemaNames.add(candidate)
|
|
return generator.addSchema(candidate, schema)
|
|
}
|
|
|
|
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
for (const method of methodNames) {
|
|
const operation = methods[method]
|
|
|
|
if (Predicate.isUndefined(operation)) {
|
|
continue
|
|
}
|
|
|
|
const id = operation.operationId
|
|
? Utils.camelize(operation.operationId)
|
|
: `${method.toUpperCase()}${path}`
|
|
|
|
const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
|
|
|
|
const { pathIds, pathTemplate } = processPath(path)
|
|
|
|
const op = ParsedOperation.makeDeepMutable({
|
|
id,
|
|
method,
|
|
description,
|
|
pathIds,
|
|
pathTemplate
|
|
})
|
|
op.path = path
|
|
op.operationId = Utils.nonEmptyString(operation.operationId)
|
|
op.tags = [...(operation.tags ?? [])]
|
|
if (isHttpApi && op.tags.length > 1) {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "additional-tags-dropped",
|
|
message: `Additional tags (${op.tags.slice(1).join(", ")}) were dropped. Only the first tag ("${
|
|
op.tags[0]
|
|
}") is used for grouping.`
|
|
})
|
|
}
|
|
op.metadata = {
|
|
summary: Utils.nonEmptyString(operation.summary),
|
|
description: Utils.nonEmptyString(operation.description),
|
|
deprecated: operation.deprecated === true,
|
|
externalDocs: operation.externalDocs
|
|
}
|
|
op.effectiveSecurity = cloneSecurityRequirements(operation.security ?? spec.security ?? [])
|
|
if (isHttpApi) {
|
|
warnForAndSecurityRequirements(emitWarning, op)
|
|
}
|
|
|
|
const schemaId = Utils.identifier(operation.operationId ?? path)
|
|
|
|
const pathParameters = Predicate.isObject(methods) && Array.isArray((methods as any).parameters)
|
|
? (methods as any).parameters as ReadonlyArray<unknown>
|
|
: undefined
|
|
const parameters = resolveOperationParameters(
|
|
pathParameters,
|
|
Array.isArray(operation.parameters) ? operation.parameters : undefined,
|
|
resolveRef
|
|
)
|
|
for (const parameter of parameters) {
|
|
const parsedParameter: ParsedOperation.ParsedOperationParameter = {
|
|
name: parameter.name,
|
|
in: parameter.in,
|
|
required: parameter.required === true,
|
|
description: Utils.nonEmptyString(parameter.description),
|
|
schema: parameter.schema
|
|
}
|
|
switch (parameter.in) {
|
|
case "path": {
|
|
op.parameters.path.push(parsedParameter)
|
|
break
|
|
}
|
|
case "query": {
|
|
op.parameters.query.push(parsedParameter)
|
|
break
|
|
}
|
|
case "header": {
|
|
op.parameters.header.push(parsedParameter)
|
|
break
|
|
}
|
|
case "cookie": {
|
|
op.parameters.cookie.push(parsedParameter)
|
|
warnForOperation(emitWarning, op, {
|
|
code: "cookie-parameter-dropped",
|
|
message:
|
|
`Cookie parameter "${parameter.name}" was dropped because non-security cookie parameters are not supported.`
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const requestBody = resolveReference(operation.requestBody, resolveRef)
|
|
if (isHttpApi && !methodSupportsRequestBody(op.method) && Predicate.isObject(requestBody)) {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "no-body-method-request-body-skipped",
|
|
message: `Operation was skipped because ${op.method.toUpperCase()} does not support request bodies.`
|
|
})
|
|
continue
|
|
}
|
|
|
|
const resolvedResponses = Object.entries(operation.responses ?? {}).map(
|
|
([status, response]) => [status, resolveReference(response, resolveRef)] as const
|
|
)
|
|
const hasExplicitSuccessResponse = resolvedResponses.some(([status]) => {
|
|
if (!/^\d{3}$/.test(status)) {
|
|
return false
|
|
}
|
|
return Number(status) < 400
|
|
})
|
|
|
|
if (isHttpApi && hasUnsupportedSuccessfulSseResponse(resolvedResponses, hasExplicitSuccessResponse)) {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "sse-operation-skipped",
|
|
message:
|
|
"Operation was skipped because a successful SSE response is missing x-effect-stream metadata required for HttpApi generation."
|
|
})
|
|
continue
|
|
}
|
|
|
|
const validParameters = parameters.filter((parameter) => parameter.in !== "path" && parameter.in !== "cookie")
|
|
|
|
const combinedParameterSchema = buildParameterSchema(validParameters, (parameter, added) => {
|
|
if (parameter.in === "query") {
|
|
Utils.spreadElementsInto(added, op.urlParams)
|
|
} else if (parameter.in === "header") {
|
|
Utils.spreadElementsInto(added, op.headers)
|
|
} else if (parameter.in === "cookie") {
|
|
Utils.spreadElementsInto(added, op.cookies)
|
|
}
|
|
})
|
|
|
|
if (combinedParameterSchema !== undefined) {
|
|
op.params = addSchema(`${schemaId}Params`, combinedParameterSchema.schema, op)
|
|
op.paramsOptional = combinedParameterSchema.optional
|
|
}
|
|
|
|
if (isHttpApi) {
|
|
const pathParameterSchema = buildParameterSchema(op.parameters.path)
|
|
if (pathParameterSchema !== undefined) {
|
|
op.pathSchema = addSchema(`${schemaId}PathParams`, pathParameterSchema.schema, op)
|
|
}
|
|
|
|
const queryParameterSchema = buildParameterSchema(op.parameters.query)
|
|
if (queryParameterSchema !== undefined) {
|
|
op.querySchema = addSchema(`${schemaId}Query`, queryParameterSchema.schema, op)
|
|
op.querySchemaOptional = queryParameterSchema.optional
|
|
}
|
|
|
|
const headerParameterSchema = buildParameterSchema(op.parameters.header)
|
|
if (headerParameterSchema !== undefined) {
|
|
op.headersSchema = addSchema(`${schemaId}Headers`, headerParameterSchema.schema, op)
|
|
op.headersSchemaOptional = headerParameterSchema.optional
|
|
}
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(requestBody) && Predicate.isObject(requestBody)) {
|
|
const content = Predicate.isObject(requestBody.content)
|
|
? requestBody.content as Record<string, any>
|
|
: {}
|
|
const requestSchemaNames = new Map<string, string>()
|
|
op.requestBody = {
|
|
required: requestBody.required === true,
|
|
contentTypes: Object.keys(content)
|
|
}
|
|
if (isHttpApi && requestBody.required === false) {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "optional-request-body-approximated",
|
|
message: "Optional request body was approximated by adding a no-content payload alternative."
|
|
})
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(content["application/json"]?.schema)) {
|
|
op.payload = addSchema(`${schemaId}RequestJson`, content["application/json"].schema, op)
|
|
requestSchemaNames.set("application/json", op.payload)
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(content["multipart/form-data"]?.schema)) {
|
|
op.payload = addSchema(
|
|
`${schemaId}RequestFormData`,
|
|
transformMultipartSchema(content["multipart/form-data"].schema, multipartSchemaRefs, resolveRef),
|
|
op
|
|
)
|
|
op.payloadFormData = true
|
|
requestSchemaNames.set("multipart/form-data", op.payload)
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(content["application/x-www-form-urlencoded"]?.schema)) {
|
|
op.payload = addSchema(
|
|
`${schemaId}RequestFormUrlEncoded`,
|
|
content["application/x-www-form-urlencoded"].schema,
|
|
op
|
|
)
|
|
op.payloadFormUrlEncoded = true
|
|
requestSchemaNames.set("application/x-www-form-urlencoded", op.payload)
|
|
}
|
|
|
|
if (isHttpApi) {
|
|
const representableRequestBody: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
|
|
for (const [contentType, mediaType] of Object.entries(content)) {
|
|
if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
|
|
continue
|
|
}
|
|
const encoding = getRequestMediaTypeEncoding(contentType)
|
|
if (encoding === undefined) {
|
|
continue
|
|
}
|
|
let schemaName = requestSchemaNames.get(contentType)
|
|
if (schemaName === undefined) {
|
|
const schema = encoding === "multipart"
|
|
? transformMultipartSchema(mediaType.schema as JsonSchema.JsonSchema, multipartSchemaRefs, resolveRef)
|
|
: mediaType.schema as JsonSchema.JsonSchema
|
|
schemaName = addSchema(
|
|
`${schemaId}Request${mediaTypeToSuffix(contentType)}`,
|
|
schema,
|
|
op
|
|
)
|
|
requestSchemaNames.set(contentType, schemaName)
|
|
}
|
|
representableRequestBody.push({
|
|
contentType,
|
|
encoding,
|
|
schema: schemaName
|
|
})
|
|
}
|
|
op.requestBodyRepresentable = representableRequestBody
|
|
}
|
|
}
|
|
|
|
let defaultSchema: string | undefined
|
|
for (const [status, response] of resolvedResponses) {
|
|
if (!Predicate.isObject(response)) {
|
|
continue
|
|
}
|
|
|
|
const parsedStatus = isHttpApi
|
|
? remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
|
|
: status
|
|
if (isHttpApi && status === "default") {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "default-response-remapped",
|
|
message: `Default response was remapped to status ${parsedStatus} for HttpApi generation.`
|
|
})
|
|
}
|
|
|
|
const content = Predicate.isObject(response.content)
|
|
? response.content as Record<string, any>
|
|
: undefined
|
|
if (isHttpApi && Predicate.isNotUndefined(response.headers)) {
|
|
warnForOperation(emitWarning, op, {
|
|
code: "response-headers-ignored",
|
|
message: `Response headers on status ${status} were ignored in HttpApi generation.`
|
|
})
|
|
}
|
|
const representable: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
|
|
|
|
let jsonSchemaName: string | undefined
|
|
const jsonResponseSchema = content?.["application/json"]?.schema
|
|
if (Predicate.isNotUndefined(jsonResponseSchema)) {
|
|
jsonSchemaName = addSchema(`${schemaId}${status}`, jsonResponseSchema, op)
|
|
if (isHttpApi) {
|
|
representable.push({
|
|
contentType: "application/json",
|
|
encoding: "json",
|
|
schema: jsonSchemaName
|
|
})
|
|
}
|
|
}
|
|
|
|
if (isHttpApi) {
|
|
for (const [contentType, mediaType] of Object.entries(content ?? {})) {
|
|
if (contentType === "application/json") {
|
|
continue
|
|
}
|
|
if (!Predicate.isObject(mediaType)) {
|
|
continue
|
|
}
|
|
|
|
const statusMajorNumber = Number(parsedStatus[0])
|
|
const streamEncoding = !Number.isNaN(statusMajorNumber) && statusMajorNumber < 4
|
|
? getEffectStreamEncoding(mediaType)
|
|
: undefined
|
|
if (streamEncoding === "uint8array") {
|
|
representable.push({
|
|
contentType,
|
|
encoding: "binary",
|
|
effectStream: "uint8array"
|
|
})
|
|
continue
|
|
}
|
|
if (streamEncoding === "sse") {
|
|
const errorSchema = getEffectStreamErrorSchema(mediaType)
|
|
if (Predicate.isUndefined(mediaType.schema) || Predicate.isUndefined(errorSchema)) {
|
|
continue
|
|
}
|
|
representable.push({
|
|
contentType,
|
|
encoding: "text",
|
|
effectStream: "sse",
|
|
schema: addSchema(
|
|
`${schemaId}${status}Sse`,
|
|
mediaType.schema as JsonSchema.JsonSchema,
|
|
op
|
|
),
|
|
errorSchema: addSchema(
|
|
`${schemaId}${status}SseError`,
|
|
errorSchema,
|
|
op
|
|
)
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (Predicate.isUndefined(mediaType.schema)) {
|
|
continue
|
|
}
|
|
const encoding = getResponseMediaTypeEncoding(contentType)
|
|
if (encoding === undefined) {
|
|
continue
|
|
}
|
|
const schemaName = addSchema(
|
|
`${schemaId}${status}${mediaTypeToSuffix(contentType)}`,
|
|
mediaType.schema as JsonSchema.JsonSchema,
|
|
op
|
|
)
|
|
representable.push({
|
|
contentType,
|
|
encoding,
|
|
schema: schemaName
|
|
})
|
|
}
|
|
}
|
|
|
|
const isEmptyResponse = Predicate.isUndefined(content) || Object.keys(content).length === 0
|
|
const parsedResponse = {
|
|
status: parsedStatus,
|
|
description: Utils.nonEmptyString(response.description),
|
|
contentTypes: Predicate.isNotUndefined(content) ? Object.keys(content) : [],
|
|
hasHeaders: Predicate.isNotUndefined(response.headers),
|
|
isEmpty: isEmptyResponse,
|
|
representable
|
|
}
|
|
op.responses.push(parsedResponse)
|
|
if (status === "default") {
|
|
op.defaultResponse = parsedResponse
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(jsonSchemaName)) {
|
|
const schemaName = jsonSchemaName
|
|
|
|
if (status === "default" && !isHttpApi) {
|
|
defaultSchema = schemaName
|
|
continue
|
|
}
|
|
|
|
const statusLower = parsedStatus.toLowerCase()
|
|
const statusMajorNumber = Number(parsedStatus[0])
|
|
if (Number.isNaN(statusMajorNumber)) {
|
|
continue
|
|
}
|
|
if (statusMajorNumber < 4) {
|
|
op.successSchemas.set(statusLower, schemaName)
|
|
} else {
|
|
op.errorSchemas.set(statusLower, schemaName)
|
|
}
|
|
}
|
|
|
|
const sseResponseSchema = content?.["text/event-stream"]?.schema
|
|
if (!isHttpApi && Predicate.isUndefined(op.sseSchema) && Predicate.isNotUndefined(sseResponseSchema)) {
|
|
const statusMajorNumber = Number(parsedStatus[0])
|
|
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
|
|
op.sseSchema = addSchema(`${schemaId}${status}Sse`, sseResponseSchema, op)
|
|
}
|
|
}
|
|
|
|
if (Predicate.isNotUndefined(content?.["application/octet-stream"])) {
|
|
const statusMajorNumber = Number(parsedStatus[0])
|
|
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
|
|
op.binaryResponse = true
|
|
}
|
|
}
|
|
|
|
if (isEmptyResponse) {
|
|
if (parsedStatus !== "default") {
|
|
op.voidSchemas.add(parsedStatus.toLowerCase())
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isHttpApi && op.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)) {
|
|
op.successSchemas.set("2xx", defaultSchema)
|
|
warnForOperation(emitWarning, op, {
|
|
code: "default-response-remapped",
|
|
message: "Default response was remapped to 2xx for the current HttpClient outputs."
|
|
})
|
|
}
|
|
|
|
operations.push(op)
|
|
}
|
|
}
|
|
|
|
return {
|
|
metadata: {
|
|
title: spec.info.title,
|
|
version: spec.info.version,
|
|
summary: Utils.nonEmptyString(spec.info.summary),
|
|
description: Utils.nonEmptyString(spec.info.description),
|
|
license: spec.info.license,
|
|
servers: spec.servers
|
|
},
|
|
tags: (spec.tags ?? []).map((tag) => ({
|
|
name: tag.name,
|
|
description: Utils.nonEmptyString(tag.description),
|
|
externalDocs: tag.externalDocs
|
|
})),
|
|
securitySchemes,
|
|
operations
|
|
}
|
|
}
|
|
|
|
interface OpenApiParameter {
|
|
readonly name: string
|
|
readonly in: "path" | "query" | "header" | "cookie"
|
|
readonly required: boolean
|
|
readonly schema: {}
|
|
readonly description?: string | undefined
|
|
}
|
|
|
|
const isOpenApiParameter = (parameter: unknown): parameter is OpenApiParameter => {
|
|
if (!Predicate.isObject(parameter)) {
|
|
return false
|
|
}
|
|
return (
|
|
typeof parameter.name === "string" &&
|
|
(parameter.in === "path" || parameter.in === "query" || parameter.in === "header" || parameter.in === "cookie")
|
|
)
|
|
}
|
|
|
|
const resolveOperationParameters = (
|
|
pathParameters: ReadonlyArray<unknown> | undefined,
|
|
operationParameters: ReadonlyArray<unknown> | undefined,
|
|
resolveRef: (ref: string) => unknown
|
|
): Array<OpenApiParameter> => {
|
|
const resolved = new Map<string, OpenApiParameter>()
|
|
const add = (parameter: unknown): void => {
|
|
const current = resolveReference(parameter, resolveRef)
|
|
if (!isOpenApiParameter(current)) {
|
|
return
|
|
}
|
|
|
|
const key = `${current.in}:${current.name}`
|
|
if (resolved.has(key)) {
|
|
resolved.delete(key)
|
|
}
|
|
resolved.set(key, current)
|
|
}
|
|
|
|
for (const parameter of pathParameters ?? []) {
|
|
add(parameter)
|
|
}
|
|
|
|
for (const parameter of operationParameters ?? []) {
|
|
add(parameter)
|
|
}
|
|
|
|
return [...resolved.values()]
|
|
}
|
|
|
|
const buildParameterSchema = <
|
|
Parameter extends {
|
|
readonly name: string
|
|
readonly required: boolean
|
|
readonly schema: {}
|
|
readonly in?: "path" | "query" | "header" | "cookie" | undefined
|
|
}
|
|
>(
|
|
parameters: ReadonlyArray<Parameter>,
|
|
onAdded?: ((parameter: Parameter, added: Array<string>) => void) | undefined
|
|
): {
|
|
readonly schema: JsonSchema.JsonSchema
|
|
readonly optional: boolean
|
|
} | undefined => {
|
|
if (parameters.length === 0) {
|
|
return
|
|
}
|
|
|
|
const schema = {
|
|
type: "object" as JsonSchema.Type,
|
|
properties: {} as Record<string, JsonSchema.JsonSchema>,
|
|
required: [] as Array<string>,
|
|
additionalProperties: false
|
|
}
|
|
|
|
for (const parameter of parameters) {
|
|
const paramSchema = parameter.schema as any
|
|
const added: Array<string> = []
|
|
if (
|
|
Predicate.isObject(paramSchema) &&
|
|
"properties" in paramSchema &&
|
|
Predicate.isObject(paramSchema.properties)
|
|
) {
|
|
const required = "required" in paramSchema
|
|
? paramSchema.required as Array<string>
|
|
: []
|
|
|
|
for (const [name, propertySchema] of Object.entries(paramSchema.properties)) {
|
|
const adjustedName = `${parameter.name}[${name}]`
|
|
schema.properties[adjustedName] = propertySchema as JsonSchema.JsonSchema
|
|
if (required.includes(name)) {
|
|
schema.required.push(adjustedName)
|
|
}
|
|
added.push(adjustedName)
|
|
}
|
|
} else {
|
|
schema.properties[parameter.name] = parameter.schema as JsonSchema.JsonSchema
|
|
if (parameter.required) {
|
|
schema.required.push(parameter.name)
|
|
}
|
|
added.push(parameter.name)
|
|
}
|
|
|
|
onAdded?.(parameter, added)
|
|
}
|
|
|
|
return {
|
|
schema,
|
|
optional: schema.required.length === 0
|
|
}
|
|
}
|
|
|
|
const mediaTypeToSuffix = (contentType: string): string => {
|
|
const normalized = contentType.toLowerCase()
|
|
switch (normalized) {
|
|
case "application/json":
|
|
return "Json"
|
|
case "multipart/form-data":
|
|
return "FormData"
|
|
case "application/x-www-form-urlencoded":
|
|
return "FormUrlEncoded"
|
|
case "text/plain":
|
|
return "Text"
|
|
case "application/octet-stream":
|
|
return "Binary"
|
|
}
|
|
const suffix = Utils.identifier(contentType)
|
|
return suffix.length > 0 ? suffix : "Body"
|
|
}
|
|
|
|
const makeHttpApiMultipartSchemaRefs = (definitions: JsonSchema.Definitions): HttpApiMultipartSchemaRefs => {
|
|
const names = new Set(Object.keys(definitions))
|
|
const allocate = (base: string): string => {
|
|
let candidate = base
|
|
let index = 2
|
|
while (names.has(candidate)) {
|
|
candidate = `${base}${index}`
|
|
index += 1
|
|
}
|
|
names.add(candidate)
|
|
return candidate
|
|
}
|
|
return {
|
|
singleFile: allocate("__HttpApiMultipartSingleFile"),
|
|
files: allocate("__HttpApiMultipartFiles")
|
|
}
|
|
}
|
|
|
|
const toDefinitionRef = (name: string): string => `#/$defs/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`
|
|
|
|
const withHttpApiMultipartSchemas = (
|
|
definitions: JsonSchema.Definitions,
|
|
multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined
|
|
): JsonSchema.Definitions => {
|
|
if (multipartSchemaRefs === undefined) {
|
|
return definitions
|
|
}
|
|
return {
|
|
...definitions,
|
|
[multipartSchemaRefs.singleFile]: {
|
|
type: "string",
|
|
format: "binary"
|
|
},
|
|
[multipartSchemaRefs.files]: {
|
|
type: "array",
|
|
items: {
|
|
$ref: toDefinitionRef(multipartSchemaRefs.singleFile)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const transformMultipartSchema = (
|
|
schema: JsonSchema.JsonSchema,
|
|
multipartSchemaRefs: HttpApiMultipartSchemaRefs | undefined,
|
|
resolveRef: (ref: string) => unknown
|
|
): JsonSchema.JsonSchema => {
|
|
if (multipartSchemaRefs === undefined) {
|
|
return schema
|
|
}
|
|
|
|
const singleFileRef = toDefinitionRef(multipartSchemaRefs.singleFile)
|
|
const filesRef = toDefinitionRef(multipartSchemaRefs.files)
|
|
const cache = new Map<string, unknown>()
|
|
const stack = new Set<string>()
|
|
|
|
const visit = (value: unknown): unknown => {
|
|
if (Array.isArray(value)) {
|
|
return value.map(visit)
|
|
}
|
|
if (!Predicate.isObject(value)) {
|
|
return value
|
|
}
|
|
|
|
if (typeof value.$ref === "string" && value.$ref.startsWith("#/components/schemas/")) {
|
|
const cached = cache.get(value.$ref)
|
|
if (cached !== undefined) {
|
|
return cached
|
|
}
|
|
if (stack.has(value.$ref)) {
|
|
return value
|
|
}
|
|
stack.add(value.$ref)
|
|
const transformed = visit(resolveSchemaReference(value.$ref, resolveRef))
|
|
stack.delete(value.$ref)
|
|
cache.set(value.$ref, transformed)
|
|
return transformed
|
|
}
|
|
|
|
if (isMultipartBinaryFile(value)) {
|
|
return { $ref: singleFileRef }
|
|
}
|
|
|
|
const out: Record<string, unknown> = {}
|
|
for (const [key, current] of Object.entries(value)) {
|
|
out[key] = visit(current)
|
|
}
|
|
|
|
if (isMultipartBinaryFiles(out, singleFileRef)) {
|
|
return { $ref: filesRef }
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
return visit(schema) as JsonSchema.JsonSchema
|
|
}
|
|
|
|
const resolveSchemaReference = (ref: string, resolveRef: (ref: string) => unknown): unknown => {
|
|
let current: unknown = { $ref: ref }
|
|
const seen = new Set<string>()
|
|
while (Predicate.isObject(current) && typeof current.$ref === "string") {
|
|
if (seen.has(current.$ref)) {
|
|
return current
|
|
}
|
|
seen.add(current.$ref)
|
|
current = resolveRef(current.$ref)
|
|
}
|
|
return current
|
|
}
|
|
|
|
const isMultipartBinaryFile = (value: unknown): value is JsonSchema.JsonSchema =>
|
|
Predicate.isObject(value) &&
|
|
value.type === "string" &&
|
|
(
|
|
(typeof value.format === "string" && value.format.toLowerCase() === "binary") ||
|
|
(typeof value.contentEncoding === "string" && value.contentEncoding.toLowerCase() === "binary")
|
|
)
|
|
|
|
const isMultipartBinaryFiles = (value: Record<string, unknown>, singleFileRef: string): boolean => {
|
|
if (value.type !== "array") {
|
|
return false
|
|
}
|
|
const items = value.items
|
|
return isMultipartBinaryFile(items) || (Predicate.isObject(items) && items.$ref === singleFileRef)
|
|
}
|
|
|
|
const isJsonMediaType = (contentType: string): boolean =>
|
|
contentType === "application/json" ||
|
|
(contentType.startsWith("application/") && contentType.endsWith("+json"))
|
|
|
|
const isTextMediaType = (contentType: string): boolean => contentType.startsWith("text/")
|
|
|
|
const isBinaryMediaType = (contentType: string): boolean =>
|
|
contentType === "application/octet-stream" ||
|
|
(contentType.startsWith("application/") && (contentType.includes("binary") || contentType.endsWith("+octet-stream")))
|
|
|
|
const getRequestMediaTypeEncoding = (
|
|
contentType: string
|
|
): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
|
|
const normalized = contentType.toLowerCase()
|
|
if (isJsonMediaType(normalized)) {
|
|
return "json"
|
|
}
|
|
if (normalized === "multipart/form-data") {
|
|
return "multipart"
|
|
}
|
|
if (normalized === "application/x-www-form-urlencoded") {
|
|
return "form-url-encoded"
|
|
}
|
|
if (isTextMediaType(normalized)) {
|
|
return "text"
|
|
}
|
|
if (isBinaryMediaType(normalized)) {
|
|
return "binary"
|
|
}
|
|
return
|
|
}
|
|
|
|
const getResponseMediaTypeEncoding = (
|
|
contentType: string
|
|
): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
|
|
const normalized = contentType.toLowerCase()
|
|
if (isJsonMediaType(normalized)) {
|
|
return "json"
|
|
}
|
|
if (normalized === "application/x-www-form-urlencoded") {
|
|
return "form-url-encoded"
|
|
}
|
|
if (isTextMediaType(normalized)) {
|
|
return "text"
|
|
}
|
|
if (isBinaryMediaType(normalized)) {
|
|
return "binary"
|
|
}
|
|
return
|
|
}
|
|
|
|
const getEffectStreamEncoding = (mediaType: object): "uint8array" | "sse" | undefined => {
|
|
const stream = (mediaType as Record<string, unknown>)["x-effect-stream"]
|
|
if (!Predicate.isObject(stream)) {
|
|
return
|
|
}
|
|
return stream.encoding === "uint8array" || stream.encoding === "sse" ? stream.encoding : undefined
|
|
}
|
|
|
|
const getEffectStreamErrorSchema = (mediaType: object): JsonSchema.JsonSchema | undefined => {
|
|
const stream = (mediaType as Record<string, unknown>)["x-effect-stream"]
|
|
if (!Predicate.isObject(stream) || !Predicate.isObject(stream.errorSchema)) {
|
|
return
|
|
}
|
|
return stream.errorSchema as JsonSchema.JsonSchema
|
|
}
|
|
|
|
const resolveReference = (input: unknown, resolveRef: (ref: string) => unknown): any => {
|
|
let current = input
|
|
while (Predicate.isObject(current) && typeof current.$ref === "string") {
|
|
current = resolveRef(current.$ref)
|
|
}
|
|
return current
|
|
}
|
|
|
|
const cloneSecurityRequirements = (
|
|
security: ReadonlyArray<Record<string, ReadonlyArray<string>>>
|
|
): Array<ParsedOperation.ParsedOperationSecurityRequirement> =>
|
|
security.map((requirement) =>
|
|
Object.fromEntries(
|
|
Object.entries(requirement).map(([name, scopes]) => [name, [...scopes]])
|
|
)
|
|
)
|
|
|
|
const parseSecuritySchemes = (
|
|
spec: OpenAPISpec,
|
|
resolveRef: (ref: string) => unknown
|
|
): Array<ParsedOperation.ParsedOpenApiSecurityScheme> => {
|
|
const securitySchemes = spec.components?.securitySchemes ?? {}
|
|
const parsed: Array<ParsedOperation.ParsedOpenApiSecurityScheme> = []
|
|
|
|
for (const [name, value] of Object.entries(securitySchemes)) {
|
|
const scheme = resolveReference(value, resolveRef) as OpenAPISecurityScheme | undefined
|
|
if (!Predicate.isObject(scheme)) {
|
|
continue
|
|
}
|
|
|
|
if (scheme.type === "http" && typeof scheme.scheme === "string") {
|
|
const normalizedScheme = scheme.scheme.toLowerCase()
|
|
if (normalizedScheme === "basic") {
|
|
parsed.push({
|
|
name,
|
|
type: "basic",
|
|
description: Utils.nonEmptyString(scheme.description),
|
|
bearerFormat: undefined,
|
|
scheme: undefined,
|
|
key: undefined,
|
|
in: undefined
|
|
})
|
|
} else if (normalizedScheme === "bearer") {
|
|
parsed.push({
|
|
name,
|
|
type: "bearer",
|
|
description: Utils.nonEmptyString(scheme.description),
|
|
bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
|
|
scheme: undefined,
|
|
key: undefined,
|
|
in: undefined
|
|
})
|
|
} else {
|
|
parsed.push({
|
|
name,
|
|
type: "http",
|
|
description: Utils.nonEmptyString(scheme.description),
|
|
bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
|
|
scheme: scheme.scheme,
|
|
key: undefined,
|
|
in: undefined
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (
|
|
scheme.type === "apiKey" &&
|
|
(scheme.in === "header" || scheme.in === "query" || scheme.in === "cookie") &&
|
|
typeof scheme.name === "string"
|
|
) {
|
|
parsed.push({
|
|
name,
|
|
type: "apiKey",
|
|
description: Utils.nonEmptyString(scheme.description),
|
|
bearerFormat: undefined,
|
|
scheme: undefined,
|
|
key: scheme.name,
|
|
in: scheme.in
|
|
})
|
|
}
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
const warnForAndSecurityRequirements = (
|
|
emitWarning: WarningEmitter,
|
|
operation: ParsedOperation.ParsedOperation
|
|
): void => {
|
|
if (operation.effectiveSecurity.some((requirement) => Object.keys(requirement).length === 0)) {
|
|
return
|
|
}
|
|
for (const requirement of operation.effectiveSecurity) {
|
|
const schemes = Object.keys(requirement)
|
|
if (schemes.length <= 1) {
|
|
continue
|
|
}
|
|
warnForOperation(emitWarning, operation, {
|
|
code: "security-and-downgraded",
|
|
message: `Security requirement requiring all of [${
|
|
schemes.join(", ")
|
|
}] was downgraded to a placeholder middleware.`
|
|
})
|
|
}
|
|
}
|
|
|
|
const hasUnsupportedSuccessfulSseResponse = (
|
|
responses: ReadonlyArray<readonly [string, unknown]>,
|
|
hasExplicitSuccessResponse: boolean
|
|
): boolean => {
|
|
for (const [status, response] of responses) {
|
|
if (!Predicate.isObject(response)) {
|
|
continue
|
|
}
|
|
const remappedStatus = remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
|
|
const statusCode = Number(remappedStatus)
|
|
if (Number.isNaN(statusCode) || statusCode >= 400) {
|
|
continue
|
|
}
|
|
|
|
const content = Predicate.isObject(response.content)
|
|
? response.content as Record<string, any>
|
|
: undefined
|
|
if (Predicate.isUndefined(content)) {
|
|
continue
|
|
}
|
|
|
|
for (const [contentType, mediaType] of Object.entries(content)) {
|
|
if (!Predicate.isObject(mediaType)) {
|
|
continue
|
|
}
|
|
const streamEncoding = getEffectStreamEncoding(mediaType)
|
|
if (streamEncoding === "sse") {
|
|
if (Predicate.isUndefined(mediaType.schema) || Predicate.isUndefined(getEffectStreamErrorSchema(mediaType))) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if (contentType === "text/event-stream") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const remapDefaultResponseStatusForHttpApi = (status: string, hasExplicitSuccessResponse: boolean): string =>
|
|
status === "default" ? (hasExplicitSuccessResponse ? "500" : "200") : status
|
|
|
|
const methodSupportsRequestBody = (method: OpenAPISpecMethodName): boolean =>
|
|
method !== "get" && method !== "head" && method !== "options" && method !== "trace"
|
|
|
|
const warnForOperation = (
|
|
emitWarning: WarningEmitter,
|
|
operation: ParsedOperation.ParsedOperation,
|
|
warning: {
|
|
readonly code: OpenApiGeneratorWarningCode
|
|
readonly message: string
|
|
}
|
|
): void => {
|
|
emitWarning({
|
|
...warning,
|
|
path: operation.path,
|
|
method: operation.method,
|
|
operationId: operation.operationId
|
|
})
|
|
}
|
|
|
|
function getDialect(spec: OpenAPISpec): "openapi-3.0" | "openapi-3.1" {
|
|
return spec.openapi.trim().startsWith("3.0") ? "openapi-3.0" : "openapi-3.1"
|
|
}
|
|
|
|
/**
|
|
* Layer providing an OpenAPI generator for Schema-backed HTTP client and HttpApi output.
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layerTransformerSchema: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
|
|
|
|
/**
|
|
* Layer providing an OpenAPI generator for type-only HTTP client output.
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layerTransformerTs: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
|
|
|
|
const isSwaggerSpec = (spec: OpenAPISpec) => "swagger" in spec
|
|
|
|
const convertSwaggerSpec = Effect.fn((spec: OpenAPISpec) =>
|
|
Effect.callback<OpenAPISpec>((resume) => {
|
|
SwaggerToOpenApi.convertObj(
|
|
spec as any,
|
|
{ laxDefaults: true, laxurls: true, patch: true, warnOnly: true },
|
|
(err, result) => {
|
|
if (err) {
|
|
resume(Effect.die(err))
|
|
} else {
|
|
resume(Effect.succeed(result.openapi as any))
|
|
}
|
|
}
|
|
)
|
|
}).pipe(Effect.withSpan("OpenApi.convertSwaggerSpec"))
|
|
)
|
|
|
|
const processPath = (path: string): {
|
|
readonly pathIds: Array<string>
|
|
readonly pathTemplate: string
|
|
} => {
|
|
const pathIds: Array<string> = []
|
|
path = path.replace(/{([^}]+)}/g, (_, name) => {
|
|
const id = Utils.camelize(name)
|
|
pathIds.push(id)
|
|
return "${" + id + "}"
|
|
})
|
|
const pathTemplate = "`" + path + "`"
|
|
return { pathIds, pathTemplate } as const
|
|
}
|