@nestia/core
Version:
Super-fast validation decorators of NestJS
430 lines (410 loc) • 13.4 kB
text/typescript
/// <reference path="../typings/get-function-location.d.ts" />
import { INestApplication, VersioningType } from "@nestjs/common";
import {
HOST_METADATA,
MODULE_PATH,
PATH_METADATA,
SCOPE_OPTIONS_METADATA,
VERSION_METADATA,
} from "@nestjs/common/constants";
import { VERSION_NEUTRAL, VersionValue } from "@nestjs/common/interfaces";
import { NestContainer } from "@nestjs/core";
import { InstanceWrapper } from "@nestjs/core/injector/instance-wrapper";
import { Module } from "@nestjs/core/injector/module";
import getFunctionLocation from "get-function-location";
import { IncomingMessage, Server } from "http";
import path from "path";
import { Path } from "path-parser";
import { Duplex } from "stream";
import { WebSocketAcceptor } from "tgrid";
import typia from "typia";
import WebSocket from "ws";
import { IWebSocketRouteReflect } from "../decorators/internal/IWebSocketRouteReflect";
import { ArrayUtil } from "../utils/ArrayUtil";
import { VersioningStrategy } from "../utils/VersioningStrategy";
export class WebSocketAdaptor {
public static async upgrade(
app: INestApplication,
): Promise<WebSocketAdaptor> {
return new this(app, await visitApplication(app));
}
public readonly close = async (): Promise<void> =>
new Promise((resolve) => {
this.http.off("close", this.close);
this.http.off("upgrade", this.handleUpgrade);
this.ws.close(() => resolve());
});
private constructor(app: INestApplication, operations: IOperator[]) {
this.operators = operations;
this.ws = new WebSocket.Server({ noServer: true });
this.http = app.getHttpServer();
this.http.on("close", this.close);
this.http.on("upgrade", this.handleUpgrade);
}
private readonly handleUpgrade = (
request: IncomingMessage,
duplex: Duplex,
head: Buffer,
) => {
this.ws.handleUpgrade(request, duplex, head, (client, request) =>
WebSocketAcceptor.upgrade(
request,
client as any,
async (acceptor): Promise<void> => {
const path: string = (() => {
const index: number = acceptor.path.indexOf("?");
return index === -1 ? acceptor.path : acceptor.path.slice(0, index);
})();
for (const op of this.operators) {
const params: Record<string, string> | null = op.parser.test(path);
if (params !== null)
try {
await op.handler({ params, acceptor });
} catch (error) {
if (
acceptor.state === WebSocketAcceptor.State.OPEN ||
acceptor.state === WebSocketAcceptor.State.ACCEPTING
)
await acceptor.reject(
1008,
error instanceof Error
? JSON.stringify({ ...error })
: "unknown error",
);
} finally {
return;
}
}
await acceptor.reject(1002, `WebSocket API not found`);
},
),
);
};
private readonly http: Server;
private readonly operators: IOperator[];
private readonly ws: WebSocket.Server;
}
const visitApplication = async (
app: INestApplication,
): Promise<IOperator[]> => {
const operators: IOperator[] = [];
const errors: IControllerError[] = [];
const config: IConfig = {
globalPrefix:
typeof (app as any).config?.globalPrefix === "string"
? (app as any).config.globalPrefix
: undefined,
versioning: (() => {
const versioning = (app as any).config?.versioningOptions;
return versioning === undefined || versioning.type !== VersioningType.URI
? undefined
: {
prefix:
versioning.prefix === undefined || versioning.prefix === false
? "v"
: versioning.prefix,
defaultVersion: versioning.defaultVersion,
};
})(),
};
const container: NestContainer = (app as any).container as NestContainer;
const modules: Module[] = [...container.getModules().values()].filter(
(m) => !!m.controllers?.size,
);
for (const m of modules) {
const modulePrefix: string =
Reflect.getMetadata(
MODULE_PATH + container.getModules().applicationId,
m.metatype,
) ??
Reflect.getMetadata(MODULE_PATH, m.metatype) ??
"";
for (const controller of m.controllers.values())
await visitController({
config,
errors,
operators,
controller,
modulePrefix,
});
}
if (errors.length)
throw new Error(
[
`WebSocketAdaptor: ${errors.length} error(s) found:`,
``,
...errors.map((e) =>
[
` - controller: ${e.name}`,
` methods:`,
...e.methods.map((m) =>
[
` - name: ${m.name}`,
` file: ${m.source}:${m.line}:${m.column}`,
` reasons:`,
...m.messages.map(
(msg) =>
` - ${msg
.split("\n")
.map((str, i) => (i == 0 ? str : ` ${str}`))
.join("\n")}`,
),
].join("\n"),
),
].join("\n"),
),
].join("\n"),
);
return operators;
};
const visitController = async (props: {
config: IConfig;
errors: IControllerError[];
operators: IOperator[];
controller: InstanceWrapper<object>;
modulePrefix: string;
}): Promise<void> => {
if (
ArrayUtil.has(
Reflect.getMetadataKeys(props.controller.metatype as Function),
PATH_METADATA,
HOST_METADATA,
SCOPE_OPTIONS_METADATA,
) === false
)
return;
const methodErrors: IMethodError[] = [];
const controller: IController = {
name: props.controller.name,
instance: props.controller.instance,
constructor: props.controller.metatype as Function,
prototype: Object.getPrototypeOf(props.controller.instance),
prefixes: (() => {
const value: string | string[] = Reflect.getMetadata(
PATH_METADATA,
props.controller.metatype as object,
);
if (typeof value === "string") return [value];
else if (value.length === 0) return [""];
else return value;
})(),
versions: props.config.versioning
? VersioningStrategy.cast(
Reflect.getMetadata(
VERSION_METADATA,
props.controller.metatype as Function,
),
)
: undefined,
modulePrefix: props.modulePrefix,
};
for (const mk of getOwnPropertyNames(controller.prototype).filter(
(key) =>
key !== "constructor" && typeof controller.prototype[key] === "function",
)) {
const errorMessages: string[] = [];
visitMethod({
config: props.config,
operators: props.operators,
controller,
method: {
key: mk,
value: controller.prototype[mk],
},
report: (msg) => errorMessages.push(msg),
});
if (errorMessages.length) {
const file = await getFunctionLocation(controller.prototype[mk]);
methodErrors.push({
name: mk,
messages: errorMessages,
...file,
source: path.relative(
process.cwd(),
file.source.replace("file:///", ""),
),
});
}
}
if (methodErrors.length)
props.errors.push({
name: controller.name,
methods: methodErrors,
});
};
const visitMethod = (props: {
config: IConfig;
operators: IOperator[];
controller: IController;
method: Entry<Function>;
report: (message: string) => void;
}): void => {
const route: IWebSocketRouteReflect | undefined = Reflect.getMetadata(
"nestia/WebSocketRoute",
props.method.value,
);
if (typia.is<IWebSocketRouteReflect>(route) === false) return;
const parameters: IWebSocketRouteReflect.IArgument[] = (
(Reflect.getMetadata(
"nestia/WebSocketRoute/Parameters",
props.controller.prototype,
props.method.key,
) ?? []) as IWebSocketRouteReflect.IArgument[]
).sort((a, b) => a.index - b.index);
// acceptor must be
if (parameters.some((p) => p.category === "acceptor") === false)
return props.report(
"@WebSocketRoute.Acceptor() decorated parameter must be.",
);
// length of parameters must be fulfilled
if (parameters.length !== props.method.value.length)
return props.report(
[
"Every parameters must be one of below:",
" - @WebSocketRoute.Acceptor()",
" - @WebSocketRoute.Driver()",
" - @WebSocketRoute.Header()",
" - @WebSocketRoute.Param()",
" - @WebSocketRoute.Query()",
].join("\n"),
);
const versions: string[] = VersioningStrategy.merge(props.config.versioning)([
...(props.controller.versions ?? []),
...VersioningStrategy.cast(
Reflect.getMetadata(VERSION_METADATA, props.method.value),
),
]);
for (const v of versions)
for (const cp of wrapPaths(props.controller.prefixes))
for (const mp of wrapPaths(route.paths)) {
const parser: Path = new Path(
"/" +
[
props.config.globalPrefix ?? "",
v,
props.controller.modulePrefix,
cp,
mp,
]
.filter((str) => !!str.length)
.join("/")
.split("/")
.filter((str) => str.length)
.join("/"),
);
const pathParams: IWebSocketRouteReflect.IParam[] = parameters.filter(
(p) => p.category === "param",
) as IWebSocketRouteReflect.IParam[];
if (parser.params.length !== pathParams.length) {
props.report(
[
`Path "${parser}" must have same number of parameters with @WebSocketRoute.Param()`,
` - path: ${JSON.stringify(parser.params)}`,
` - arguments: ${JSON.stringify(pathParams.map((p) => p.field))}`,
].join("\n"),
);
continue;
}
const meet: boolean = pathParams
.map((p) => {
const has: boolean = parser.params.includes(p.field);
if (has === false)
props.report(
`Path "${parser}" must have parameter "${p.field}" with @WebSocketRoute.Param()`,
);
return has;
})
.every((b) => b);
if (meet === false) continue;
props.operators.push({
parser,
handler: async (input: {
params: Record<string, string>;
acceptor: WebSocketAcceptor<any, any, any>;
}): Promise<void> => {
const args: any[] = [];
try {
for (const p of parameters)
if (p.category === "acceptor") args.push(input.acceptor);
else if (p.category === "driver")
args.push(input.acceptor.getDriver());
else if (p.category === "header") {
const error: Error | null = p.validate(input.acceptor.header);
if (error !== null) throw error;
args.push(input.acceptor.header);
} else if (p.category === "param")
args.push(p.assert(input.params[p.field]));
else if (p.category === "query") {
const query: any | Error = p.validate(
new URLSearchParams(
input.acceptor.path.indexOf("?") !== -1
? input.acceptor.path.split("?")[1]
: "",
),
);
if (query instanceof Error) throw query;
args.push(query);
}
} catch (exp) {
await input.acceptor.reject(
1003,
exp instanceof Error
? JSON.stringify({ ...exp })
: "unknown error",
);
return;
}
await props.method.value.call(props.controller.instance, ...args);
},
});
}
};
const wrapPaths = (value: string[]) => (value.length === 0 ? [""] : value);
const getOwnPropertyNames = (prototype: any): string[] => {
const result: Set<string> = new Set();
const iterate = (m: any) => {
if (m === null) return;
for (const k of Object.getOwnPropertyNames(m)) result.add(k);
iterate(Object.getPrototypeOf(m));
};
iterate(prototype);
return Array.from(result);
};
interface Entry<T> {
key: string;
value: T;
}
interface IController {
name: string;
versions: Array<string | typeof VERSION_NEUTRAL> | undefined;
instance: object;
constructor: Function;
prototype: any;
prefixes: string[];
modulePrefix: string;
}
interface IOperator {
parser: Path;
handler: (props: {
params: Record<string, string>;
acceptor: WebSocketAcceptor<any, any, any>;
}) => Promise<any>;
}
interface IConfig {
globalPrefix?: string;
versioning?: {
prefix: string;
defaultVersion?: VersionValue;
};
}
interface IControllerError {
name: string;
methods: IMethodError[];
}
interface IMethodError {
name: string;
messages: string[];
source: string;
line: number;
column: number;
}