UNPKG

@cisstech/nestjs-expand

Version:

A NestJS module to build Dynamic Resource Expansion for APIs

341 lines 19.8 kB
"use strict"; var ExpandService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.ExpandService = void 0; const tslib_1 = require("tslib"); /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ const nestjs_discovery_1 = require("@golevelup/nestjs-discovery"); const common_1 = require("@nestjs/common"); require("reflect-metadata"); const expand_1 = require("./expand"); const expand_utils_1 = require("./expand.utils"); let ExpandService = ExpandService_1 = class ExpandService { /** * The configuration for the module. */ get config() { return this.conf; } constructor(discovery, conf) { this.discovery = discovery; this.conf = conf; this.logger = new common_1.Logger(ExpandService_1.name); // Stores standard expanders: DTO Class -> Array of Expander Instances this.standardExpanders = new Map(); // Stores reusable expander instances: Reusable Class -> Instance Info this.expanderMethodsInstances = new Map(); // Stores links from standard expanders to reusable methods: Expander Class -> Map<fieldName, UseExpansionMethodMetadata> this.expansionMethodLinks = new Map(); this.conf = { ...expand_1.DEFAULT_EXPAND_CONFIG, ...conf }; this.conf.errorHandling = { ...expand_1.DEFAULT_EXPAND_CONFIG.errorHandling, ...conf?.errorHandling, }; } /** * Lifecycle hook to discover standard expanders, reusable expanders, * and the links (@UseExpansionMethod) between them. */ async onModuleInit() { try { const [standardExpandersMeta, expanderMethodsMeta, expandablesMeta] = await Promise.all([ this.discovery.providersWithMetaAtKey(expand_1.EXPANDER_KEY), this.discovery.providersWithMetaAtKey(expand_1.EXPANDER_METHODS_KEY), this.discovery.methodsAndControllerMethodsWithMetaAtKey(expand_1.EXPANDABLE_KEY), ]); // Process standard expanders standardExpandersMeta.forEach((expander) => { const dtoClass = expander.meta; const instance = expander.discoveredClass.instance; const existing = this.standardExpanders.get(dtoClass) ?? []; existing.push(instance); this.standardExpanders.set(dtoClass, existing); // Discover @UseExpansionMethod metadata on this standard expander class const methodLinks = this.discoverExpansionMethodLinks(expander.discoveredClass.injectType); if (methodLinks.size > 0) { this.expansionMethodLinks.set(expander.discoveredClass.injectType, methodLinks); } }); // Process reusable expander methods classes expanderMethodsMeta.forEach((expander) => { this.expanderMethodsInstances.set(expander.discoveredClass.injectType, { instance: expander.discoveredClass.instance, meta: expander.meta, }); }); // Validation: Check if @Expandable targets have corresponding standard expanders registered const missingStandardExpanders = expandablesMeta .filter((expandable) => !this.standardExpanders.has(expandable.meta.target)) .map((expandable) => { const { methodName, parentClass } = expandable.discoveredMethod; return `${expandable.meta.target.name} used in ${parentClass.name}.${methodName}`; }); if (missingStandardExpanders.length) { throw new Error(`Missing providers decorated with @Expander for: ${missingStandardExpanders.join(', ')}`); } // Validation: Check if @UseExpansionMethod references existing @ExpanderMethods classes this.expansionMethodLinks.forEach((links, expanderClass) => { links.forEach((linkMeta) => { // Access properties via the config object if (!this.expanderMethodsInstances.has(linkMeta.class)) { throw new Error(`Class ${linkMeta.class.name} referenced in ${expanderClass.name} via @UseExpansionMethod for field "${linkMeta.name}" is not registered or decorated with @ExpanderMethods.` // Use linkMeta.name ); } // Further validation to check if the 'method' exists on the reusable instance const reusableInstance = this.expanderMethodsInstances.get(linkMeta.class)?.instance; if (!reusableInstance || typeof reusableInstance[linkMeta.method] !== 'function') { throw new Error(`Method "${String(linkMeta.method)}" referenced in ${expanderClass.name} via @UseExpansionMethod for field "${linkMeta.name}" does not exist on class ${linkMeta.class.name} decorated with @ExpanderMethods.`); } }); }); if (this.conf?.enableLogging) { this.logger.log('Expansion logging is enabled.'); this.log('debug', `Discovered ${this.standardExpanders.size} standard expander DTOs.`); this.log('debug', `Discovered ${this.expanderMethodsInstances.size} classes decorated with @ExpanderMethods.`); this.log('debug', `Discovered ${this.expansionMethodLinks.size} expander classes using @UseExpansionMethod.`); } } catch (error) { this.logger.error(`Error during module initialization: ${error.message}`, error.stack); throw error; } } /** * Expands/selects properties of a resource based on the provided parameters. * @param request - The incoming request object. * @param resource - The resource to be expanded. * @param expandable - The parameters for expansion, including the target class and rootField. * @returns The expanded resource. * @throws Error if there's an issue during the expansion process. */ async expandAndSelect(request, resource, expandable, selectable) { const { query } = request; if (!query) return resource; const expands = query[expandable?.queryParamName ?? (this.conf.expandQueryParamName || expand_1.DEFAULT_EXPAND_CONFIG.expandQueryParamName)]; const selects = query[selectable?.queryParamName ?? (this.conf.selectQueryParamName || expand_1.DEFAULT_EXPAND_CONFIG.selectQueryParamName)]; if (!expands && !selects) return resource; // Create an error map specific to this request (concurrency-safe) const expansionErrors = new Map(); const expansionThree = (0, expand_utils_1.createExpansionThree)(expands); const selectionThree = (0, expand_utils_1.createExpansionThree)(selects); const response = expands && expandable ? await this.expandResource(request, resource, expandable, expansionThree, expansionErrors) : resource; const result = selects && (selectable || this.config.enableGlobalSelection) ? this.selectResource(response, selectable, selectionThree) : response; // If we have errors and error inclusion is enabled, add them to the response (0, expand_utils_1.handleExpansionErrors)(expansionErrors, expandable?.rootField ? result[expandable.rootField] : result, this.config.errorHandling?.includeErrorsInResponse); return result; } log(level, message, ...optionalParams) { if (!this.conf.logLevel || this.conf.logLevel === 'none') return; // Only log if the configured log level is high enough const levels = ['debug', 'log', 'warn', 'error']; if (levels.indexOf(this.conf.logLevel) <= levels.indexOf(level)) { this.logger[level](message, ...optionalParams); } } /** * Helper to discover @UseExpansionMethod metadata on a class. * @param targetClass The class to inspect for @UseExpansionMethod metadata. * @returns A map of field names to their corresponding UseExpansionMethodMetadata. */ discoverExpansionMethodLinks(targetClass) { const links = new Map(); // Use Reflect.getMetadata to retrieve the array stored by the decorator const metadataList = Reflect.getMetadata(expand_1.USE_EXPANSION_METHOD_KEY, targetClass) || []; // Iterate through the array of configurations metadataList.forEach((meta) => { if (meta && meta.name) { // Check if meta is valid and has the 'name' property if (links.has(meta.name)) { this.log('warn', `Duplicate @UseExpansionMethod configuration found for field "${meta.name}" on class ${targetClass.name}. The last one defined will be used.`); } links.set(meta.name, meta); // Store the whole config object, keyed by field name } }); return links; } /** * Returns the @Expandable metadata for a given method using the Reflect API. * @remarks * This method is used internally as a wrapper around Reflect.getMetadata to make testing easier. * @param target - The method to be inspected. * @returns The @Expandable metadata for the given method or undefined if none is found. */ getMethodExpandableMetadata(target) { return Reflect.getMetadata(expand_1.EXPANDABLE_KEY, target); } async transformResource(resource, parameters, transformFn) { if (!resource) return resource; try { const root = parameters?.rootField ? resource[parameters.rootField] : resource; if (!root) return resource; const resources = Array.isArray(root) ? root : [root]; // Pass array index to transformFn for tracking errors with specific items const transformations = await Promise.all(resources.map((res, index) => transformFn(res, Array.isArray(root) ? index : undefined))); const response = Array.isArray(root) ? transformations : transformations[0]; return parameters?.rootField ? { ...resource, [parameters.rootField]: response } : response; } catch (error) { if (this.conf?.enableLogging) { this.log('error', `Error during transformation: ${error.message}`, error.stack); } throw error; } } async expandResource(request, resource, expandableParams, three, expansionErrors) { // Get the DTO class constructor from the expandable parameters const dtoClass = expandableParams.target; if (!dtoClass) { this.log('warn', `NestJsExpand: @Expandable decorator is missing target DTO class.`); return resource; } // Find standard expander instances for this DTO const standardExpanderInstances = this.standardExpanders.get(dtoClass); // Find linked reusable methods for this DTO const linkedMethods = new Map(); standardExpanderInstances?.forEach((instance) => { const instanceLinks = this.expansionMethodLinks.get(instance.constructor); if (instanceLinks) { for (const [fieldName, metadata] of instanceLinks.entries()) { linkedMethods.set(fieldName, metadata); } } }); if (!standardExpanderInstances && !linkedMethods.size) { this.log('warn', `NestJsExpand: No standard expanders or linked reusable methods found for DTO ${dtoClass.name}.`); return resource; } return this.transformResource(resource, expandableParams, async (parent, index) => { if (!parent) return parent; const extraValues = {}; const context = { parent, request }; // Create context once for (const propName in three) { if (!three[propName]) continue; // Skip if expansion is explicitly false let value; const expansionPath = typeof index === 'number' ? `${dtoClass.name}.${propName}[${index}]` : `${dtoClass.name}.${propName}`; // Get errror policy from the expandable parameters or use default const errorPolicy = expandableParams.errorPolicy || this.conf.errorHandling?.defaultErrorPolicy || 'ignore'; try { let standardExpanderInstance; let expanderMethodsInstanceInfo; // --- Check for @UseExpansionMethod link first --- const linkMetadata = linkedMethods?.get(propName); if (linkMetadata) { this.log('debug', `Using @UseExpansionMethod for ${expansionPath}`); expanderMethodsInstanceInfo = this.expanderMethodsInstances.get(linkMetadata.class); if (!expanderMethodsInstanceInfo?.instance) { throw new Error(`Internal Error: Instance for ${linkMetadata.class.name} decorated with @ExpanderMethods not found.`); } const reusableInstance = expanderMethodsInstanceInfo.instance; const reusableMethod = reusableInstance[linkMetadata.method]; if (typeof reusableMethod !== 'function') { throw new Error(`Internal Error: Method "${String(linkMetadata.method)}" not found on class ${linkMetadata.class.name} decorated with @ExpanderMethods.`); } // Determine arguments based on params config let args; if (Array.isArray(linkMetadata.params)) { // Simple property path array args = linkMetadata.params.map((propPath) => { // Basic property access, could be extended for deep paths const propValue = parent[propPath]; if (propValue === undefined || propValue === null) { this.log('debug', `Skipping expansion for ${expansionPath} via ${linkMetadata.class.name}.${String(linkMetadata.method)} due to missing parent property: ${String(propPath)}`); // Throw an error that can be caught below to handle policies like 'ignore' gracefully throw new Error(`Missing required parent property "${String(propPath)}" for reusable expansion.`); } return propValue; }); } else if (typeof linkMetadata.params === 'function') { // Custom params function args = linkMetadata.params(context); } else { throw new Error(`Invalid 'params' configuration for ${expansionPath} using ${linkMetadata.class.name}.${String(linkMetadata.method)}.`); } value = await reusableMethod.apply(reusableInstance, args); } else { // --- Fallback to standard @Expander method --- standardExpanderInstance = standardExpanderInstances?.find((e) => propName in e); if (standardExpanderInstance) { this.log('debug', `Using standard @Expander method for ${expansionPath}`); const standardMethod = standardExpanderInstance[propName]; value = await standardMethod.call(standardExpanderInstance, context); } else { this.log('warn', `NestJsExpand: No expander method (standard or linked reusable) found for requested expansion "${propName}" on DTO ${dtoClass.name}.`); continue; // Skip this property if no method found } } // --- Recursive Expansion & Error Handling (Common Logic) --- const subThree = three[propName]; if (value && typeof subThree === 'object') { // Determine if the *executed* method (standard or reusable) is decorated with @Expandable const methodToInspect = linkMetadata ? expanderMethodsInstanceInfo.instance[linkMetadata.method] // The reusable method : standardExpanderInstance[propName]; // The standard method const recursiveParams = this.getMethodExpandableMetadata(methodToInspect); if (recursiveParams) { value = await this.expandResource(request, value, recursiveParams, subThree, expansionErrors); } else { this.log('warn', `NestJsExpand: Missing @Expandable on method for ${propName} to recursively expand ${Object.keys(subThree)}. Target DTO: ${dtoClass.name}`); } } extraValues[propName] = value; } catch (error) { // Handle errors based on policy (common logic for both paths) const formatter = this.conf.errorHandling?.errorResponseShape || expand_1.DEFAULT_EXPAND_CONFIG.errorHandling.errorResponseShape; const formattedError = formatter(error, expansionPath); // Store the error for potential inclusion in the response expansionErrors.set(expansionPath, { message: formattedError.message || error.message, path: expansionPath, ...(formattedError.stack && { stack: formattedError.stack }), }); // Handle error according to the policy if (errorPolicy === 'throw') { throw error; // Re-throw to interrupt the request via interceptor } // For 'ignore' or 'include', log and continue to the next property this.log('warn', `Error during expansion of ${expansionPath}: ${error.message}`, error.stack); continue; } } return { ...parent, ...extraValues }; }); } selectResource(resource, selectable, three) { // Check if selection is empty; if so, return resource as is. if (Object.keys(three).length === 0) { return resource; } return this.transformResource(resource, selectable, (parent) => { if (!parent) return parent; return (0, expand_utils_1.maskObjectWithThree)(parent, three); }); } }; exports.ExpandService = ExpandService; exports.ExpandService = ExpandService = ExpandService_1 = tslib_1.__decorate([ (0, common_1.Injectable)(), tslib_1.__param(1, (0, common_1.Optional)()), tslib_1.__param(1, (0, common_1.Inject)(expand_1.EXPAND_CONFIG)), tslib_1.__metadata("design:paramtypes", [nestjs_discovery_1.DiscoveryService, Object]) ], ExpandService); //# sourceMappingURL=expand.service.js.map