Baterka
Baterka

Reputation: 3714

Custom ClassSerializerInterceptor in NestJS to serialize based on user role (express req object)

In NestJS route, I want to serialize reponse based on request (user's role). This means that I need to pass option groups: [] into transformToPlain method in ClassSerializerInterceptor so class-transformer can properly return filtered format:

This is original interceptor source: https://github.com/nestjs/nest/blob/master/packages/common/serializer/class-serializer.interceptor.ts

I just extended this class and changed intercept method to also include group option based on request:

export class CustomClassSerializerInterceptor extends ClassSerializerInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // @ts-ignore
    const contextOptions = this.getContextOptions(context);
    const options = {
      ...this.defaultOptions,
      ...contextOptions,
      groups: request.user.roles // Pseudo
    };
    return next
      .handle()
      .pipe(map((res: PlainLiteralObject | Array<PlainLiteralObject>) => this.serialize(res, options)));
  }
}

My entity:

export class Content extends BaseDatabaseEntity {
  @Column()
  @Transform((type) => EContentType[type])
  type: EContentType;

  @Expose({ groups: ["MODERATOR", "ADMIN"] })
  @Column({ default: "[]", type: "json" })
  data: TContentDataColumn[];
}

Is this proper way to do it? For example this.getContextOptions method is private in original source, so I need to do ts-ignore here, to override default intended privacy of class which seems to me as big no-no.

Am I even supposted to transform API response based on user's role, or it is anti-pattern?

Upvotes: 4

Views: 2615

Answers (2)

R Simioni
R Simioni

Reputation: 183

For NestJS >= 8.0

Yes, extending the ClassSerializerInterceptor is an adequate way to add more functionality into it.

As @Baterka have pointed out, this always worked but triggered compiler warnings.

Since then the source code has been changed so previously private fields like defaultOptions and getContextOptions are now protected instead, meaning they are available to derived classes.

Here is a working example that's fully compatible with NestJS version 8.0 or later:

roles-serializer.interceptor.ts

import { CallHandler, ClassSerializerInterceptor, ExecutionContext, Injectable, PlainLiteralObject } from '@nestjs/common';
import { map, Observable } from 'rxjs';
  
@Injectable()
export class RolesSerializerInterceptor extends ClassSerializerInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler
  ): Observable<any> {
    const userRoles  = context.switchToHttp().getRequest().user?.roles ?? [];
    const contextOptions = this.getContextOptions(context);
    const options = {
      ...this.defaultOptions,
      ...contextOptions,
      groups: userRoles
    };
    return next
      .handle()
      .pipe(
        map((res: PlainLiteralObject | PlainLiteralObject[]) =>
          this.serialize(res, options)
        )
      );
  }
}

Upvotes: 4

Jay McDoniel
Jay McDoniel

Reputation: 70490

You can make use of the @SerializationOptions() decorator to pass extra options based on what route is being triggered. As it seems you need to do this dynamically, you could go so far as to use the Reflect namespace and set the metadata each request. The metadata token is 'class_serializer:options', so you could do something like

Reflect.defineMetadata(
  'class_serializer:options',
  { groups: req.user.roles },
  Class.prototype,
  'method'
);

It's not necessarily pretty, but it could work

Upvotes: 3

Related Questions