Skip to content

Instantly share code, notes, and snippets.

@flisboac
Last active December 13, 2021 19:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save flisboac/6af02b5254088558362757593dc54f9c to your computer and use it in GitHub Desktop.
Save flisboac/6af02b5254088558362757593dc54f9c to your computer and use it in GitHub Desktop.
// https://gist.github.com/flisboac/6af02b5254088558362757593dc54f9c
import {
Abstract,
DynamicModule,
INestApplicationContext,
Type,
} from '@nestjs/common';
import { ContextId } from '@nestjs/core';
import {
ClassConstructor,
ClassTransformOptions,
plainToClass,
TransformOptions,
TransformationType,
TransformFnParams,
} from 'class-transformer';
export interface Transformer {
transform(params: TransformFnParams): Promise<unknown>;
}
export interface TransformerFunction {
(params: AsyncTransformerParams): Promise<unknown>;
}
export interface TransformerResolver {
resolveTransformer(options: TransformerResolverOptions): Promise<Transformer>;
}
export type TransformerResolverOptions = {
strict: boolean;
scoped: boolean;
contextId?: ContextId;
token: ResolvedTransformerToken;
};
export type BaseTransformOptions = TransformOptions & {
select?: Type<any> | DynamicModule;
strict?: boolean;
scoped?: boolean;
};
export type AsyncTransformWithOptions = BaseTransformOptions & {
token?: TransformerToken;
};
export type AsyncTransformOptions = BaseTransformOptions & {
body?: TransformerFunction;
};
export type AnyTransformOptions =
| AsyncTransformOptions
| AsyncTransformWithOptions;
export type AsyncTransformerParams = Omit<TransformFnParams, 'options'> & {
options: AnyTransformOptions;
};
export type TransformEntry = {
propertyKey: string | symbol;
options: AnyTransformOptions;
};
type Token<T> = string | symbol | Type<T> | Abstract<T>;
type AbstractTransformerType = Abstract<Transformer>;
type TransformerType = Type<Transformer>;
type TransformerToken =
| string
| symbol
| (() => AbstractTransformerType | TransformerType);
type ResolvedTransformerToken =
| string
| symbol
| AbstractTransformerType
| TransformerType;
const TRANSFORM_ENTRIES = Symbol('TRANSFORM_ENTRIES');
class TransformContext implements TransformerResolver {
public appContext: INestApplicationContext;
public appContextId: ContextId | undefined;
public resolverToken: Token<TransformerResolver> | undefined;
async _initialize(
appContext: INestApplicationContext,
appContextId: ContextId | undefined,
resolverToken: Token<TransformerResolver> | undefined,
): Promise<void> {
if (this.appContext) {
throw new Error(
'The InjectedTransform mechanism was already initialized.',
);
}
this.appContext = appContext;
this.appContextId = appContextId;
this.resolverToken = resolverToken;
}
_ensureInitialized() {
if (!this.appContext) {
throw new Error('Async Transform context was not initialized yet.');
}
}
async getTransformer(
options: AsyncTransformWithOptions,
): Promise<Transformer> {
const resolvedOptions = this.resolveOptions(options);
const { nestContext, strict, token } = resolvedOptions;
const resolver = this.resolverToken
? nestContext.get(this.resolverToken, { strict })
: this;
const instance = await resolver.resolveTransformer(resolvedOptions);
if (!instance) {
const transformerName =
typeof token === 'function' ? token.name : String(token);
throw new Error(
`No instance for transformer of type ${transformerName} was created, or \`useContainer\` was not called yet.`,
);
}
return instance;
}
async resolveTransformer(
options: TransformerResolverOptions & {
nestContext: INestApplicationContext;
},
): Promise<Transformer> {
const { nestContext, strict, contextId, scoped, token } = options;
if (scoped) {
return nestContext.resolve(token, contextId, { strict });
}
return nestContext.get(token, { strict });
}
addTransformEntry<T>(
type: Type<T> | Abstract<T>,
options: TransformEntry,
): void {
if (typeof type === 'function' && 'prototype' in type) {
type = type.prototype;
}
const constructor = type.constructor;
const entries: TransformEntry[] =
Reflect.getMetadata(TRANSFORM_ENTRIES, constructor) || [];
entries.push(options);
Reflect.defineMetadata(TRANSFORM_ENTRIES, entries, constructor);
}
listTransformEntries<T>(type: Type<T> | Abstract<T>): TransformEntry[] {
if (typeof type === 'function' && 'prototype' in type) {
type = type.prototype;
}
const constructor = type.constructor;
return Reflect.getMetadata(TRANSFORM_ENTRIES, constructor) || [];
}
private resolveOptions(
options: AsyncTransformWithOptions,
): TransformerResolverOptions & { nestContext: INestApplicationContext } {
const { select } = options;
let nestContext = this.appContext;
if (select) {
nestContext = this.appContext.select(select);
}
const strict = options.strict ?? !!module;
const scoped = options.scoped ?? false;
const token = resolveToken(options.token);
return { strict, scoped, token, nestContext };
}
}
let context: TransformContext;
function getContext(): TransformContext {
if (!context) {
context = new TransformContext();
}
return context;
}
function getInitializedContext(): TransformContext {
const ctx = getContext();
ctx._ensureInitialized();
return ctx;
}
function resolveToken(token: TransformerToken): ResolvedTransformerToken {
return typeof token === 'function' ? token() : token;
}
export interface UseContainerOptions {
contextId?: ContextId;
resolver?: Token<TransformerResolver>;
}
export async function useContainer(
_appContext: INestApplicationContext,
options: UseContainerOptions = {},
): Promise<void> {
getContext()._initialize(_appContext, options.contextId, options.resolver);
}
export function AsyncTransform(
fn: TransformerFunction,
options?: AsyncTransformOptions,
): PropertyDecorator;
export function AsyncTransform(
options: AsyncTransformOptions & { body: TransformerToken },
): PropertyDecorator;
export function AsyncTransform(
_body:
| TransformerFunction
| (AsyncTransformOptions & { body: TransformerToken }),
_options: AsyncTransformOptions = {},
): PropertyDecorator {
let options = _options;
if (typeof _body === 'function') {
options.body = _body;
} else {
options = _body;
}
return (type: any, propertyKey) => {
getContext().addTransformEntry(type, { propertyKey, options });
};
}
export function AsyncTransformWith(
useClass: TransformerToken,
options?: AsyncTransformWithOptions,
): PropertyDecorator;
export function AsyncTransformWith(
options: AsyncTransformWithOptions & { token: TransformerToken },
): PropertyDecorator;
export function AsyncTransformWith(
_token:
| TransformerToken
| (AsyncTransformWithOptions & { token: TransformerToken }),
_options: AsyncTransformWithOptions = {},
): PropertyDecorator {
let options = _options;
if (
typeof _token === 'string' ||
typeof _token === 'symbol' ||
typeof _token === 'function'
) {
options = { ..._options, token: _token };
} else {
options = _token;
}
return (type: any, propertyKey) => {
getContext().addTransformEntry(type, { propertyKey, options });
};
}
export function asyncPlainToClass<T, V>(
type: ClassConstructor<T>,
plain: V[],
options?: ClassTransformOptions,
): Promise<T[]>;
export function asyncPlainToClass<T, V>(
type: ClassConstructor<T>,
plain: V,
options?: ClassTransformOptions,
): Promise<T>;
export async function asyncPlainToClass<T, V>(
type: ClassConstructor<T>,
plains: V[] | V,
callOptions: ClassTransformOptions = {},
): Promise<T[] | T> {
const entries = getContext().listTransformEntries(type);
for (const entry of entries) {
const { propertyKey, options: decoratorOptions } = entry;
const options: AnyTransformOptions = {
...callOptions,
...decoratorOptions,
};
const postTransform = async (transformed: T): Promise<T> => {
const value = transformed[propertyKey];
const key = String(propertyKey);
const obj = transformed;
const type = TransformationType.PLAIN_TO_CLASS;
let propValue: unknown;
if ('body' in decoratorOptions) {
propValue = await decoratorOptions.body({
value,
key,
obj,
type,
options,
});
} else if ('token' in decoratorOptions) {
const transformer = await getInitializedContext().getTransformer(
decoratorOptions,
);
propValue = await transformer.transform({
value,
key,
obj,
type,
options,
});
} else {
// should never happen, tho
throw new Error('Invalid transform configuration');
}
transformed[propertyKey] = propValue;
return transformed;
};
if (Array.isArray(plains)) {
const transformed = plainToClass(type, plains, callOptions);
const result = await Promise.all(transformed.map(postTransform));
return result;
}
const transformed = plainToClass(type, plains, callOptions);
const result = await postTransform(transformed);
return result;
}
}
@Migushthe2nd
Copy link

