From 9590a19d866635d41185c7273225c4b6a38741ef Mon Sep 17 00:00:00 2001 From: Papooch <46406259+Papooch@users.noreply.github.com> Date: Mon, 14 Feb 2022 22:18:09 +0100 Subject: [PATCH] Improve type inference and type safety for ClsService (#16) * feat: add recursive key types * feat: typing for ClsService#get * feat: typing for ClsService#set * chore: lint - remove unused variables * docs: add section on type safety --- README.md | 116 ++++++++++++++++----- package.json | 8 +- src/lib/cls.constants.ts | 6 +- src/lib/cls.guard.ts | 2 +- src/lib/cls.interceptor.ts | 2 +- src/lib/cls.interfaces.ts | 8 +- src/lib/cls.middleware.ts | 6 +- src/lib/cls.service.spec.ts | 155 +++++++++++++++++++++++++---- src/lib/cls.service.ts | 69 +++++++++---- src/lib/copy-metadata.ts | 16 --- src/types/recursive-key-of.type.ts | 48 +++++++++ src/types/type-if-type.type.ts | 29 ++++++ src/utils/value-from-path.spec.ts | 33 ++++++ src/utils/value-from-path.ts | 26 +++++ test/common/test.guard.ts | 6 +- test/gql/item/dto/recipes.args.ts | 4 +- test/gql/item/item.service.ts | 1 + test/gql/test-gql.filter.ts | 4 +- test/rest/http.app.ts | 4 +- 19 files changed, 440 insertions(+), 103 deletions(-) delete mode 100644 src/lib/copy-metadata.ts create mode 100644 src/types/recursive-key-of.type.ts create mode 100644 src/types/type-if-type.type.ts create mode 100644 src/utils/value-from-path.spec.ts create mode 100644 src/utils/value-from-path.ts diff --git a/README.md b/README.md index 824aecb3..d4c4d0f2 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # NestJS CLS +> **New**: Release `2.0` brings advanced [type safety and type inference](#type-safety-and-type-inference), check below for more info. + A continuation-local storage module compatible with [NestJS](https://nestjs.com/)'s dependency injection. _Continuous-local storage allows to store state and propagate it throughout callbacks and promise chains. It allows storing data throughout the lifetime of a web request or any other asynchronous duration. It is similar to thread-local storage in other languages._ Some common use cases for CLS include: -- Request ID tracing for logging purposes +- Tracing the Request ID and other metadata for logging purposes - Making the Tenant ID available everywhere in multi-tenant apps -- Globally setting the authentication level for the request +- Globally setting an authentication level for the request -Most of these are theoretically solvable using _request-scoped_ providers or passing the context as a parameter, but these solutions are often clunky and come with a whole lot of other issues. Thus this package was born. +Most of these are to some extent solvable using _request-scoped_ providers or passing the context as a parameter, but these solutions are often clunky and come with a whole lot of other issues. -> **Note**: For versions < 1.2, this package used [cls-hooked](https://www.npmjs.com/package/cls-hooked) as a peer dependency, now it uses [AsyncLocalStorage](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage) from Node's `async_hooks` directly. The API stays the same for now but I'll consider making it more friendly for version 2. +> **Note**: This package uses [AsyncLocalStorage](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage) from Node's `async_hooks` API. Most parts of it are marked as _stable_ now, see [Security considerations](#security-considerations) for more details. # Outline @@ -28,6 +30,7 @@ Most of these are theoretically solvable using _request-scoped_ providers or pas - [Request ID](#request-id) - [Additional CLS Setup](#additional-cls-setup) - [Breaking out of DI](#breaking-out-of-di) +- [Type safety and type inference](#type-safety-and-type-inference) - [Security considerations](#security-considerations) - [Compatibility considerations](#compatibility-considerations) - [REST](#rest) @@ -58,7 +61,7 @@ Below is an example of storing the client's IP address in an interceptor and ret // Register the ClsModule and automatically mount the ClsMiddleware ClsModule.register({ global: true, - middleware: { mount: true } + middleware: { mount: true }, }), ], providers: [AppService], @@ -66,15 +69,14 @@ Below is an example of storing the client's IP address in an interceptor and ret }) export class TestHttpApp {} - /* user-ip.interceptor.ts */ @Injectable() export class UserIpInterceptor implements NestInterceptor { constructor( // Inject the ClsService into the interceptor to get // access to the current shared cls context. - private readonly cls: ClsService - ) + private readonly cls: ClsService, + ); intercept(context: ExecutionContext, next: CallHandler): Observable { // Extract the client's ip address from the request... @@ -86,7 +88,6 @@ export class UserIpInterceptor implements NestInterceptor { } } - /* app.controller.ts */ // By mounting the interceptor on the controller, it gets access @@ -102,13 +103,12 @@ export class AppController { } } - /* app.service.ts */ @Injectable() export class AppService { constructor( // Inject ClsService to be able to retrieve data from the cls context. - private readonly cls: ClsService + private readonly cls: ClsService, ) {} sayHello() { @@ -194,7 +194,9 @@ If you need any other guards to use the `ClsService`, it's preferable to mount ` }) export class AppModule {} ``` + or mount it directly on the Controller/Resolver with + ```ts @UseGuards(ClsGuard); ``` @@ -214,26 +216,23 @@ ClsModule.register({ ``` Or mount it manually as `APP_INTERCEPTOR`, or directly on the Controller/Resolver with: + ```ts @UseInterceptors(ClsInterceptor); ``` > **Please note**: Since Nest's _Interceptors_ run after _Guards_, that means using this method makes CLS **unavailable in Guards** (and in case of REST Controllers, also in **Exception Filters**). - - # API The injectable `ClsService` provides the following API to manipulate the cls context: -- **_`set`_**`(key: string, value: T): T` +- **_`set`_**`(key: string, value: any): void` Set a value on the CLS context. -- **_`get`_**`(key: string): T` - Retrieve a value from the CLS context by key. +- **_`get`_**`(key?: string): any` + Retrieve a value from the CLS context by key. Get the whole store if key is omitted. - **_`getId`_**`(): string;` Retrieve the request ID (a shorthand for `cls.get(CLS_ID)`) -- **_`getStore`_**`(): any` - Retrieve the object containing all properties of the current CLS context. - **_`enter`_**`(): void;` Run any following code in a shared CLS context. - **_`enterWith`_**`(store: any): void;` @@ -284,7 +283,6 @@ The `ClsMiddlewareOptions` additionally takes the following parameters: - **_`useEnterWith`_: `boolean`** (default _`false`_) Set to `true` to set up the context using a call to [`AsyncLocalStorage#enterWith`](https://nodejs.org/api/async_context.html#async_context_asynclocalstorage_enterwith_store) instead of wrapping the `next()` call with the safer [`AsyncLocalStorage#run`](https://nodejs.org/api/async_context.html#async_context_asynclocalstorage_run_store_callback_args). Most of the time this should not be necessary, but [some frameworks](#graphql) are known to lose the context with `run`. - # Request ID Because of a shared storage, CLS is an ideal tool for tracking request (correlation) IDs for the purpose of logging. This package provides an option to automatically generate request IDs in the middleware/guard/interceptor, if you pass `{ generateId: true }` to its options. By default, the generated ID is a string based on `Math.random()`, but you can provide a custom function in the `idGenerator` option. @@ -341,7 +339,7 @@ The function receives the `ClsService` instance and the `Request` (or `Execution ClsModule.register({ middleware: { mount: true, - setup: (cls, req) => { + setup: (cls, req: Request) => { // put some additional default info in the CLS cls.set('TENANT_ID', req.params('tenant_id')); cls.set('AUTH', { authenticated: false }); @@ -364,6 +362,71 @@ function helper() { > **Please note**: Only use this feature where absolutely necessary. Using this technique instead of dependency injection will make it difficult to mock the ClsService and your code will become harder to test. +# Type safety and type inference + +By default the CLS context is untyped and allows setting and retrieving any `string` or `symbol` key from the context. Some safety can be enforced by using `CONSTANTS` instead of magic strings, but that might not be enough. + +Therefore, it is possible to specify a custom interface for the `ClsService` and get proper typing and automatic type inference when retrieving or setting values. This works even for _nested objects_ using a dot notation. + +To create a typed CLS Store, start by creating an interface that extends `ClsStore`. + +```ts +export interface MyClsStore extends ClsStore { + tenantId: string; + user: { + id: number; + authorized: boolean; + }; +} +``` + +Then you can inject the `ClsService` with a type parameter `ClsService` and + +```ts +export class MyService { + constructor(private readonly cls: ClsService) {} + + doTheThing() { + // a boolean type will be enforced here + this.cls.set('user.authorized', true); + + // tenantId will be inferred as a stirng + const tenantId = this.cls.get('tenantId'); + + // userId will be inferred as a number + const userId = this.cls.get('user.id'); + + // user will be inferred as { id: number, authorized: boolean } + const user = this.cls.get('user'); + + // you'll even get intellisense for the keys, because the type + // will be inferred as: + // symbol | 'tenantId˙ | 'user' | 'user.id' | 'user.authorized' + + // alternatively, since the `get` method returns the whole store + // when called without arguments, you can use object destructuring + const { tenantId, user } = this.cls.get(); + + // accessing a nonexistent property will result in a type error + const notExist = this.cls.get('user.name'); + } +} +``` + +Alternatively, if you feel like using `ClsService` everywhere is tedious, you can instead globally [augment the `ClsStore interface`](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) and have strict typing of `ClsService` anywhere without the type parameter: + +```ts +declare module 'nestjs-cls' { + interface ClsStore { + tenantId: string; + user: { + id: number; + authorized: boolean; + }; + } +} +``` + # Security considerations It is often discussed whether [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html) is safe to use for _concurrent requests_ (because of a possible context leak) and whether the context could be _lost_ throughout the duration of a request. @@ -384,11 +447,11 @@ The `ClsInterceptor` only uses the safe `run()` method. The table below outlines the compatibility with some platforms: -| | REST | GQL | WS |Others | -| :----------------------------------------------------------: | :-------------------------------------------------: | :--------------------------------------------------------: |:--:|:----: | -| **ClsMiddleware** | ✔ | ✔
must be _mounted manually_
and use `useEnterWith: true` | ✖ | ✖ | -| **ClsGuard**
(uses `enterWith`) | ✔ | ✔ | ✔[*](#websockets) | ? | -| **ClsInterceptor**
(context inaccessible
in _Guards_) | ✔
context also inaccessible
in _Exception Filters_ | ✔ | ✔[*](#websockets) |? | +| | REST | GQL | WS | Others | +| :----------------------------------------------------------: | :------------------------------------------------------: | :-------------------------------------------------------------: | :----------------: | :----: | +| **ClsMiddleware** | ✔ | ✔
must be _mounted manually_
and use `useEnterWith: true` | ✖ | ✖ | +| **ClsGuard**
(uses `enterWith`) | ✔ | ✔ | ✔[\*](#websockets) | ? | +| **ClsInterceptor**
(context inaccessible
in _Guards_) | ✔
context also inaccessible
in _Exception Filters_ | ✔ | ✔[\*](#websockets) | ? | ## REST @@ -419,7 +482,8 @@ Use the `ClsGuard` or `ClsInterceptor` to set up context with any other platform Below are listed platforms with which it is confirmed to work. ### Websockets -*Websocket Gateways* don't respect globally bound enhancers, therefore it is required to bind the `ClsGuard` or `ClsIntercetor` manually on the `WebscocketGateway`. (See [#8](https://github.com/Papooch/nestjs-cls/issues/8)) + +_Websocket Gateways_ don't respect globally bound enhancers, therefore it is required to bind the `ClsGuard` or `ClsIntercetor` manually on the `WebscocketGateway`. (See [#8](https://github.com/Papooch/nestjs-cls/issues/8)) # Namespaces (experimental) diff --git a/package.json b/package.json index 3e22d5c6..5b456c6f 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,12 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "globals": { + "ts-jest": { + "isolatedModules": true, + "maxWorkers": 1 + } + } } } diff --git a/src/lib/cls.constants.ts b/src/lib/cls.constants.ts index 114a2352..16af81e0 100644 --- a/src/lib/cls.constants.ts +++ b/src/lib/cls.constants.ts @@ -1,6 +1,6 @@ -export const CLS_REQ = 'CLS_REQUEST'; -export const CLS_RES = 'CLS_RESPONSE'; -export const CLS_ID = 'CLS_ID'; +export const CLS_REQ = Symbol('CLS_REQUEST'); +export const CLS_RES = Symbol('CLS_RESPONSE'); +export const CLS_ID = Symbol('CLS_ID'); export const CLS_DEFAULT_NAMESPACE = 'CLS_DEFAULT_NAMESPACE'; export const CLS_MODULE_OPTIONS = 'ClsModuleOptions'; export const CLS_MIDDLEWARE_OPTIONS = 'ClsMiddlewareOptions'; diff --git a/src/lib/cls.guard.ts b/src/lib/cls.guard.ts index 6c54aed8..705eb28b 100644 --- a/src/lib/cls.guard.ts +++ b/src/lib/cls.guard.ts @@ -23,7 +23,7 @@ export class ClsGuard implements CanActivate { cls.enter(); if (this.options.generateId) { const id = await this.options.idGenerator(context); - cls.set(CLS_ID, id); + cls.set(CLS_ID, id); } if (this.options.setup) { await this.options.setup(cls, context); diff --git a/src/lib/cls.interceptor.ts b/src/lib/cls.interceptor.ts index 09149896..24c4948f 100644 --- a/src/lib/cls.interceptor.ts +++ b/src/lib/cls.interceptor.ts @@ -25,7 +25,7 @@ export class ClsInterceptor implements NestInterceptor { cls.run(async () => { if (this.options.generateId) { const id = await this.options.idGenerator(context); - cls.set(CLS_ID, id); + cls.set(CLS_ID, id); } if (this.options.setup) { await this.options.setup(cls, context); diff --git a/src/lib/cls.interfaces.ts b/src/lib/cls.interfaces.ts index 645ac949..a24c9379 100644 --- a/src/lib/cls.interfaces.ts +++ b/src/lib/cls.interfaces.ts @@ -68,14 +68,14 @@ export class ClsMiddlewareOptions { /** * the function to generate request ids inside the middleware */ - idGenerator?: (req: Request) => string | Promise = () => + idGenerator?: (req: any) => string | Promise = () => Math.random().toString(36).slice(-8); /** * Function that executes after the CLS context has been initialised. * It can be used to put additional variables in the CLS context. */ - setup?: (cls: ClsService, req: Request) => void | Promise; + setup?: (cls: ClsService, req: any) => void | Promise; /** * Whether to store the Request object to the CLS @@ -159,3 +159,7 @@ export class ClsInterceptorOptions { readonly namespaceName?: string; } + +export interface ClsStore { + [key: symbol]: any; +} diff --git a/src/lib/cls.middleware.ts b/src/lib/cls.middleware.ts index 86a2ff35..271a62f2 100644 --- a/src/lib/cls.middleware.ts +++ b/src/lib/cls.middleware.ts @@ -21,10 +21,10 @@ export class ClsMiddleware implements NestMiddleware { this.options.useEnterWith && cls.enter(); if (this.options.generateId) { const id = await this.options.idGenerator(req); - cls.set(CLS_ID, id); + cls.set(CLS_ID, id); } - if (this.options.saveReq) cls.set(CLS_REQ, req); - if (this.options.saveRes) cls.set(CLS_RES, res); + if (this.options.saveReq) cls.set(CLS_REQ, req); + if (this.options.saveRes) cls.set(CLS_RES, res); if (this.options.setup) { await this.options.setup(cls, req); } diff --git a/src/lib/cls.service.spec.ts b/src/lib/cls.service.spec.ts index cd953143..cd0ec51e 100644 --- a/src/lib/cls.service.spec.ts +++ b/src/lib/cls.service.spec.ts @@ -1,13 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ClsServiceManager } from './cls-service-manager'; -import { CLS_DEFAULT_NAMESPACE } from './cls.constants'; +import { CLS_DEFAULT_NAMESPACE, CLS_ID } from './cls.constants'; +import { ClsStore } from './cls.interfaces'; import { ClsService } from './cls.service'; describe('ClsService', () => { + let module: TestingModule; let service: ClsService; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + module = await Test.createTestingModule({ providers: [ { provide: ClsService, @@ -21,33 +23,142 @@ describe('ClsService', () => { service = module.get(ClsService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('happy path', () => { + it('is defined', () => { + expect(service).toBeDefined(); + }); + + it('sets and retrieves the context', () => { + service.run(() => { + service.set('key', 123); + expect(service.get()).toEqual({ key: 123 }); + }); + }); + it('sets and retrieves a single key from context', () => { + service.run(() => { + service.set('key', 123); + expect(service.get('key')).toEqual(123); + }); + }); + + it('does not retireve context in a different call (run)', () => { + service.run(() => { + service.set('key', 123); + }); + service.run(() => { + expect(service.get('key')).not.toEqual(123); + }); + }); + it('does not retireve context in a different call (enter)', () => { + const runMe = (cb: () => void) => cb(); + runMe(() => { + service.enter(); + service.set('key', 123); + }); + runMe(() => { + service.enter(); + expect(service.get('key')).not.toEqual(123); + }); + }); }); - it('should set and retrieve the context', () => { - service.run(() => { - service.set('key', 123); - expect(service.get('key')).toEqual(123); + describe('store access', () => { + it('retrieves the whole store', () => { + service.run(() => { + service.set('a', 1); + service.set('b', 2); + service.set('c', 3); + + expect(service.get()).toEqual({ + a: 1, + b: 2, + c: 3, + }); + }); + }); + + it('sets and retrieves symbol key from context', () => { + const sym = Symbol('sym'); + service.run(() => { + service.set(sym, 123); + expect(service.get(sym)).toEqual(123); + }); + }); + it('sets CLS_ID and retrieves it with getId', () => { + service.run(() => { + service.set(CLS_ID, 123); + expect(service.getId()).toEqual(123); + }); }); }); - it('should not retireve context in a different call (run)', () => { - service.run(() => { - service.set('key', 123); + + describe('edge cases', () => { + it('returns undefined on nonexistent key', () => { + service.run(() => { + const value = service.get('key'); + expect(value).toBeUndefined(); + }); }); - service.run(() => { - expect(service.get('key')).not.toEqual(123); + + it('throws error if trying to set a value without context', () => { + expect(() => service.set('key', 123)).toThrowError(); }); }); - it('should not retireve context in a different call (enter)', () => { - const runMe = (cb: () => void) => cb(); - runMe(() => { - service.enter(); - service.set('key', 123); - }); - runMe(() => { - service.enter(); - expect(service.get('key')).not.toEqual(123); + + describe('nested values', () => { + it('sets an object and gets a nested value', () => { + service.run(() => { + service.set('a', { b: 4 }); + expect(service.get('a.b')).toEqual(4); + }); + }); + + it('sets nested value and gets an object', () => { + service.run(() => { + service.set('a.b', 8); + expect(service.get('a')).toEqual({ b: 8 }); + }); + }); + + it('gets undefined for deep nested value that does not exist', () => { + service.run(() => { + expect(service.get('e.f.g.h')).toBeUndefined(); + }); + }); + }); + + describe('typing', () => { + interface IStore extends ClsStore { + a: string; + b: { + c: number; + d: { + e: boolean; + }; + }; + } + + let typedService: ClsService; + + beforeEach(() => { + typedService = module.get>(ClsService); + }); + + it('enforces types', () => { + typedService.run(() => { + typedService.set('a', '1'); + typedService.set('b.c', 1); + typedService.set('b.d.e', false); + + const { a, b } = typedService.get(); + a; + b; + + typedService.get('a'); + typedService.get('b.c'); + typedService.get('b.d'); + typedService.get('b.d.e'); + }); }); }); }); diff --git a/src/lib/cls.service.ts b/src/lib/cls.service.ts index 1ffa5daa..88ec8794 100644 --- a/src/lib/cls.service.ts +++ b/src/lib/cls.service.ts @@ -1,7 +1,18 @@ import { AsyncLocalStorage } from 'async_hooks'; +import { + DeepPropertyType, + RecursiveKeyOf, +} from '../types/recursive-key-of.type'; +import { + AnyIfNever, + StringIfNever, + TypeIfUndefined, +} from '../types/type-if-type.type'; +import { getValueFromPath, setValueFromPath } from '../utils/value-from-path'; import { CLS_ID } from './cls.constants'; +import { ClsStore } from './cls.interfaces'; -export class ClsService> { +export class ClsService { private readonly namespace: AsyncLocalStorage; constructor(namespace: AsyncLocalStorage) { this.namespace = namespace; @@ -12,24 +23,54 @@ export class ClsService> { * @param key the key * @param value the value to set */ - set(key: string, value: T) { + set< + // eslint-disable-next-line @typescript-eslint/no-unused-vars + R = undefined, + T extends RecursiveKeyOf = any, + P extends DeepPropertyType = any, + >(key: StringIfNever | keyof ClsStore, value: AnyIfNever

): void { const store = this.namespace.getStore(); if (!store) { throw new Error( - `Cannot se the key "${key}". No cls context available in namespace "${this.namespace['name']}", please make sure to wrap any calls that depend on cls with "ClsService#run" or register the ClsMiddleware for all routes that use ClsService`, + `Cannot se the key "${String( + key, + )}". No cls context available in namespace "${ + this.namespace['name'] + }", please make sure that a ClsMiddleware/Guard/Interceptor has set up the context, or wrap any calls that depend on cls with "ClsService#run"`, ); } - store[key] = value; + if (typeof key === 'symbol') { + store[key] = value; + } else { + setValueFromPath(store as S, key as any, value as P); + } } /** * Retrieve a value from the CLS context by key. - * @param key the key from which to retrieve the value + * @param key the key from which to retrieve the value, returns the whole context if ommited + * @returns the value stored under the key or undefined + */ + get(): AnyIfNever; + /** + * Retrieve a value from the CLS context by key. + * @param key the key from which to retrieve the value, returns the whole context if ommited * @returns the value stored under the key or undefined */ - get(key: string): T { + get< + R = undefined, + T extends RecursiveKeyOf = any, + P = DeepPropertyType, + >( + key?: StringIfNever | keyof ClsStore, + ): TypeIfUndefined>, R>; + get(key?: string | symbol): any { const store = this.namespace.getStore(); - return store?.[key]; + if (!key) return store; + if (typeof key === 'symbol') { + return store[key]; + } + return getValueFromPath(store as S, key as any) as any; } /** @@ -41,14 +82,6 @@ export class ClsService> { return store?.[CLS_ID]; } - /** - * Retrieve the object containing all properties of the current CLS context. - * @returns the store - */ - getStore(): S { - return this.namespace.getStore(); - } - /** * Run the callback with a shared CLS context. * @param callback function to run @@ -64,7 +97,7 @@ export class ClsService> { * @param callback function to run * @returns whatever the callback returns */ - runWith(store: any, callback: () => T) { + runWith(store: S, callback: () => T) { return this.namespace.run(store ?? {}, callback); } @@ -79,8 +112,8 @@ export class ClsService> { * Run any following code with a shared ClS context * @param store the default context contents */ - enterWith(store: any = {}) { - return this.namespace.enterWith(store); + enterWith(store?: S) { + return this.namespace.enterWith(store ?? {}); } /** diff --git a/src/lib/copy-metadata.ts b/src/lib/copy-metadata.ts deleted file mode 100644 index 1a2b10da..00000000 --- a/src/lib/copy-metadata.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copies all metadata from one object to another. - * Useful for overwriting function definition in - * decorators while keeping all previously - * attached metadata - * - * @param from object to copy metadata from - * @param to object to copy metadata to - */ -export function copyMetadata(from: any, to: any) { - const metadataKeys = Reflect.getMetadataKeys(from); - metadataKeys.map((key) => { - const value = Reflect.getMetadata(key, from); - Reflect.defineMetadata(key, value, to); - }); -} diff --git a/src/types/recursive-key-of.type.ts b/src/types/recursive-key-of.type.ts new file mode 100644 index 00000000..662fdba0 --- /dev/null +++ b/src/types/recursive-key-of.type.ts @@ -0,0 +1,48 @@ +type Terminal = + | string + | number + | bigint + | boolean + | null + | undefined + | Date + | RegExp + | ((...args: any) => any); + +/** + * Deep nested keys of an interface with dot syntax + * + * @example + * type t = RecursiveKeyOf<{a: {b: {c: string}}> // => 'a' | 'a.b' | 'a.b.c' + */ +export type RecursiveKeyOf< + T, + Prefix extends string = never, +> = T extends Terminal + ? never + : { + [K in keyof T & string]: [Prefix] extends [never] + ? K | RecursiveKeyOf + : `${Prefix}.${K}` | RecursiveKeyOf; + }[keyof T & string]; + +/** + * Get the type of a nested property with dot syntax + * + * Basically the inverse of `RecursiveKeyOf` + * + * @example + * type t = DeepPropertyType<{a: {b: {c: string}}}, 'a.b.c'> // => string + */ +export type DeepPropertyType< + T, + P extends RecursiveKeyOf, +> = P extends `${infer Prefix}.${infer Rest}` + ? Prefix extends keyof T + ? Rest extends RecursiveKeyOf + ? DeepPropertyType + : never + : never + : P extends keyof T + ? T[P] + : never; diff --git a/src/types/type-if-type.type.ts b/src/types/type-if-type.type.ts new file mode 100644 index 00000000..10ccfea6 --- /dev/null +++ b/src/types/type-if-type.type.ts @@ -0,0 +1,29 @@ +/** + * If the condition type (C) extends (E), return T, else E2 + */ +export type TypeIfType = [C] extends [E] ? T : T2; + +/** + * If the condition type (C) extends (E), return T, else E2 + */ +export type TypeIfSymbol = [C] extends [symbol] ? T : T2; + +/** + * If the condition type (C) is `undefined`, return T, else T2 + */ +export type TypeIfUndefined = [C] extends [undefined] ? T : T2; + +/** + * If the condition type (C) is `never`, return T, else C + */ +export type TypeIfNever = [C] extends [never] ? T : C; + +/** + * If the condition type (C) is `never`, return `string`, else C + */ +export type AnyIfNever = TypeIfNever; + +/** + * If the condition type (C) is `never`, return `any`, else C + */ +export type StringIfNever = TypeIfNever; diff --git a/src/utils/value-from-path.spec.ts b/src/utils/value-from-path.spec.ts new file mode 100644 index 00000000..0b2192e3 --- /dev/null +++ b/src/utils/value-from-path.spec.ts @@ -0,0 +1,33 @@ +import { getValueFromPath, setValueFromPath } from './value-from-path'; + +describe('getValueFromPath', () => { + const obj = { a: { b: { c: 4 } } }; + it('gets top level value from path', () => { + expect(getValueFromPath(obj, 'a')).toEqual({ b: { c: 4 } }); + }); + it('gets nested value from path', () => { + expect(getValueFromPath(obj, 'a.b.c')).toEqual(4); + }); + it('gets undefined for value that does not exist', () => { + expect(getValueFromPath(obj, 'a.b.c.d' as any)).toBeUndefined; + }); +}); +describe('setValueFromPath', () => { + const expected = { a: { b: { c: 4 } } }; + it('sets top level value from path', () => { + const obj = {} as typeof expected; + expect(setValueFromPath(obj, 'a', { b: { c: 4 } })).toEqual(expected); + }); + it('gets nested value from path', () => { + const obj = {} as typeof expected; + expect(setValueFromPath(obj, 'a.b.c', 4)).toEqual(expected); + }); + it("doesn't overwrite existing values if nested value is set", () => { + const expected2 = expected as any; + expected2.a.d = 8; + const obj = {} as typeof expected; + setValueFromPath(obj, 'a.b.c', 4); + setValueFromPath(obj, 'a.d' as any, 8); + expect(obj).toEqual(expected2); + }); +}); diff --git a/src/utils/value-from-path.ts b/src/utils/value-from-path.ts new file mode 100644 index 00000000..a7165a32 --- /dev/null +++ b/src/utils/value-from-path.ts @@ -0,0 +1,26 @@ +import { + RecursiveKeyOf, + DeepPropertyType, +} from '../types/recursive-key-of.type'; + +export function getValueFromPath< + T extends any, + TP extends RecursiveKeyOf & string, +>(obj: T, path?: TP): DeepPropertyType { + const pathSegments = path.split('.'); + return pathSegments.reduce((acc, curr) => acc?.[curr], obj); +} + +export function setValueFromPath< + T extends any, + TP extends RecursiveKeyOf & string, + V extends DeepPropertyType, +>(obj: T, path: TP, value: V) { + const pathSegments = path.split('.'); + const leaf = pathSegments.slice(0, -1).reduce((acc, curr) => { + acc[curr] ?? (acc[curr] = {}); + return acc[curr]; + }, obj ?? {}); + leaf[pathSegments.pop()] = value; + return obj; +} diff --git a/test/common/test.guard.ts b/test/common/test.guard.ts index 4668c1ff..09e88644 100644 --- a/test/common/test.guard.ts +++ b/test/common/test.guard.ts @@ -1,4 +1,4 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { CanActivate, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { ClsService } from '../../src'; @@ -6,9 +6,7 @@ import { ClsService } from '../../src'; export class TestGuard implements CanActivate { constructor(private readonly cls: ClsService) {} - canActivate( - context: ExecutionContext, - ): boolean | Promise | Observable { + canActivate(): boolean | Promise | Observable { if (this.cls.isActive()) this.cls.set('FROM_GUARD', this.cls.getId()); return true; } diff --git a/test/gql/item/dto/recipes.args.ts b/test/gql/item/dto/recipes.args.ts index 78a7c58f..a27e02b3 100644 --- a/test/gql/item/dto/recipes.args.ts +++ b/test/gql/item/dto/recipes.args.ts @@ -3,11 +3,11 @@ import { Max, Min } from 'class-validator'; @ArgsType() export class RecipesArgs { - @Field((type) => Int) + @Field(() => Int) @Min(0) skip = 0; - @Field((type) => Int) + @Field(() => Int) @Min(1) @Max(50) take = 25; diff --git a/test/gql/item/item.service.ts b/test/gql/item/item.service.ts index 772cfff4..1cdbe81b 100644 --- a/test/gql/item/item.service.ts +++ b/test/gql/item/item.service.ts @@ -8,6 +8,7 @@ export class ItemService { constructor(private readonly cls: ClsService) {} async findAll(recipesArgs: RecipesArgs): Promise { + recipesArgs; const payload = [ { id: this.cls.getId(), diff --git a/test/gql/test-gql.filter.ts b/test/gql/test-gql.filter.ts index 9746cc22..20f10c4e 100644 --- a/test/gql/test-gql.filter.ts +++ b/test/gql/test-gql.filter.ts @@ -1,4 +1,4 @@ -import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { Catch, ExceptionFilter } from '@nestjs/common'; import { HttpAdapterHost } from '@nestjs/core'; import mercurius from 'mercurius'; import { ClsService } from '../../src'; @@ -11,7 +11,7 @@ export class TestGqlExceptionFilter implements ExceptionFilter { private readonly cls: ClsService, ) {} - catch(exception: TestException, host: ArgumentsHost) { + catch(exception: TestException) { const adapter = this.adapterHost.httpAdapter; exception.response.fromFilter = this.cls.getId(); diff --git a/test/rest/http.app.ts b/test/rest/http.app.ts index e76e19f9..91bc7347 100644 --- a/test/rest/http.app.ts +++ b/test/rest/http.app.ts @@ -6,11 +6,11 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common'; -import { ClsService, CLS_ID } from '../../src'; +import { ClsService } from '../../src'; import { TestException } from '../common/test.exception'; -import { TestRestExceptionFilter } from './test-rest.filter'; import { TestGuard } from '../common/test.guard'; import { TestInterceptor } from '../common/test.interceptor'; +import { TestRestExceptionFilter } from './test-rest.filter'; @Injectable() export class TestHttpService {