@samchon/openapi
Version:
OpenAPI definitions and converters for 'typia' and 'nestia'.
487 lines (467 loc) • 16.5 kB
text/typescript
import { OpenApi } from "../../OpenApi";
import { IHttpMigrateRoute } from "../../structures/IHttpMigrateRoute";
import { EndpointUtil } from "../../utils/EndpointUtil";
import { Escaper } from "../../utils/Escaper";
import { OpenApiTypeChecker } from "../../utils/OpenApiTypeChecker";
export namespace HttpMigrateRouteComposer {
export interface IProps {
document: OpenApi.IDocument;
method: "head" | "get" | "post" | "put" | "patch" | "delete";
path: string;
emendedPath: string;
operation: OpenApi.IOperation;
}
export const compose = (props: IProps): IHttpMigrateRoute | string[] => {
//----
// REQUEST AND RESPONSE BODY
//----
const body: false | null | IHttpMigrateRoute.IBody = emplaceBodySchema(
"request",
)((schema) =>
emplaceReference({
document: props.document,
name:
EndpointUtil.pascal(`I/Api/${props.path}`) +
"." +
EndpointUtil.pascal(`${props.method}/Body`),
schema,
}),
)(props.operation.requestBody);
const success: false | null | IHttpMigrateRoute.ISuccess = (() => {
const body = emplaceBodySchema("response")((schema) =>
emplaceReference({
document: props.document,
name:
EndpointUtil.pascal(`I/Api/${props.path}`) +
"." +
EndpointUtil.pascal(`${props.method}/Response`),
schema,
}),
)(
props.operation.responses?.["201"] ??
props.operation.responses?.["200"] ??
props.operation.responses?.default,
);
return body
? {
...body,
status: props.operation.responses?.["201"]
? "201"
: props.operation.responses?.["200"]
? "200"
: "default",
}
: body;
})();
const failures: string[] = [];
if (body === false)
failures.push(
`supports only "application/json", "application/x-www-form-urlencoded", "multipart/form-data" and "text/plain" content type in the request body.`,
);
if (success === false)
failures.push(
`supports only "application/json", "application/x-www-form-urlencoded" and "text/plain" content type in the response body.`,
);
//----
// HEADERS AND QUERY
//---
const [headers, query] = ["header", "query"].map((type) => {
// FIND TARGET PARAMETERS
const parameters: OpenApi.IOperation.IParameter[] = (
props.operation.parameters ?? []
).filter((p) => p.in === type);
if (parameters.length === 0) return null;
// CHECK PARAMETER TYPES -> TO BE OBJECT
const objects = parameters
.map((p) =>
OpenApiTypeChecker.isObject(p.schema)
? p.schema
: OpenApiTypeChecker.isReference(p.schema) &&
OpenApiTypeChecker.isObject(
props.document.components.schemas?.[
p.schema.$ref.replace(`#/components/schemas/`, ``)
] ?? {},
)
? p.schema
: null!,
)
.filter((s) => !!s);
const primitives = parameters.filter(
(p) =>
OpenApiTypeChecker.isBoolean(p.schema) ||
OpenApiTypeChecker.isInteger(p.schema) ||
OpenApiTypeChecker.isNumber(p.schema) ||
OpenApiTypeChecker.isString(p.schema) ||
OpenApiTypeChecker.isArray(p.schema) ||
OpenApiTypeChecker.isTuple(p.schema),
);
const out = (elem: {
schema: OpenApi.IJsonSchema;
title?: string;
description?: string;
example?: any;
examples?: Record<string, any>;
}) =>
({
...elem,
name: type,
key: type,
title: () => elem.title,
description: () => elem.description,
example: () => elem.example,
examples: () => elem.examples,
}) satisfies IHttpMigrateRoute.IHeaders;
if (objects.length === 1 && primitives.length === 0)
return out(parameters[0]);
else if (objects.length > 1) {
failures.push(`${type} typed parameters must be only one object type`);
return false;
}
// GATHER TO OBJECT TYPE
const dto: OpenApi.IJsonSchema.IObject | null = objects[0]
? OpenApiTypeChecker.isObject(objects[0])
? objects[0]
: ((props.document.components.schemas ?? {})[
(objects[0] as OpenApi.IJsonSchema.IReference).$ref.replace(
`#/components/schemas/`,
``,
)
] as OpenApi.IJsonSchema.IObject)
: null;
const entire: OpenApi.IJsonSchema.IObject[] = [
...objects.map((o) =>
OpenApiTypeChecker.isObject(o)
? o
: (props.document.components.schemas?.[
o.$ref.replace(`#/components/schemas/`, ``)
]! as OpenApi.IJsonSchema.IObject),
),
{
type: "object",
properties: Object.fromEntries([
...primitives.map((p) => [
p.name,
{
...p.schema,
description: p.schema.description ?? p.description,
},
]),
...(dto ? Object.entries(dto.properties ?? {}) : []),
]),
required: [
...new Set([
...primitives.filter((p) => p.required).map((p) => p.name!),
...(dto?.required ?? []),
]),
],
},
];
return parameters.length === 0
? null
: out({
schema: emplaceReference({
document: props.document,
name:
EndpointUtil.pascal(`I/Api/${props.path}`) +
"." +
EndpointUtil.pascal(`${props.method}/${type}`),
schema: {
type: "object",
properties: Object.fromEntries([
...new Map<string, OpenApi.IJsonSchema>(
entire
.map((o) =>
Object.entries(o.properties ?? {}).map(
([name, schema]) =>
[
name,
{
...schema,
description:
schema.description ?? schema.description,
} as OpenApi.IJsonSchema,
] as const,
),
)
.flat(),
),
]),
required: [
...new Set(entire.map((o) => o.required ?? []).flat()),
],
} satisfies OpenApi.IJsonSchema.IObject,
}),
});
});
//----
// PATH PARAMETERS
//----
const parameterNames: string[] = EndpointUtil.splitWithNormalization(
props.emendedPath,
)
.filter((str) => str[0] === ":")
.map((str) => str.substring(1));
const pathParameters: OpenApi.IOperation.IParameter[] = (
props.operation.parameters ?? []
).filter((p) => p.in === "path");
if (parameterNames.length !== pathParameters.length)
if (
pathParameters.length < parameterNames.length &&
pathParameters.every(
(p) => p.name !== undefined && parameterNames.includes(p.name),
)
) {
for (const name of parameterNames)
if (pathParameters.find((p) => p.name === name) === undefined)
pathParameters.push({
name,
in: "path",
schema: { type: "string" },
});
pathParameters.sort(
(a, b) =>
parameterNames.indexOf(a.name!) - parameterNames.indexOf(b.name!),
);
props.operation.parameters = [
...pathParameters,
...(props.operation.parameters ?? []).filter((p) => p.in !== "path"),
];
} else
failures.push(
"number of path parameters are not matched with its full path.",
);
if (failures.length) return failures;
const parameters: IHttpMigrateRoute.IParameter[] = (
props.operation.parameters ?? []
)
.filter((p) => p.in === "path")
.map((p, i) => ({
// FILL KEY NAME IF NOT EXISTsS
name: parameterNames[i],
key: (() => {
let key: string = EndpointUtil.normalize(parameterNames[i]);
if (Escaper.variable(key)) return key;
while (true) {
key = "_" + key;
if (!parameterNames.some((s) => s === key)) return key;
}
})(),
schema: p.schema,
parameter: () => p,
}));
return {
method: props.method,
path: props.path,
emendedPath: props.emendedPath,
accessor: ["@lazy"],
parameters: (props.operation.parameters ?? [])
.filter((p) => p.in === "path")
.map((p, i) => ({
// FILL KEY NAME IF NOT EXISTsS
name: parameterNames[i],
key: (() => {
let key: string = EndpointUtil.normalize(parameterNames[i]);
if (Escaper.variable(key)) return key;
while (true) {
key = "_" + key;
if (!parameterNames.some((s) => s === key)) return key;
}
})(),
schema: p.schema,
parameter: () => p,
})),
headers: headers || null,
query: query || null,
body: body || null,
success: success || null,
exceptions: Object.fromEntries(
Object.entries(props.operation.responses ?? {})
.filter(
([key]) => key !== "200" && key !== "201" && key !== "default",
)
.map(([status, response]) => [
status,
{
schema: (response.content?.["application/json"]?.schema ??
{}) satisfies OpenApi.IJsonSchema,
response: () => response,
media: () =>
(response.content?.["application/json"] ??
{}) satisfies OpenApi.IJsonSchema,
} satisfies IHttpMigrateRoute.IException,
]),
),
comment: () =>
writeRouteComment({
operation: props.operation,
parameters,
query: query || null,
body: body || null,
}),
operation: () => props.operation,
} satisfies IHttpMigrateRoute as IHttpMigrateRoute;
};
const writeRouteComment = (props: {
operation: OpenApi.IOperation;
parameters: IHttpMigrateRoute.IParameter[];
query: IHttpMigrateRoute.IQuery | null;
body: IHttpMigrateRoute.IBody | null;
}): string => {
const commentTags: string[] = [];
const add = (text: string) => {
if (commentTags.every((line) => line !== text)) commentTags.push(text);
};
let description: string = props.operation.description ?? "";
if (props.operation.summary) {
const emended: string = props.operation.summary.endsWith(".")
? props.operation.summary
: props.operation.summary + ".";
if (
!!description.length &&
!description.startsWith(props.operation.summary)
)
description = `${emended}\n${description}`;
}
description = description
.split("\n")
.map((s) => s.trim())
.join("\n");
for (const p of props.parameters ?? []) {
const param = p.parameter();
if (param.description || param.title) {
const text: string = (param.description ?? param.title)!;
add(`@param ${p.name} ${writeIndented(text, p.name.length + 8)}`);
}
}
if (props.body?.description()?.length)
add(`@param body ${writeIndented(props.body.description()!, 12)}`);
for (const security of props.operation.security ?? [])
for (const [name, scopes] of Object.entries(security))
add(`@security ${[name, ...scopes].join("")}`);
if (props.operation.tags)
props.operation.tags.forEach((name) => add(`@tag ${name}`));
if (props.operation.deprecated) add("@deprecated");
description = description.length
? commentTags.length
? `${description}\n\n${commentTags.join("\n")}`
: description
: commentTags.join("\n");
description = description.split("*/").join("*\\/");
return description;
};
const writeIndented = (text: string, spaces: number): string =>
text
.split("\n")
.map((s) => s.trim())
.map((s, i) => (i === 0 ? s : `${" ".repeat(spaces)}${s}`))
.join("\n");
const emplaceBodySchema =
(from: "request" | "response") =>
(
emplacer: (schema: OpenApi.IJsonSchema) => OpenApi.IJsonSchema.IReference,
) =>
(meta?: {
description?: string;
content?: Partial<Record<string, OpenApi.IOperation.IMediaType>>; // ISwaggerRouteBodyContent;
"x-nestia-encrypted"?: boolean;
}): false | null | IHttpMigrateRoute.IBody => {
if (!meta?.content) return null;
const entries: [string, OpenApi.IOperation.IMediaType][] = Object.entries(
meta.content,
).filter(([_, v]) => !!v) as [string, OpenApi.IOperation.IMediaType][];
const json = entries.find((e) =>
meta["x-nestia-encrypted"] === true
? e[0].includes("text/plain") || e[0].includes("application/json")
: e[0].includes("application/json") || e[0].includes("*/*"),
);
if (json) {
const { schema } = json[1];
return schema || from === "response"
? {
type: "application/json",
name: "body",
key: "body",
schema: schema
? isNotObjectLiteral(schema)
? schema
: emplacer(schema)
: {},
description: () => meta.description,
media: () => json[1],
"x-nestia-encrypted": meta["x-nestia-encrypted"],
}
: null;
}
const query = entries.find((e) =>
e[0].includes("application/x-www-form-urlencoded"),
);
if (query) {
const { schema } = query[1];
return schema || from === "response"
? {
type: "application/x-www-form-urlencoded",
name: "body",
key: "body",
schema: schema
? isNotObjectLiteral(schema)
? schema
: emplacer(schema)
: {},
description: () => meta.description,
media: () => query[1],
}
: null;
}
const text = entries.find((e) => e[0].includes("text/plain"));
if (text)
return {
type: "text/plain",
name: "body",
key: "body",
schema: { type: "string" },
description: () => meta.description,
media: () => text[1],
};
if (from === "request") {
const multipart = entries.find((e) =>
e[0].includes("multipart/form-data"),
);
if (multipart) {
const { schema } = multipart[1];
return {
type: "multipart/form-data",
name: "body",
key: "body",
schema: schema
? isNotObjectLiteral(schema)
? schema
: emplacer(schema)
: {},
description: () => meta.description,
media: () => multipart[1],
};
}
}
return false;
};
const emplaceReference = (props: {
document: OpenApi.IDocument;
name: string;
schema: OpenApi.IJsonSchema;
}): OpenApi.IJsonSchema.IReference => {
props.document.components.schemas ??= {};
props.document.components.schemas[props.name] = props.schema;
return {
$ref: `#/components/schemas/${props.name}`,
} satisfies OpenApi.IJsonSchema.IReference;
};
const isNotObjectLiteral = (schema: OpenApi.IJsonSchema): boolean =>
OpenApiTypeChecker.isReference(schema) ||
OpenApiTypeChecker.isBoolean(schema) ||
OpenApiTypeChecker.isNumber(schema) ||
OpenApiTypeChecker.isString(schema) ||
OpenApiTypeChecker.isUnknown(schema) ||
(OpenApiTypeChecker.isOneOf(schema) &&
schema.oneOf.every(isNotObjectLiteral)) ||
(OpenApiTypeChecker.isArray(schema) && isNotObjectLiteral(schema.items));
}