| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
- import { ModuleRef } from '@nestjs/core';
- import { GqlExecutionContext } from '@nestjs/graphql';
- import { getGraphQlInputName } from '@vendure/common/lib/shared-utils';
- import {
- getNamedType,
- GraphQLSchema,
- OperationDefinitionNode,
- TypeInfo,
- visit,
- visitWithTypeInfo,
- } from 'graphql';
- import { Injector } from '../../common/injector';
- import { ConfigService } from '../../config/config.service';
- import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
- import { parseContext } from '../common/parse-context';
- import { internal_getRequestContext, RequestContext } from '../common/request-context';
- import { validateCustomFieldValue } from '../common/validate-custom-field-value';
- /**
- * @description
- * Unified interceptor that processes custom fields in GraphQL mutations by:
- *
- * 1. Applying default values when fields are explicitly set to null (create operations only)
- * 2. Validating custom field values according to their constraints
- *
- * Uses native GraphQL utilities (visit, visitWithTypeInfo, getNamedType) for efficient
- * AST traversal and type analysis.
- */
- @Injectable()
- export class CustomFieldProcessingInterceptor implements NestInterceptor {
- private readonly createInputsWithCustomFields = new Set<string>();
- private readonly updateInputsWithCustomFields = new Set<string>();
- constructor(
- private readonly configService: ConfigService,
- private readonly moduleRef: ModuleRef,
- ) {
- Object.keys(configService.customFields).forEach(entityName => {
- this.createInputsWithCustomFields.add(`Create${entityName}Input`);
- this.updateInputsWithCustomFields.add(`Update${entityName}Input`);
- });
- // Note: OrderLineCustomFieldsInput is handled separately since it's used in both
- // create operations (addItemToOrder) and update operations (adjustOrderLine)
- }
- async intercept(context: ExecutionContext, next: CallHandler<any>) {
- const parsedContext = parseContext(context);
- if (!parsedContext.isGraphQL) {
- return next.handle();
- }
- const { operation, schema } = parsedContext.info;
- if (operation.operation === 'mutation') {
- await this.processMutationCustomFields(context, operation, schema);
- }
- return next.handle();
- }
- private async processMutationCustomFields(
- context: ExecutionContext,
- operation: OperationDefinitionNode,
- schema: GraphQLSchema,
- ) {
- const gqlExecutionContext = GqlExecutionContext.create(context);
- const variables = gqlExecutionContext.getArgs();
- const ctx = internal_getRequestContext(parseContext(context).req);
- const injector = new Injector(this.moduleRef);
- const inputTypeNames = this.getArgumentMap(operation, schema);
- for (const [inputName, typeName] of Object.entries(inputTypeNames)) {
- if (this.hasCustomFields(typeName) && variables[inputName]) {
- await this.processInputVariables(typeName, variables[inputName], ctx, injector, operation);
- }
- }
- }
- private hasCustomFields(typeName: string): boolean {
- return (
- this.createInputsWithCustomFields.has(typeName) ||
- this.updateInputsWithCustomFields.has(typeName) ||
- typeName === 'OrderLineCustomFieldsInput'
- );
- }
- private async processInputVariables(
- typeName: string,
- variableInput: any,
- ctx: RequestContext,
- injector: Injector,
- operation: OperationDefinitionNode,
- ) {
- const inputVariables = Array.isArray(variableInput) ? variableInput : [variableInput];
- const shouldApplyDefaults = this.shouldApplyDefaults(typeName, operation);
- for (const inputVariable of inputVariables) {
- if (shouldApplyDefaults) {
- this.applyDefaultsToInput(typeName, inputVariable);
- }
- await this.validateInput(typeName, ctx, injector, inputVariable);
- }
- }
- private shouldApplyDefaults(typeName: string, operation: OperationDefinitionNode): boolean {
- // For regular create inputs, always apply defaults
- if (this.createInputsWithCustomFields.has(typeName)) {
- return true;
- }
- // For OrderLineCustomFieldsInput, check the actual mutation name
- if (typeName === 'OrderLineCustomFieldsInput') {
- return this.isOrderLineCreateOperation(operation);
- }
- // For update inputs, never apply defaults
- return false;
- }
- private isOrderLineCreateOperation(operation: OperationDefinitionNode): boolean {
- // Check if any field in the operation is a "create/add" operation for order lines
- for (const selection of operation.selectionSet.selections) {
- if (selection.kind === 'Field') {
- const fieldName = selection.name.value;
- // These mutations create new order lines, so should apply defaults
- if (fieldName === 'addItemToOrder' || fieldName === 'addItemToDraftOrder') {
- return true;
- }
- // These mutations modify existing order lines, so should NOT apply defaults
- if (fieldName === 'adjustOrderLine' || fieldName === 'adjustDraftOrderLine') {
- return false;
- }
- }
- }
- // Default to false for safety (don't apply defaults unless we're sure it's a create)
- return false;
- }
- private getArgumentMap(
- operation: OperationDefinitionNode,
- schema: GraphQLSchema,
- ): { [inputName: string]: string } {
- const typeInfo = new TypeInfo(schema);
- const map: { [inputName: string]: string } = {};
- const visitor = {
- enter(node: any) {
- if (node.kind === 'Field') {
- const fieldDef = typeInfo.getFieldDef();
- if (fieldDef) {
- for (const arg of fieldDef.args) {
- map[arg.name] = getNamedType(arg.type).name;
- }
- }
- }
- },
- };
- visit(operation, visitWithTypeInfo(typeInfo, visitor));
- return map;
- }
- private applyDefaultsToInput(typeName: string, variableValues: any) {
- if (typeName === 'OrderLineCustomFieldsInput') {
- this.applyDefaultsForOrderLine(variableValues);
- } else {
- this.applyDefaultsForEntity(typeName, variableValues);
- }
- }
- private applyDefaultsForOrderLine(variableValues: any) {
- const orderLineConfig = this.configService.customFields.OrderLine || [];
- this.applyDefaultsToCustomFieldsObject(orderLineConfig, variableValues);
- }
- private applyDefaultsForEntity(typeName: string, variableValues: any) {
- const entityName = this.getEntityNameFromInputType(typeName);
- const customFieldConfig = this.configService.customFields[entityName];
- if (!customFieldConfig) {
- return;
- }
- this.applyDefaultsToDirectCustomFields(customFieldConfig, variableValues);
- this.applyDefaultsToTranslationCustomFields(customFieldConfig, variableValues);
- }
- private applyDefaultsToDirectCustomFields(customFieldConfig: any[], variableValues: any) {
- if (variableValues.customFields) {
- this.applyDefaultsToCustomFieldsObject(customFieldConfig, variableValues.customFields);
- }
- }
- private applyDefaultsToTranslationCustomFields(customFieldConfig: any[], variableValues: any) {
- if (!variableValues.translations || !Array.isArray(variableValues.translations)) {
- return;
- }
- for (const translation of variableValues.translations) {
- if (translation.customFields) {
- this.applyDefaultsToCustomFieldsObject(customFieldConfig, translation.customFields);
- }
- }
- }
- private applyDefaultsToCustomFieldsObject(customFieldConfig: any[], customFieldsObject: any) {
- for (const config of customFieldConfig) {
- const fieldName = getGraphQlInputName(config);
- // Only apply default if the field is explicitly null and has a default value
- if (customFieldsObject[fieldName] === null && config.defaultValue !== undefined) {
- customFieldsObject[fieldName] = config.defaultValue;
- }
- }
- }
- private getEntityNameFromInputType(typeName: string): string {
- // Remove "Create" or "Update" prefix and "Input" suffix
- // e.g., "CreateProductInput" -> "Product", "UpdateCustomerInput" -> "Customer"
- if (typeName.startsWith('Create')) {
- return typeName.slice(6, -5); // Remove "Create" and "Input"
- }
- if (typeName.startsWith('Update')) {
- return typeName.slice(6, -5); // Remove "Update" and "Input"
- }
- return typeName;
- }
- private async validateInput(
- typeName: string,
- ctx: RequestContext,
- injector: Injector,
- variableValues?: { [key: string]: any },
- ) {
- if (variableValues) {
- const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
- const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
- if (typeName === 'OrderLineCustomFieldsInput') {
- // special case needed to handle custom fields passed via addItemToOrder or adjustOrderLine
- // mutations.
- await this.validateCustomFieldsObject(
- this.configService.customFields.OrderLine,
- ctx,
- variableValues,
- injector,
- );
- }
- if (variableValues.customFields) {
- await this.validateCustomFieldsObject(
- customFieldConfig,
- ctx,
- variableValues.customFields,
- injector,
- );
- }
- const translations = variableValues.translations;
- if (Array.isArray(translations)) {
- for (const translation of translations) {
- if (translation.customFields) {
- await this.validateCustomFieldsObject(
- customFieldConfig,
- ctx,
- translation.customFields,
- injector,
- );
- }
- }
- }
- }
- }
- private async validateCustomFieldsObject(
- customFieldConfig: CustomFieldConfig[],
- ctx: RequestContext,
- customFieldsObject: { [key: string]: any },
- injector: Injector,
- ) {
- for (const [key, value] of Object.entries(customFieldsObject)) {
- const config = customFieldConfig.find(c => getGraphQlInputName(c) === key);
- if (config) {
- await validateCustomFieldValue(config, value, injector, ctx);
- }
- }
- }
- }
|