request-context.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import { ExecutionContext } from '@nestjs/common';
  2. import { CurrencyCode, LanguageCode, Permission } from '@vendure/common/lib/generated-types';
  3. import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
  4. import { isObject } from '@vendure/common/lib/shared-utils';
  5. import { Request } from 'express';
  6. import { TFunction } from 'i18next';
  7. import { ReplicationMode, EntityManager } from 'typeorm';
  8. import {
  9. REQUEST_CONTEXT_KEY,
  10. REQUEST_CONTEXT_MAP_KEY,
  11. TRANSACTION_MANAGER_KEY,
  12. } from '../../common/constants';
  13. import { idsAreEqual } from '../../common/utils';
  14. import { CachedSession } from '../../config/session-cache/session-cache-strategy';
  15. import { Channel } from '../../entity/channel/channel.entity';
  16. import { ApiType } from './get-api-type';
  17. export type SerializedRequestContext = {
  18. _req?: any;
  19. _session: JsonCompatible<Required<CachedSession>>;
  20. _apiType: ApiType;
  21. _channel: JsonCompatible<Channel>;
  22. _languageCode: LanguageCode;
  23. _isAuthorized: boolean;
  24. _authorizedAsOwnerOnly: boolean;
  25. };
  26. /**
  27. * This object is used to store the RequestContext on the Express Request object.
  28. */
  29. interface RequestContextStore {
  30. /**
  31. * This is the default RequestContext for the handler.
  32. */
  33. default: RequestContext;
  34. /**
  35. * If a transaction is started, the resulting RequestContext is stored here.
  36. * This RequestContext will have a transaction manager attached via the
  37. * TRANSACTION_MANAGER_KEY symbol.
  38. *
  39. * When a transaction is started, the TRANSACTION_MANAGER_KEY symbol is added to the RequestContext
  40. * object. This is then detected inside the {@link internal_setRequestContext} function and the
  41. * RequestContext object is stored in the RequestContextStore under the withTransactionManager key.
  42. */
  43. withTransactionManager?: RequestContext;
  44. }
  45. interface RequestWithStores extends Request {
  46. // eslint-disable-next-line @typescript-eslint/ban-types
  47. [REQUEST_CONTEXT_MAP_KEY]?: Map<Function, RequestContextStore>;
  48. [REQUEST_CONTEXT_KEY]?: RequestContextStore;
  49. }
  50. /**
  51. * @description
  52. * This function is used to set the {@link RequestContext} on the `req` object. This is the underlying
  53. * mechanism by which we are able to access the `RequestContext` from different places.
  54. *
  55. * For example, here is a diagram to show how, in an incoming API request, we are able to store
  56. * and retrieve the `RequestContext` in a resolver:
  57. * ```
  58. * - query { product }
  59. * |
  60. * - AuthGuard.canActivate()
  61. * | | creates a `RequestContext`, stores it on `req`
  62. * |
  63. * - product() resolver
  64. * | @Ctx() decorator fetching `RequestContext` from `req`
  65. * ```
  66. *
  67. * We named it this way to discourage usage outside the framework internals.
  68. */
  69. export function internal_setRequestContext(
  70. req: RequestWithStores,
  71. ctx: RequestContext,
  72. executionContext?: ExecutionContext,
  73. ) {
  74. // If we have access to the `ExecutionContext`, it means we are able to bind
  75. // the `ctx` object to the specific "handler", i.e. the resolver function (for GraphQL)
  76. // or controller (for REST).
  77. let item: RequestContextStore | undefined;
  78. if (executionContext && typeof executionContext.getHandler === 'function') {
  79. // eslint-disable-next-line @typescript-eslint/ban-types
  80. const map = req[REQUEST_CONTEXT_MAP_KEY] || new Map();
  81. item = map.get(executionContext.getHandler());
  82. const ctxHasTransaction = Object.getOwnPropertySymbols(ctx).includes(TRANSACTION_MANAGER_KEY);
  83. if (item) {
  84. item.default = item.default ?? ctx;
  85. if (ctxHasTransaction) {
  86. item.withTransactionManager = ctx;
  87. }
  88. } else {
  89. item = {
  90. default: ctx,
  91. withTransactionManager: ctxHasTransaction ? ctx : undefined,
  92. };
  93. }
  94. map.set(executionContext.getHandler(), item);
  95. req[REQUEST_CONTEXT_MAP_KEY] = map;
  96. }
  97. // We also bind to a shared key so that we can access the `ctx` object
  98. // later even if we don't have a reference to the `ExecutionContext`
  99. req[REQUEST_CONTEXT_KEY] = item ?? {
  100. default: ctx,
  101. };
  102. }
  103. /**
  104. * @description
  105. * Gets the {@link RequestContext} from the `req` object. See {@link internal_setRequestContext}
  106. * for more details on this mechanism.
  107. */
  108. export function internal_getRequestContext(
  109. req: RequestWithStores,
  110. executionContext?: ExecutionContext,
  111. ): RequestContext {
  112. let item: RequestContextStore | undefined;
  113. if (executionContext && typeof executionContext.getHandler === 'function') {
  114. // eslint-disable-next-line @typescript-eslint/ban-types
  115. const map = req[REQUEST_CONTEXT_MAP_KEY];
  116. item = map?.get(executionContext.getHandler());
  117. // If we have a ctx associated with the current handler (resolver function), we
  118. // return it. Otherwise, we fall back to the shared key which will be there.
  119. if (item) {
  120. return item.withTransactionManager || item.default;
  121. }
  122. }
  123. if (!item) {
  124. item = req[REQUEST_CONTEXT_KEY] as RequestContextStore;
  125. }
  126. const transactionalCtx =
  127. item?.withTransactionManager &&
  128. ((item.withTransactionManager as any)[TRANSACTION_MANAGER_KEY] as EntityManager | undefined)
  129. ?.queryRunner?.isReleased === false
  130. ? item.withTransactionManager
  131. : undefined;
  132. return transactionalCtx || item.default;
  133. }
  134. /**
  135. * @description
  136. * The RequestContext holds information relevant to the current request, which may be
  137. * required at various points of the stack.
  138. *
  139. * It is a good practice to inject the RequestContext (using the {@link Ctx} decorator) into
  140. * _all_ resolvers & REST handler, and then pass it through to the service layer.
  141. *
  142. * This allows the service layer to access information about the current user, the active language,
  143. * the active Channel, and so on. In addition, the {@link TransactionalConnection} relies on the
  144. * presence of the RequestContext object in order to correctly handle per-request database transactions.
  145. *
  146. * The RequestContext also provides mechanisms for managing the database replication mode via the
  147. * `setReplicationMode` method and the `replicationMode` getter. This allows for finer control
  148. * over whether database queries within the context should be executed against the master or a replica
  149. * database, which can be particularly useful in distributed database environments.
  150. *
  151. * @example
  152. * ```ts
  153. * \@Query()
  154. * myQuery(\@Ctx() ctx: RequestContext) {
  155. * return this.myService.getData(ctx);
  156. * }
  157. * ```
  158. *
  159. * @example
  160. * ```ts
  161. * \@Query()
  162. * myMutation(\@Ctx() ctx: RequestContext) {
  163. * ctx.setReplicationMode('master');
  164. * return this.myService.getData(ctx);
  165. * }
  166. * ```
  167. * @docsCategory request
  168. */
  169. export class RequestContext {
  170. private readonly _languageCode: LanguageCode;
  171. private readonly _currencyCode: CurrencyCode;
  172. private readonly _channel: Channel;
  173. private readonly _session?: CachedSession;
  174. private readonly _isAuthorized: boolean;
  175. private readonly _authorizedAsOwnerOnly: boolean;
  176. private readonly _translationFn: TFunction;
  177. private readonly _apiType: ApiType;
  178. private readonly _req?: Request;
  179. private _replicationMode?: ReplicationMode;
  180. /**
  181. * @internal
  182. */
  183. constructor(options: {
  184. req?: Request;
  185. apiType: ApiType;
  186. channel: Channel;
  187. session?: CachedSession;
  188. languageCode?: LanguageCode;
  189. currencyCode?: CurrencyCode;
  190. isAuthorized: boolean;
  191. authorizedAsOwnerOnly: boolean;
  192. translationFn?: TFunction;
  193. }) {
  194. const { req, apiType, channel, session, languageCode, currencyCode, translationFn } = options;
  195. this._req = req;
  196. this._apiType = apiType;
  197. this._channel = channel;
  198. this._session = session;
  199. this._languageCode = languageCode || (channel && channel.defaultLanguageCode);
  200. this._currencyCode = currencyCode || (channel && channel.defaultCurrencyCode);
  201. this._isAuthorized = options.isAuthorized;
  202. this._authorizedAsOwnerOnly = options.authorizedAsOwnerOnly;
  203. this._translationFn = translationFn || (((key: string) => key) as any);
  204. }
  205. /**
  206. * @description
  207. * Creates an "empty" RequestContext object. This is only intended to be used
  208. * when a service method must be called outside the normal request-response
  209. * cycle, e.g. when programmatically populating data. Usually a better alternative
  210. * is to use the {@link RequestContextService} `create()` method, which allows more control
  211. * over the resulting RequestContext object.
  212. */
  213. static empty(): RequestContext {
  214. return new RequestContext({
  215. apiType: 'admin',
  216. authorizedAsOwnerOnly: false,
  217. channel: new Channel(),
  218. isAuthorized: true,
  219. });
  220. }
  221. /**
  222. * @description
  223. * Creates a new RequestContext object from a serialized object created by the
  224. * `serialize()` method.
  225. */
  226. static deserialize(ctxObject: SerializedRequestContext): RequestContext {
  227. return new RequestContext({
  228. req: ctxObject._req,
  229. apiType: ctxObject._apiType,
  230. channel: new Channel(ctxObject._channel),
  231. session: {
  232. ...ctxObject._session,
  233. expires: ctxObject._session?.expires && new Date(ctxObject._session.expires),
  234. },
  235. languageCode: ctxObject._languageCode,
  236. isAuthorized: ctxObject._isAuthorized,
  237. authorizedAsOwnerOnly: ctxObject._authorizedAsOwnerOnly,
  238. });
  239. }
  240. /**
  241. * @description
  242. * Returns `true` if there is an active Session & User associated with this request,
  243. * and that User has the specified permissions on the active Channel.
  244. */
  245. userHasPermissions(permissions: Permission[]): boolean {
  246. const user = this.session?.user;
  247. if (!user || !this.channelId) {
  248. return false;
  249. }
  250. const permissionsOnChannel = user.channelPermissions.find(c => idsAreEqual(c.id, this.channelId));
  251. if (permissionsOnChannel) {
  252. return this.arraysIntersect(permissionsOnChannel.permissions, permissions);
  253. }
  254. return false;
  255. }
  256. /**
  257. * @description
  258. * Serializes the RequestContext object into a JSON-compatible simple object.
  259. * This is useful when you need to send a RequestContext object to another
  260. * process, e.g. to pass it to the Job Queue via the {@link JobQueueService}.
  261. */
  262. serialize(): SerializedRequestContext {
  263. const serializableThis: any = Object.assign({}, this);
  264. if (this._req) {
  265. serializableThis._req = this.shallowCloneRequestObject(this._req);
  266. }
  267. return JSON.parse(JSON.stringify(serializableThis));
  268. }
  269. /**
  270. * @description
  271. * Creates a shallow copy of the RequestContext instance. This means that
  272. * mutations to the copy itself will not affect the original, but deep mutations
  273. * (e.g. copy.channel.code = 'new') *will* also affect the original.
  274. */
  275. copy(): RequestContext {
  276. return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
  277. }
  278. /**
  279. * @description
  280. * The raw Express request object.
  281. */
  282. get req(): Request | undefined {
  283. return this._req;
  284. }
  285. /**
  286. * @description
  287. * Signals which API this request was received by, e.g. `admin` or `shop`.
  288. */
  289. get apiType(): ApiType {
  290. return this._apiType;
  291. }
  292. /**
  293. * @description
  294. * The active {@link Channel} of this request.
  295. */
  296. get channel(): Channel {
  297. return this._channel;
  298. }
  299. get channelId(): ID {
  300. return this._channel.id;
  301. }
  302. get languageCode(): LanguageCode {
  303. return this._languageCode;
  304. }
  305. get currencyCode(): CurrencyCode {
  306. return this._currencyCode;
  307. }
  308. get session(): CachedSession | undefined {
  309. return this._session;
  310. }
  311. get activeUserId(): ID | undefined {
  312. return this.session?.user?.id;
  313. }
  314. /**
  315. * @description
  316. * True if the current session is authorized to access the current resolver method.
  317. *
  318. * @deprecated Use `userHasPermissions()` method instead.
  319. */
  320. get isAuthorized(): boolean {
  321. return this._isAuthorized;
  322. }
  323. /**
  324. * @description
  325. * True if the current anonymous session is only authorized to operate on entities that
  326. * are owned by the current session.
  327. */
  328. get authorizedAsOwnerOnly(): boolean {
  329. return this._authorizedAsOwnerOnly;
  330. }
  331. /**
  332. * @description
  333. * Translate the given i18n key
  334. */
  335. translate(key: string, variables?: { [k: string]: any }): string {
  336. try {
  337. return this._translationFn(key, variables);
  338. } catch (e: any) {
  339. return `Translation format error: ${JSON.stringify(e.message)}). Original key: ${key}`;
  340. }
  341. }
  342. /**
  343. * Returns true if any element of arr1 appears in arr2.
  344. */
  345. private arraysIntersect<T>(arr1: T[], arr2: T[]): boolean {
  346. return arr1.reduce((intersects, role) => {
  347. return intersects || arr2.includes(role);
  348. }, false as boolean);
  349. }
  350. /**
  351. * The Express "Request" object is huge and contains many circular
  352. * references. We will preserve just a subset of the whole, by preserving
  353. * only the serializable properties up to 2 levels deep.
  354. * @private
  355. */
  356. private shallowCloneRequestObject(req: Request) {
  357. function copySimpleFieldsToDepth(target: any, maxDepth: number, depth: number = 0) {
  358. const result: any = {};
  359. // eslint-disable-next-line guard-for-in
  360. for (const key in target) {
  361. if (key === 'host' && depth === 0) {
  362. // avoid Express "deprecated: req.host" warning
  363. continue;
  364. }
  365. let val: any;
  366. try {
  367. val = target[key];
  368. } catch (e: any) {
  369. val = String(e);
  370. }
  371. if (Array.isArray(val)) {
  372. depth++;
  373. result[key] = val.map(v => {
  374. if (!isObject(v) && typeof val !== 'function') {
  375. return v;
  376. } else {
  377. return copySimpleFieldsToDepth(v, maxDepth, depth);
  378. }
  379. });
  380. depth--;
  381. } else if (!isObject(val) && typeof val !== 'function') {
  382. result[key] = val;
  383. } else if (depth < maxDepth) {
  384. depth++;
  385. result[key] = copySimpleFieldsToDepth(val, maxDepth, depth);
  386. depth--;
  387. }
  388. }
  389. return result;
  390. }
  391. return copySimpleFieldsToDepth(req, 1);
  392. }
  393. /**
  394. * @description
  395. * Sets the replication mode for the current RequestContext. This mode determines whether the operations
  396. * within this context should interact with the master database or a replica. Use this method to explicitly
  397. * define the replication mode for the context.
  398. *
  399. * @param mode - The replication mode to be set (e.g., 'master' or 'replica').
  400. */
  401. setReplicationMode(mode: ReplicationMode): void {
  402. this._replicationMode = mode;
  403. }
  404. /**
  405. * @description
  406. * Gets the current replication mode of the RequestContext. If no replication mode has been set,
  407. * it returns `undefined`. This property indicates whether the context is configured to interact with
  408. * the master database or a replica.
  409. *
  410. * @returns The current replication mode, or `undefined` if none is set.
  411. */
  412. get replicationMode(): ReplicationMode | undefined {
  413. return this._replicationMode;
  414. }
  415. }