How would I use this for an @InputType() class?

@flisboac
Copy link
Author

flisboac commented Nov 23, 2021

@migus

How would I use this for an @InputType() class?

I wrote this script with the use case of explicit class transformation in mind (e.g. custom ValidationPipe implementation, custom service to transform plain objects, etc). Anything that calls plainToClass directly, instead of asyncPlainToClass, will bypass this mechanism entirely. I don't think the default NestJS mechanism will work.

The idea here is for you to call useContainer, passing your global DI container instance. To transform into classes, you must use the asyncPlainToClass function instead of plainToClass. Internally, asyncPlainToclass uses class-transformer's plainToClass, but it resolves all properties previously registered with @AsyncTransform or @AsyncTransformWith before returning the transformed object.

There's another script I wrote that implements the same idea (i.e. using a DI container to inject and use validators) but in a slightly different way; the catch is that it does not resolve promise properties (you must do that yourself, after class transformation; this means no nested transformations for promises!): https://gist.github.com/flisboac/b778aba38e69e6a3dd9d6a70cda0edf9

This comment is also relevant: typestack/class-transformer#549 (comment)

@volkramweber
Copy link

volkramweber commented Nov 30, 2021

@flisboac Thanks a lot for your work, it looks like the feature I really miss while using class-transformer.
I try to use the AsyncTransform decorator within a DTO and asyncPlainToClass within a custom validation pipe.
But I always get Error: Async Transform context was not initialized yet. on startup.
Where and how do I have to call useContainer? Do I need to do anything else?

