custom-field-processing-interceptor.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
  2. import { ModuleRef } from '@nestjs/core';
  3. import { GqlExecutionContext } from '@nestjs/graphql';
  4. import { getGraphQlInputName } from '@vendure/common/lib/shared-utils';
  5. import {
  6. getNamedType,
  7. GraphQLSchema,
  8. OperationDefinitionNode,
  9. TypeInfo,
  10. visit,
  11. visitWithTypeInfo,
  12. } from 'graphql';
  13. import { Injector } from '../../common/injector';
  14. import { ConfigService } from '../../config/config.service';
  15. import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
  16. import { parseContext } from '../common/parse-context';
  17. import { internal_getRequestContext, RequestContext } from '../common/request-context';
  18. import { validateCustomFieldValue } from '../common/validate-custom-field-value';
  19. /**
  20. * @description
  21. * Unified interceptor that processes custom fields in GraphQL mutations by:
  22. *
  23. * 1. Applying default values when fields are explicitly set to null (create operations only)
  24. * 2. Validating custom field values according to their constraints
  25. *
  26. * Uses native GraphQL utilities (visit, visitWithTypeInfo, getNamedType) for efficient
  27. * AST traversal and type analysis.
  28. */
  29. @Injectable()
  30. export class CustomFieldProcessingInterceptor implements NestInterceptor {
  31. private readonly createInputsWithCustomFields = new Set<string>();
  32. private readonly updateInputsWithCustomFields = new Set<string>();
  33. constructor(
  34. private readonly configService: ConfigService,
  35. private readonly moduleRef: ModuleRef,
  36. ) {
  37. Object.keys(configService.customFields).forEach(entityName => {
  38. this.createInputsWithCustomFields.add(`Create${entityName}Input`);
  39. this.updateInputsWithCustomFields.add(`Update${entityName}Input`);
  40. });
  41. // Note: OrderLineCustomFieldsInput is handled separately since it's used in both
  42. // create operations (addItemToOrder) and update operations (adjustOrderLine)
  43. }
  44. async intercept(context: ExecutionContext, next: CallHandler<any>) {
  45. const parsedContext = parseContext(context);
  46. if (!parsedContext.isGraphQL) {
  47. return next.handle();
  48. }
  49. const { operation, schema } = parsedContext.info;
  50. if (operation.operation === 'mutation') {
  51. await this.processMutationCustomFields(context, operation, schema);
  52. }
  53. return next.handle();
  54. }
  55. private async processMutationCustomFields(
  56. context: ExecutionContext,
  57. operation: OperationDefinitionNode,
  58. schema: GraphQLSchema,
  59. ) {
  60. const gqlExecutionContext = GqlExecutionContext.create(context);
  61. const variables = gqlExecutionContext.getArgs();
  62. const ctx = internal_getRequestContext(parseContext(context).req);
  63. const injector = new Injector(this.moduleRef);
  64. const inputTypeNames = this.getArgumentMap(operation, schema);
  65. for (const [inputName, typeName] of Object.entries(inputTypeNames)) {
  66. if (this.hasCustomFields(typeName) && variables[inputName]) {
  67. await this.processInputVariables(typeName, variables[inputName], ctx, injector, operation);
  68. }
  69. }
  70. }
  71. private hasCustomFields(typeName: string): boolean {
  72. return (
  73. this.createInputsWithCustomFields.has(typeName) ||
  74. this.updateInputsWithCustomFields.has(typeName) ||
  75. typeName === 'OrderLineCustomFieldsInput'
  76. );
  77. }
  78. private async processInputVariables(
  79. typeName: string,
  80. variableInput: any,
  81. ctx: RequestContext,
  82. injector: Injector,
  83. operation: OperationDefinitionNode,
  84. ) {
  85. const inputVariables = Array.isArray(variableInput) ? variableInput : [variableInput];
  86. const shouldApplyDefaults = this.shouldApplyDefaults(typeName, operation);
  87. for (const inputVariable of inputVariables) {
  88. if (shouldApplyDefaults) {
  89. this.applyDefaultsToInput(typeName, inputVariable);
  90. }
  91. await this.validateInput(typeName, ctx, injector, inputVariable);
  92. }
  93. }
  94. private shouldApplyDefaults(typeName: string, operation: OperationDefinitionNode): boolean {
  95. // For regular create inputs, always apply defaults
  96. if (this.createInputsWithCustomFields.has(typeName)) {
  97. return true;
  98. }
  99. // For OrderLineCustomFieldsInput, check the actual mutation name
  100. if (typeName === 'OrderLineCustomFieldsInput') {
  101. return this.isOrderLineCreateOperation(operation);
  102. }
  103. // For update inputs, never apply defaults
  104. return false;
  105. }
  106. private isOrderLineCreateOperation(operation: OperationDefinitionNode): boolean {
  107. // Check if any field in the operation is a "create/add" operation for order lines
  108. for (const selection of operation.selectionSet.selections) {
  109. if (selection.kind === 'Field') {
  110. const fieldName = selection.name.value;
  111. // These mutations create new order lines, so should apply defaults
  112. if (fieldName === 'addItemToOrder' || fieldName === 'addItemToDraftOrder') {
  113. return true;
  114. }
  115. // These mutations modify existing order lines, so should NOT apply defaults
  116. if (fieldName === 'adjustOrderLine' || fieldName === 'adjustDraftOrderLine') {
  117. return false;
  118. }
  119. }
  120. }
  121. // Default to false for safety (don't apply defaults unless we're sure it's a create)
  122. return false;
  123. }
  124. private getArgumentMap(
  125. operation: OperationDefinitionNode,
  126. schema: GraphQLSchema,
  127. ): { [inputName: string]: string } {
  128. const typeInfo = new TypeInfo(schema);
  129. const map: { [inputName: string]: string } = {};
  130. const visitor = {
  131. enter(node: any) {
  132. if (node.kind === 'Field') {
  133. const fieldDef = typeInfo.getFieldDef();
  134. if (fieldDef) {
  135. for (const arg of fieldDef.args) {
  136. map[arg.name] = getNamedType(arg.type).name;
  137. }
  138. }
  139. }
  140. },
  141. };
  142. visit(operation, visitWithTypeInfo(typeInfo, visitor));
  143. return map;
  144. }
  145. private applyDefaultsToInput(typeName: string, variableValues: any) {
  146. if (typeName === 'OrderLineCustomFieldsInput') {
  147. this.applyDefaultsForOrderLine(variableValues);
  148. } else {
  149. this.applyDefaultsForEntity(typeName, variableValues);
  150. }
  151. }
  152. private applyDefaultsForOrderLine(variableValues: any) {
  153. const orderLineConfig = this.configService.customFields.OrderLine || [];
  154. this.applyDefaultsToCustomFieldsObject(orderLineConfig, variableValues);
  155. }
  156. private applyDefaultsForEntity(typeName: string, variableValues: any) {
  157. const entityName = this.getEntityNameFromInputType(typeName);
  158. const customFieldConfig = this.configService.customFields[entityName];
  159. if (!customFieldConfig) {
  160. return;
  161. }
  162. this.applyDefaultsToDirectCustomFields(customFieldConfig, variableValues);
  163. this.applyDefaultsToTranslationCustomFields(customFieldConfig, variableValues);
  164. }
  165. private applyDefaultsToDirectCustomFields(customFieldConfig: any[], variableValues: any) {
  166. if (variableValues.customFields) {
  167. this.applyDefaultsToCustomFieldsObject(customFieldConfig, variableValues.customFields);
  168. }
  169. }
  170. private applyDefaultsToTranslationCustomFields(customFieldConfig: any[], variableValues: any) {
  171. if (!variableValues.translations || !Array.isArray(variableValues.translations)) {
  172. return;
  173. }
  174. for (const translation of variableValues.translations) {
  175. if (translation.customFields) {
  176. this.applyDefaultsToCustomFieldsObject(customFieldConfig, translation.customFields);
  177. }
  178. }
  179. }
  180. private applyDefaultsToCustomFieldsObject(customFieldConfig: any[], customFieldsObject: any) {
  181. for (const config of customFieldConfig) {
  182. const fieldName = getGraphQlInputName(config);
  183. // Only apply default if the field is explicitly null and has a default value
  184. if (customFieldsObject[fieldName] === null && config.defaultValue !== undefined) {
  185. customFieldsObject[fieldName] = config.defaultValue;
  186. }
  187. }
  188. }
  189. private getEntityNameFromInputType(typeName: string): string {
  190. // Remove "Create" or "Update" prefix and "Input" suffix
  191. // e.g., "CreateProductInput" -> "Product", "UpdateCustomerInput" -> "Customer"
  192. if (typeName.startsWith('Create')) {
  193. return typeName.slice(6, -5); // Remove "Create" and "Input"
  194. }
  195. if (typeName.startsWith('Update')) {
  196. return typeName.slice(6, -5); // Remove "Update" and "Input"
  197. }
  198. return typeName;
  199. }
  200. private async validateInput(
  201. typeName: string,
  202. ctx: RequestContext,
  203. injector: Injector,
  204. variableValues?: { [key: string]: any },
  205. ) {
  206. if (variableValues) {
  207. const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
  208. const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
  209. if (typeName === 'OrderLineCustomFieldsInput') {
  210. // special case needed to handle custom fields passed via addItemToOrder or adjustOrderLine
  211. // mutations.
  212. await this.validateCustomFieldsObject(
  213. this.configService.customFields.OrderLine,
  214. ctx,
  215. variableValues,
  216. injector,
  217. );
  218. }
  219. if (variableValues.customFields) {
  220. await this.validateCustomFieldsObject(
  221. customFieldConfig,
  222. ctx,
  223. variableValues.customFields,
  224. injector,
  225. );
  226. }
  227. const translations = variableValues.translations;
  228. if (Array.isArray(translations)) {
  229. for (const translation of translations) {
  230. if (translation.customFields) {
  231. await this.validateCustomFieldsObject(
  232. customFieldConfig,
  233. ctx,
  234. translation.customFields,
  235. injector,
  236. );
  237. }
  238. }
  239. }
  240. }
  241. }
  242. private async validateCustomFieldsObject(
  243. customFieldConfig: CustomFieldConfig[],
  244. ctx: RequestContext,
  245. customFieldsObject: { [key: string]: any },
  246. injector: Injector,
  247. ) {
  248. for (const [key, value] of Object.entries(customFieldsObject)) {
  249. const config = customFieldConfig.find(c => getGraphQlInputName(c) === key);
  250. if (config) {
  251. await validateCustomFieldValue(config, value, injector, ctx);
  252. }
  253. }
  254. }
  255. }