NestJS/Mongoose: serialization does not exclude properties in plain output

9.1k views Asked by At

I've started to play with NestJS, migrating from my old express/mongoose project and immediately crashed into a fence, just following MongoDB/serializations chapters from NestJS docs. I've prepared following schema

/////// schema
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import * as mongoose from 'mongoose';
import { Exclude, Expose } from 'class-transformer';

export type UserDocument = User & mongoose.Document;

@Schema()
export class User {
    @Prop()
    @Exclude()
    _id: String

    @Expose()
    get id(): String { return this._id ? `${this._id}` : undefined }

    @Prop()
    name: string

    @Prop({ unique: true })
    login: string

    @Exclude()
    @Prop()
    password: string        
}

export const UserSchema = SchemaFactory.createForClass(User);

registered it in app.module

MongooseModule.forRoot('mongodb://localhost/old_project'), 
MongooseModule.forFeature([ { name: User.name, schema: UserSchema } ]),

and tried following calls, expecting no password property revealed in results

/////// controller
  @UseInterceptors(ClassSerializerInterceptor)
  @Get('default')
  async default(): Promise<User> {
    let u = new User();
    u.name = 'Kos';
    u.password = "secret";
    u.login = '[email protected]'

    return u;
  }
  
  // returns
  // {"name":"Kos","login":"[email protected]"}

  @Get('first_raw')
  async firstRaw(): Promise<User> {
    return this.userModel.findOne()
  }
  
  @Get('first_lean')
  async firstLean(): Promise<User> {
    return this.userModel.findOne().lean()
  }
  
  //both return
  // {"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0}

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first_raw_stripped')
  async firstRawStripped(): Promise<User> {
    return this.userModel.findOne()
  }
  
  //returns
  // {"$__":{"strictMode":true,"selected":{},"getters":{},"_id":"5f8731a36fc003421db08921","wasPopulated":false,"activePaths":{"paths":{"_id":"init","name":"init","login":"init","password":"init","__v":"init"},"states":{"ignore":{},"default":{},"init":{"_id":true,"name":true,"login":true,"password":true,"__v":true},"modify":{},"require":{}},"stateNames":["require","modify","init","default","ignore"]},"pathsToScopes":{},"cachedRequired":{},"$setCalled":[],"emitter":{"_events":{},"_eventsCount":0,"_maxListeners":0},"$options":{"skipId":true,"isNew":false,"willInit":true,"defaults":true}},"isNew":false,"$locals":{},"$op":null,"_doc":{"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0},"$init":true}

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first_lean_stripped')
  async firstLeanStripped(): Promise<User> {
    return this.userModel.findOne().lean()
  }
  
  //returns
  // {"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0}

Finally I've found that only manual instantiation of User class does somehow what it should do, so I've added constructor to User class

constructor(partial?: Partial<User>) {
    if (partial)
        Object.assign(this, partial);
}

and then it finally returned what was expected - no password prop in result

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return new User(await this.userModel.findOne().lean());
  }
  
  //finally returns what's expected
  // {"name":"Kos","login":"kos","__v":0,"id":"5f8731a36fc003421db08921"}

Am I missing something? Somehow it seems a bit overwhelming...

UPDATE: it is either question about NestJS mongoose and serialization coupling - why this

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return await this.userModel.findOne().lean();
  }

doesn't work and this

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return new User(await this.userModel.findOne().lean());
  }

works (which also means for each result enumerable map with entity creations required)

7

There are 7 answers

3
João Mazagão On

I think that I have the solution

@Schema()
export class User {
  @Prop({select: false})
  password: string;
  @Prop()
  username: string;
}

when you do this prop to the decorator the value of the property inside of mongo is ignored in finds.

0
Jake Hall On

Mongoose has its own suppression builtin with the toJson method, you can use it when you create the schema for the model.

export const UserSchema = (() =>   
   const userSchema = SchemaFactory.createForClass(User);
   schema.set('toJSON', {
        transform: function (_, ret) {
          delete ret.password;
        },
   });
   return emailSchema;
})();

    
 
3
john haimez On

I noticed that you did not use: [1]: https://www.npmjs.com/package/nestjs-mongoose-exclude.

I realize that it is not too well known and that there is not a lot to download, but you have to give the small package a chance. If you don't want to use this package, you can do the following before returning your object:

// destructuring
const { password, ...newUser } = user;

return newUser;
9
Ali Sherafat On

After spending several hours finally I found a solution which was described in this post

The Mongoose library that we use for connecting to MongoDB and fetching entities does not return instances of our User class. Therefore, the ClassSerializerInterceptor won’t work out of the box.

First: create a interceptor for mongoose serialization:

mongooseClassSerializer.interceptor.ts

import {
  ClassSerializerInterceptor,
  PlainLiteralObject,
  Type,
} from '@nestjs/common';
import { ClassTransformOptions, plainToClass } from 'class-transformer';
import { Document } from 'mongoose';
 
function MongooseClassSerializerInterceptor(
  classToIntercept: Type,
): typeof ClassSerializerInterceptor {
  return class Interceptor extends ClassSerializerInterceptor {
    private changePlainObjectToClass(document: PlainLiteralObject) {
      if (!(document instanceof Document)) {
        return document;
      }
 
      return plainToClass(classToIntercept, document.toJSON());
    }
 
    private prepareResponse(
      response: PlainLiteralObject | PlainLiteralObject[],
    ) {
      if (Array.isArray(response)) {
        return response.map(this.changePlainObjectToClass);
      }
 
      return this.changePlainObjectToClass(response);
    }
 
    serialize(
      response: PlainLiteralObject | PlainLiteralObject[],
      options: ClassTransformOptions,
    ) {
      return super.serialize(this.prepareResponse(response), options);
    }
  };
}
 
export default MongooseClassSerializerInterceptor;

update your controller to apply this interceptor:

@UseInterceptors(MongooseClassSerializerInterceptor(User))

and your model(schema) should look like this:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { Exclude, Transform } from 'class-transformer';
 
export type UserDocument = User & Document;
 
@Schema()
export class User {
  @Transform(({ value }) => value.toString())
  _id: string;
 
  @Prop({ unique: true })
  email: string;
 
  @Prop()
  name: string;
 
  @Prop()
  @Exclude()
  password: string;
}
 
export const UserSchema = SchemaFactory.createForClass(User);
2
PRABHAT SINGH RAJPUT On

As explained by @Ali Sherafat, unfortunately solution didn't worked for me.

The Mongoose library that we use for connecting to MongoDB and fetching entities does not return instances of our User class. Therefore, the ClassSerializerInterceptor won’t work out of the box.

Definitely we would be requiring interceptor for mongoose serialization. So, I came up with one more similar solution with modifications.

Create interceptor for mongoose serialization as:

import {
    CallHandler,
    ExecutionContext,
    NestInterceptor,
    UseInterceptors,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { map, Observable } from 'rxjs';

interface ClassConstructor {
    new ( ...args: any[ ] ): { };
}

export function MongooseClassSerializerInterceptor( dto: any ) {
    return UseInterceptors( new SerializeInterceptor( dto ) );
}

export class SerializeInterceptor implements NestInterceptor {
    constructor( private dto: any ) { }
    intercept( context: ExecutionContext, handler: CallHandler ): Observable< any > {

        return handler.handle( ).pipe(
            map( ( data: any ) => { 
                return plainToClass( this.dto, data, { 
                    excludeExtraneousValues: true
                } )
            } )
        )
    }
}

Create user dto as, this way you can use it for different role. So, for normal user we can expose required things:

import { Expose } from "class-transformer";

    export class UserDto {
    
        @Expose( )
        id: number;
    
        @Expose( )
        name: string;

        @Expose( )
        login: string;
    
    }

Now in your controller use @MongooseClassSerializerInterceptor( UserDto )

Using exclude in schema is not very flexible when want to return response based on some role, e.g in required case admin may have access to more fields than normal user or vice-versa. In that case this is better approach.

0
Sam A. On

NestJS documentation explicitly states that it needs to be a class – not a plain object – for serialization to work properly. See Warning in red here: https://docs.nestjs.com/techniques/serialization#exclude-properties

This is why when you wrap it in the class constructor it works properly.

The proper way seems to be not to add a constructor to the model, as you did, but inject the schema/model into the service using the @InjectModel decorator so that the findOne method returns a class and not a plain object: https://docs.nestjs.com/techniques/serialization#exclude-properties

Once you've registered the schema, you can inject a Cat model into the CatsService using the @InjectModel() decorator:

0
Sohail Siddiq On

if you any of npm package like (mongoose-exclude) then it will only exclude single object not nested object and if you implement your own custom interceptor then @Expose() group will not work.

keep all of these issues in concertation if found a Hack

import { Role } from "@type/UserType"
import { Exclude } from "class-transformer"
import { HydratedDocument, ObjectId } from "mongoose"
import { Prop, SchemaFactory, Schema } from "@nestjs/mongoose"

export type UserDocument = HydratedDocument<User>

@Schema({
    timestamps: true,
    versionKey: false
})
export class User {
    toObject(): Partial<User> {
        throw new Error("Method not implemented.")
    }
    @Exclude()
    _id: ObjectId

    @Prop({
        type: String,
        required: true
    })
    name: string

    @Prop({
        unique: true,
        type: String,
        required: true
    })
    email: string

    @Exclude()
    @Prop({ type: String })
    password: string

    @Prop({
        type: String,
        default: Role.USER
    })
    role: Role

    @Prop({
        type: String,
        select: false
    })
    token: string

    constructor(partial: Partial<User>) {
        Object.assign(this, partial)
    }
}

export const UserSchema = SchemaFactory.createForClass(User) 

import { SignUpDto } from "@dto/UserDto"
import { Model, FilterQuery } from "mongoose"
import { StaticError } from "@type/ErrorType"
import { InjectModel } from "@nestjs/mongoose"
import { User, UserDocument } from "@schema/UserSchema"
import { Injectable, NotFoundException } from "@nestjs/common"
import { IUserRepository } from "@irepository/IUserRepository"

@Injectable()
export class UserRepository implements IUserRepository {
    constructor(@InjectModel(User.name) private readonly userModel: Model<UserDocument>) { }

    public async signUp(signUpDto: SignUpDto): Promise<User> {
        const user: User = await this.userModel.create(signUpDto)
        return new User(user.toObject())
    }
}

    app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))

All things are working as expected like Expose with group and nested exclude