@flisboac
Copy link
Author

flisboac commented Dec 1, 2021

@volkramweber you should pass your NestJS instance to useContainer right after you've created it -- near where you configure your custom ValidationPipe, but before calling app.listen() (or, if you're working with a standalone app, before calling any other runtime logic or service method, etc). An example:

async function main() {
  const app = await NestFactory.create(AppModule);
  // Set up the validation pipe, and other things
  await useContainer(app);
  app.listen(3000);
}
main();

useContainer's signature is async to allow for eventual dynamic resolutions (or to perform any other async operations) while the DI container is being initially set up. But it's not necessary for it to be async, because the only thing useContainer does is to set up a global containing the DI container you just passed; you're free to change the signature to "sync" if you want.

Anyways, this error is thrown by this function. It's there just to ensure you've set up the Dependency Injection Context (aka. your NestJS instance) before you call any of the front-facing conversion methods (because you will need to inject services whilst converting plains, and whatnot).

@volkramweber
Copy link

volkramweber commented Dec 3, 2021

@flisboac So many thanks for your help!
Unfortunately I still can't get this working (Error: Async Transform context was not initialized yet. on startup).
I created a very basic test project here.
Am I missing something?

@flisboac
Copy link
Author

@volkramweber I made a rookie mistake there... I'm sorry! I fixed the script, hopefully everything is all right, now. I also opened a PR on your repo with the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment