8

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 = 'k@o.s'

    return u;
  }
  
  // returns
  // {"name":"Kos","login":"k@o.s"}

  @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)

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
kosmo
  • 101
  • 2
  • 9
  • I have a question for you, Did you want to hide every time as selected or created? – João Mazagão May 11 '21 at 17:47
  • It is either about nestjs mongoose and serialialization coupling - why this `@UseInterceptors(ClassSerializerInterceptor) ... return await this.userModel.findOne().lean()` doesnt work and this `@UseInterceptors(ClassSerializerInterceptor) ... return new User(await this.userModel.findOne().lean())` works – kosmo May 11 '21 at 18:48
  • Did you find any good solution finally? I'm facing the same situation – Sergio Arrighi Jan 22 '22 at 17:52
  • Finally I used the constructor and for methods returning multiple items I ended up using async findAll(): Promise { return (await this.userModel.find().lean().exec()).map(user => new User(user)) } Not really satisfied – Sergio Arrighi Jan 22 '22 at 18:09

7 Answers7

8

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);
Ali Sherafat
  • 3,506
  • 2
  • 38
  • 51
  • This one didn't work for me, only thing that actually works in 2021 is instanciating the objects with constructor. It looks ok when returning a single objects from the service, but it looks bad when returning an array – Sergio Arrighi Jan 22 '22 at 17:50
  • 1
    @SergioArrighi I used the code above in the last week so it works in 2022 :) – Ali Sherafat Jan 22 '22 at 18:13
  • Thanks for letting me know, maybe there are some particularities in my software for which this solution doesn't work – Sergio Arrighi Jan 22 '22 at 18:14
  • @SergioArrighi use the tutorial link that I put in my answer. that helps you more – Ali Sherafat Jan 22 '22 at 18:26
  • Ok so I tried again and I actually managed to have @Exclude working without having to instantiate the objects out of the service. But... I couldn't manage to have @Expose({ groups: [Role.Admin] }) working while it works good when I return instances out of the service. Did you manage to have Expose working too? – Sergio Arrighi Jan 22 '22 at 20:16
  • @SergioArrighi for me, all properties are exposed by default. may be something is wrong with your group fetching data. have you tried to work without groups to check if it works? – Ali Sherafat Jan 23 '22 at 06:21
  • works fine on the latest versions as of Mar 2023. Thanks a lot for the code – Vitalii Bratok Mar 12 '23 at 01:20
  • Does not work with nested properties. – Eduard May 13 '23 at 12:30
  • Works like a charm ! Thanks for this. You should replace plainToClass by plainToInstance as it changed name & old one is deprecated now – Can Aug 29 '23 at 16:31
4

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.

PRABHAT SINGH RAJPUT
  • 1,455
  • 1
  • 12
  • 31
  • This worked for me whereas the accepted solution did not. One thing though - it seems that I have to add `@Expose to every property. I'd rather have all properties in the class exposed by default (default in serializer options), and only exclude if I have the `@Exclude decorator added. Any ideas on how to achieve this? – Tyler Nielsen Jan 24 '23 at 13:32
1

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;
ouflak
  • 2,458
  • 10
  • 44
  • 49
  • Please add further details to expand on your answer, such as working code or documentation citations. – Community Sep 07 '21 at 08:57
  • This library was a good suggestion but it is currently not working as reported in https://github.com/d38u6/nestjs-mongoose-exclude/issues/1 – Sergio Arrighi Jan 22 '22 at 17:51
  • This worked for me! :D Maybe my answer here might help someone who is facing issues with this pacakge https://github.com/d38u6/nestjs-mongoose-exclude/issues/1#issuecomment-1042003912 – Haseeb Burki Feb 16 '22 at 18:38
1

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;
})();

    
 
Jake Hall
  • 1,963
  • 22
  • 24
0

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.

João Mazagão
  • 94
  • 2
  • 11
  • 1
    No, I want to hide the value of this field only from UI part - so, that I can do console.log(user.password) or any equality checks, but return user entity (entities) to the frontend without valuable props – kosmo May 11 '21 at 18:46
  • 2
    if you make it false won't get the user with password which will later break the code when you want to compare the password with bcrypt – mirsahib Jul 28 '21 at 09:07
  • Good Point @mirsahib – João Mazagão Jul 29 '21 at 14:31
0

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:

Sam A.
  • 666
  • 4
  • 6
0

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