@elsikora/nestjs-crud-automator
Version:
A library for automating the creation of CRUD operations in NestJS.
239 lines (236 loc) • 14.5 kB
JavaScript
import { createHash } from 'node:crypto';
import '../../../constant/decorator/api/function.constant.js';
import { PROPERTY_DESCRIBE_DECORATOR_API_CONSTANT } from '../../../constant/decorator/api/property-describe.constant.js';
import { UNSAFE_OBJECT_PROPERTY_NAMES_CONSTANT } from '../../../constant/safe-object-property-names.constant.js';
import '../../../enum/decorator/api/action.enum.js';
import '../../../enum/decorator/api/authentication-type.enum.js';
import '../../../enum/decorator/api/controller/get-list/query/filter/missing-behavior.enum.js';
import '../../../enum/decorator/api/controller/get-list/query/pagination-mode.enum.js';
import '../../../enum/decorator/api/controller/get-list/query/unlisted-fields.enum.js';
import '../../../enum/decorator/api/controller/relation-reference-shape.enum.js';
import { EApiControllerRequestTarget } from '../../../enum/decorator/api/controller/request/target.enum.js';
import '../../../enum/decorator/api/controller/request/transformer-type.enum.js';
import '../../../enum/decorator/api/controller/response-target.enum.js';
import { EApiDtoType } from '../../../enum/decorator/api/dto-type.enum.js';
import '../../../enum/decorator/api/function/context-storage-kind.enum.js';
import '../../../enum/decorator/api/function/subscriber-transaction-expectation.enum.js';
import '../../../enum/decorator/api/function/transaction/event-status.enum.js';
import '../../../enum/decorator/api/function/transaction/failure-stage.enum.js';
import '../../../enum/decorator/api/function/transaction/mode.enum.js';
import '../../../enum/decorator/api/function/transaction/outcome.enum.js';
import '../../../enum/decorator/api/function/transaction/owner-kind.enum.js';
import '../../../enum/decorator/api/function/transaction/trace-type.enum.js';
import '../../../enum/decorator/api/function/type.enum.js';
import '../../../enum/decorator/api/on-type.enum.js';
import '../../../enum/decorator/api/property/data-type.enum.js';
import '../../../enum/decorator/api/property/date/identifier.enum.js';
import '../../../enum/decorator/api/property/date/type.enum.js';
import { EApiPropertyDescribeType } from '../../../enum/decorator/api/property/desribe-type.enum.js';
import '../../../enum/decorator/api/property/number-type.enum.js';
import '../../../enum/decorator/api/property/string-type.enum.js';
import '../../../enum/decorator/api/route/subscriber-authorization-expectation.enum.js';
import { EApiRouteType } from '../../../enum/decorator/api/route/type.enum.js';
import { DtoBuildDecorator } from '../../../utility/dto/build-decorator.utility.js';
import { ErrorException } from '../../../utility/error/exception.utility.js';
import { parse } from 'path-to-regexp';
class ApiControllerReadPlanCompiler {
static compile(controller, controllerPath, entityMetadata, method, routeConfig) {
const readDescriptor = Object.getOwnPropertyDescriptor(routeConfig, "read");
const requestConfig = routeConfig.request;
if (!readDescriptor) {
if (Reflect.has(routeConfig, "read")) {
throw ErrorException("Generated read must be an own property on the route configuration");
}
if (method === EApiRouteType.GET_LIST && requestConfig?.[EApiControllerRequestTarget.PARAMETERS] !== undefined) {
throw ErrorException("GET_LIST PARAMETERS request configuration requires generated read scope");
}
return undefined;
}
const readConfig = this.readTopLevelRead(routeConfig, readDescriptor);
if (method !== EApiRouteType.GET && method !== EApiRouteType.GET_LIST) {
throw ErrorException("Generated read configuration is supported only for GET and GET_LIST routes");
}
if (routeConfig.dto?.[EApiDtoType.PARAMETERS]) {
throw ErrorException("Generated read scope parameters cannot be combined with a manual PARAMETERS DTO");
}
const rawReadConfig = this.requireRecord(readConfig, "Generated read configuration");
this.requireExactKeys(rawReadConfig, ["scope"], "Generated read configuration");
const rawScope = this.requireRecord(this.readDataProperty(rawReadConfig, "scope", "Generated read configuration"), "Generated read scope");
this.requireExactKeys(rawScope, ["parameters"], "Generated read scope");
const rawParameters = this.readDenseArray(this.readDataProperty(rawScope, "parameters", "Generated read scope"), "Generated read scope parameters");
const inheritedParameters = this.extractPathParameters(controllerPath ?? "");
const inheritedParameterSet = new Set(inheritedParameters);
const mappedParameters = new Set();
const mappedFields = new Set();
const compiledByParameter = new Map();
const primaryIdentityParameter = entityMetadata.primaryKey ? String(entityMetadata.primaryKey.name) : undefined;
const currentGuard = routeConfig.security?.authentication?.guard;
if (method === EApiRouteType.GET && primaryIdentityParameter && inheritedParameterSet.has(primaryIdentityParameter)) {
throw ErrorException(`Inherited controller path parameter "${primaryIdentityParameter}" conflicts with the generated GET primary identity parameter`);
}
for (const [index, rawMapping] of rawParameters.entries()) {
const mapping = this.requireRecord(rawMapping, `Generated read scope parameters[${index}]`);
this.requireExactKeys(mapping, ["field", "parameter"], `Generated read scope parameters[${index}]`);
const rawParameter = this.readDataProperty(mapping, "parameter", `Generated read scope parameters[${index}]`);
const rawField = this.readDataProperty(mapping, "field", `Generated read scope parameters[${index}]`);
if (typeof rawParameter !== "string" || rawParameter.length === 0) {
throw ErrorException(`Generated read scope parameters[${index}] must declare a path parameter`);
}
const parameter = rawParameter;
this.requireSafeScopePropertyName(parameter, "path parameter");
if (!inheritedParameterSet.has(parameter)) {
throw ErrorException(`Generated read scope parameter "${parameter}" is not declared by the controller path`);
}
if (mappedParameters.has(parameter)) {
throw ErrorException(`Generated read scope parameter "${parameter}" is mapped more than once`);
}
if (typeof rawField !== "string" || rawField.length === 0) {
throw ErrorException(`Generated read scope parameter "${parameter}" must map to a direct scalar entity field`);
}
const field = rawField;
this.requireSafeScopePropertyName(field, "entity field");
const column = entityMetadata.columns.find((candidate) => String(candidate.name) === field);
const metadata = column?.metadata?.[PROPERTY_DESCRIBE_DECORATOR_API_CONSTANT.METADATA_KEY];
if (!column || column.relation || !metadata || metadata.type === EApiPropertyDescribeType.OBJECT || metadata.type === EApiPropertyDescribeType.RELATION) {
throw ErrorException(`Generated read scope parameter "${parameter}" must map to a described direct scalar entity field`);
}
if (!DtoBuildDecorator(method, metadata, entityMetadata, EApiDtoType.PARAMETERS, parameter, currentGuard)) {
throw ErrorException(`Generated read scope parameter "${parameter}" maps to an entity field unavailable for the route PARAMETERS DTO`);
}
if (mappedFields.has(field)) {
throw ErrorException(`Generated read scope field "${field}" is mapped more than once`);
}
mappedParameters.add(parameter);
mappedFields.add(field);
compiledByParameter.set(parameter, Object.freeze({ field, parameter }));
}
const unmappedParameter = inheritedParameters.find((parameter) => !mappedParameters.has(parameter));
if (unmappedParameter) {
throw ErrorException(`Inherited controller path parameter "${unmappedParameter}" is not mapped by the generated read scope`);
}
const parameters = Object.freeze(inheritedParameters.flatMap((parameter) => {
const compiled = compiledByParameter.get(parameter);
return compiled ? [compiled] : [];
}));
const normalizedPlan = { method, parameters };
const signature = createHash("sha256").update(JSON.stringify(normalizedPlan)).digest("hex");
const controllerName = controller.name || "AnonymousController";
return Object.freeze({
controllerName,
parameters,
schemaName: `${controllerName}${entityMetadata.name ?? "UnknownResource"}${method === EApiRouteType.GET ? "Get" : "GetList"}Parameters${signature}DTO`,
signature,
});
}
static collectPathParameters(pathParts, parameters, isOptionalGroup = false) {
for (const pathPart of pathParts) {
if (pathPart.type === "text") {
continue;
}
if (pathPart.type === "group") {
this.collectPathParameters(pathPart.tokens, parameters, true);
continue;
}
this.requireSafeScopePropertyName(pathPart.name, "controller path parameter");
if (pathPart.type === "wildcard") {
throw ErrorException(`Controller path wildcard parameter "${pathPart.name}" cannot be used by generated read scope`);
}
if (isOptionalGroup) {
throw ErrorException(`Controller path optional parameter "${pathPart.name}" cannot be used by generated read scope`);
}
if (parameters.includes(pathPart.name)) {
throw ErrorException(`Controller path parameter "${pathPart.name}" is declared more than once`);
}
parameters.push(pathPart.name);
}
}
static extractPathParameters(path) {
const parameters = [];
let pathParts;
try {
pathParts = parse(path).tokens;
}
catch {
throw ErrorException(`Controller path "${path}" is not valid route syntax`);
}
this.collectPathParameters(pathParts, parameters);
return parameters;
}
static readDataProperty(value, key, context) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !("value" in descriptor)) {
throw ErrorException(`${context} property "${key}" must be an enumerable data property`);
}
return descriptor.value;
}
static readDenseArray(value, context) {
if (!Array.isArray(value)) {
throw ErrorException(`${context} must be a non-empty array`);
}
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
const length = lengthDescriptor && "value" in lengthDescriptor ? lengthDescriptor.value : undefined;
if (length === 0) {
throw ErrorException(`${context} must be a non-empty array`);
}
if (!Number.isSafeInteger(length) || length < 0 || Reflect.ownKeys(value).length !== length + 1) {
throw ErrorException(`${context} must be a non-empty dense array of data properties`);
}
const items = [];
for (let index = 0; index < length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor?.enumerable || !("value" in descriptor)) {
throw ErrorException(`${context} must be a non-empty dense array of data properties`);
}
items.push(descriptor.value);
}
return items;
}
static readTopLevelRead(routeConfig, descriptor) {
const prototype = Object.getPrototypeOf(routeConfig);
if (prototype !== null && prototype !== Object.prototype) {
throw ErrorException("Generated read route configuration must be a plain object");
}
if (Reflect.ownKeys(routeConfig).some((key) => typeof key === "symbol")) {
throw ErrorException("Generated read route configuration must not contain symbol keys");
}
if (!descriptor.enumerable || !("value" in descriptor)) {
throw ErrorException("Generated read must be an enumerable data property on the route configuration");
}
return descriptor.value;
}
static requireExactKeys(value, expectedKeys, context) {
const actualKeys = Reflect.ownKeys(value)
.filter((key) => typeof key === "string")
.toSorted((left, right) => left.localeCompare(right));
const normalizedExpectedKeys = expectedKeys.toSorted((left, right) => left.localeCompare(right));
if (actualKeys.length !== normalizedExpectedKeys.length || actualKeys.some((key, index) => key !== normalizedExpectedKeys[index])) {
throw ErrorException(`${context} must contain exactly ${normalizedExpectedKeys.join(", ")}`);
}
}
static requireRecord(value, context) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw ErrorException(`${context} must be an object`);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== null && prototype !== Object.prototype) {
throw ErrorException(`${context} must be a plain object`);
}
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== "string") {
throw ErrorException(`${context} must contain string keys only`);
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor?.enumerable || !("value" in descriptor)) {
throw ErrorException(`${context} must contain enumerable data properties only`);
}
}
return value;
}
static requireSafeScopePropertyName(name, context) {
if (UNSAFE_OBJECT_PROPERTY_NAMES_CONSTANT.has(name)) {
throw ErrorException(`Generated read scope ${context} "${name}" is not a safe property name`);
}
}
}
export { ApiControllerReadPlanCompiler };
//# sourceMappingURL=read-plan-compiler.class.js.map