diff --git a/packages/node-mongo/README.md b/packages/node-mongo/README.md index a38004c4..1d228fc1 100644 --- a/packages/node-mongo/README.md +++ b/packages/node-mongo/README.md @@ -253,6 +253,40 @@ Fetches the first document that matches the filter. Returns `null` if document w **Returns** `Promise`. +### `getPublic` + +```typescript +getPublic(doc: U | null): Partial | null +``` + +```typescript +const user = await userService.findOne({ _id: u._id }); + +const publicUser = userService.getPublic(user); +``` + +Removes private fields from a document and returns a sanitized version. Private fields are defined in the `privateFields` option when creating the service. Returns `null` if the input document is `null`. If no `privateFields` are configured, returns the original document unchanged. + +**Parameters** + +- doc: `U | null` - The document to sanitize. Can be `null`. + +**Returns** `Partial | null` - A document with private fields omitted, or `null` if the input was `null`. + +**Example** + +```typescript +const USER_PRIVATE_FIELDS = ['passwordHash', 'signupToken', 'resetPasswordToken'] as const + +const service = db.createService("users", { + privateFields: USER_PRIVATE_FIELDS, +}); + +const user = await service.findOne({ _id: userId }); + +const publicUser = service.getPublic(user); +``` + ### `updateOne` ```typescript @@ -1046,15 +1080,11 @@ Extending API for a single service. ```typescript const service = db.createService("users", { schemaValidator: (obj) => schema.parseAsync(obj), + privateFields: ['passwordHash', 'signupToken', 'resetPasswordToken'], }); -const privateFields = ["passwordHash", "signupToken", "resetPasswordToken"]; - -const getPublic = (user: User | null) => _.omit(user, privateFields); - export default Object.assign(service, { updateLastRequest, - getPublic, }); ``` diff --git a/packages/node-mongo/src/database.ts b/packages/node-mongo/src/database.ts index 2bf24ce2..bd1fe06e 100755 --- a/packages/node-mongo/src/database.ts +++ b/packages/node-mongo/src/database.ts @@ -81,11 +81,11 @@ class Database extends EventEmitter { await this.client.close(); }; - createService( + createService = []>( collectionName: string, - options?: ServiceOptions | undefined, - ): Service { - return new Service( + options?: ServiceOptions | undefined, + ): Service { + return new Service( collectionName, this as IDatabase, options, diff --git a/packages/node-mongo/src/service.ts b/packages/node-mongo/src/service.ts index f4bb06ad..03877e82 100755 --- a/packages/node-mongo/src/service.ts +++ b/packages/node-mongo/src/service.ts @@ -33,30 +33,31 @@ import { } from './types'; import logger from './utils/logger'; -import { addUpdatedOnField, generateId } from './utils/helpers'; +import { addUpdatedOnField, generateId, omitPrivateFields } from './utils/helpers'; import PopulateUtil from './utils/populate'; import { inMemoryPublisher } from './events/in-memory'; -const defaultOptions: ServiceOptions = { +const defaultOptions: ServiceOptions> = { skipDeletedOnDocs: true, publishEvents: true, outbox: false, addCreatedOnField: true, addUpdatedOnField: true, escapeRegExp: false, + privateFields: [], }; const isDev = process.env.NODE_ENV === 'development'; -class Service { +class Service = []> { private client?: MongoClient; private collection: Collection | null; private _collectionName: string; - private options: ServiceOptions; + private options: ServiceOptions; private db; @@ -67,14 +68,14 @@ class Service { constructor( collectionName: string, db: IDatabase, - options: ServiceOptions = {}, + options: ServiceOptions = {}, ) { this._collectionName = collectionName; this.db = db; this.options = { ...defaultOptions, ...options, - }; + } as ServiceOptions; this.waitForConnection = db.waitForConnection; if (this.options.outbox) { @@ -239,11 +240,13 @@ class Service { readConfig: ReadConfig = {}, findOptions: FindOptions = {}, ): Promise<(U & PopulateTypes) | U | null> { + const { populate } = readConfig; + const collection = await this.getCollection(); filter = this.handleReadOperations(filter, readConfig); - if (readConfig.populate) { + if (populate) { const docs = await this.populateAggregate(collection, filter, readConfig, findOptions); return docs[0] || null; @@ -270,14 +273,15 @@ class Service { readConfig: ReadConfig & { page?: number; perPage?: number } = {}, findOptions: FindOptions = {}, ): Promise | FindResult> { + const { populate, page, perPage } = readConfig; + const collection = await this.getCollection(); - const { page, perPage } = readConfig; const hasPaging = !!page && !!perPage; filter = this.handleReadOperations(filter, readConfig); if (!hasPaging) { - const results = readConfig.populate + const results = populate ? await this.populateAggregate(collection, filter, readConfig, findOptions) : await collection.find(filter, findOptions).toArray(); @@ -292,7 +296,7 @@ class Service { findOptions.limit = perPage; const [results, count] = await Promise.all([ - readConfig.populate + populate ? this.populateAggregate(collection, filter, readConfig, findOptions) : collection.find(filter, findOptions).toArray(), collection.countDocuments(filter), @@ -347,12 +351,14 @@ class Service { createConfig: CreateConfig = {}, insertOneOptions: InsertOneOptions = {}, ): Promise => { + const { publishEvents } = createConfig; + const collection = await this.getCollection(); const validEntity = await this.validateCreateOperation(object, createConfig); - const shouldPublishEvents = typeof createConfig.publishEvents === 'boolean' - ? createConfig.publishEvents + const shouldPublishEvents = typeof publishEvents === 'boolean' + ? publishEvents : this.options.publishEvents; if (shouldPublishEvents) { @@ -384,14 +390,16 @@ class Service { createConfig: CreateConfig = {}, bulkWriteOptions: BulkWriteOptions = {}, ): Promise => { + const { publishEvents } = createConfig; + const collection = await this.getCollection(); const validEntities = await Promise.all(objects.map( (o) => this.validateCreateOperation(o, createConfig), )); - const shouldPublishEvents = typeof createConfig.publishEvents === 'boolean' - ? createConfig.publishEvents + const shouldPublishEvents = typeof publishEvents === 'boolean' + ? publishEvents : this.options.publishEvents; if (shouldPublishEvents) { @@ -455,6 +463,8 @@ class Service { updateConfig: UpdateConfig = {}, updateOptions: UpdateOptions = {}, ): Promise { + const { validateSchema, publishEvents } = updateConfig; + const collection = await this.getCollection(); filter = this.handleReadOperations(filter, updateConfig); @@ -515,16 +525,16 @@ class Service { updateFilter = _.merge(updateFilter, { $set: { updatedOn: updatedOnDate } }); } - const shouldValidateSchema = typeof updateConfig.validateSchema === 'boolean' - ? updateConfig.validateSchema + const shouldValidateSchema = typeof validateSchema === 'boolean' + ? validateSchema : Boolean(this.options.schemaValidator); if (shouldValidateSchema) { await this.validateSchema(newDoc); } - const shouldPublishEvents = typeof updateConfig.publishEvents === 'boolean' - ? updateConfig.publishEvents + const shouldPublishEvents = typeof publishEvents === 'boolean' + ? publishEvents : this.options.publishEvents; if (shouldPublishEvents) { @@ -579,6 +589,8 @@ class Service { updateConfig: UpdateConfig = {}, updateOptions: UpdateOptions = {}, ): Promise { + const { validateSchema, publishEvents } = updateConfig; + const collection = await this.getCollection(); filter = this.handleReadOperations(filter, updateConfig); @@ -654,8 +666,8 @@ class Service { }); } - const shouldValidateSchema = typeof updateConfig.validateSchema === 'boolean' - ? updateConfig.validateSchema + const shouldValidateSchema = typeof validateSchema === 'boolean' + ? validateSchema : Boolean(this.options.schemaValidator); if (shouldValidateSchema) { @@ -676,8 +688,8 @@ class Service { }, ); - const shouldPublishEvents = typeof updateConfig.publishEvents === 'boolean' - ? updateConfig.publishEvents + const shouldPublishEvents = typeof publishEvents === 'boolean' + ? publishEvents : this.options.publishEvents; if (shouldPublishEvents) { @@ -701,7 +713,7 @@ class Service { await collection.bulkWrite(bulkWriteQuery, updateOptions); } - return updated.map((u) => u?.doc) as U[]; + return updated.map((u) => u?.doc).filter(Boolean) as U[]; } deleteOne = async ( @@ -977,6 +989,14 @@ class Service { this.collection = null; } }; + + getPublic(doc: null): null; + + getPublic(doc: T): Omit; + + getPublic(doc: T | null): Omit | null { + return omitPrivateFields(doc, this.options.privateFields || []); + } } export default Service; diff --git a/packages/node-mongo/src/tests/service-extending.spec.ts b/packages/node-mongo/src/tests/service-extending.spec.ts index 04bf17e9..6b85bf9a 100644 --- a/packages/node-mongo/src/tests/service-extending.spec.ts +++ b/packages/node-mongo/src/tests/service-extending.spec.ts @@ -28,7 +28,7 @@ class CustomService extends Service { }; } -function createService(collectionName: string, options: ServiceOptions = {}) { +function createService(collectionName: string, options: ServiceOptions = {}) { return new CustomService(collectionName, database, options); } diff --git a/packages/node-mongo/src/tests/service.spec.ts b/packages/node-mongo/src/tests/service.spec.ts index b09d0328..2a58b662 100644 --- a/packages/node-mongo/src/tests/service.spec.ts +++ b/packages/node-mongo/src/tests/service.spec.ts @@ -36,6 +36,8 @@ enum AdminPermissions { EDIT = 'edit', } +const USER_PRIVATE_FIELDS = ['passwordHash'] as const; + const userSchema = z.object({ _id: z.string(), createdOn: z.date().optional(), @@ -43,6 +45,7 @@ const userSchema = z.object({ deletedOn: z.date().optional().nullable(), fullName: z.string(), age: z.number().optional(), + passwordHash: z.string().optional(), role: z.nativeEnum(UserRoles).default(UserRoles.MEMBER), permissions: z.array(z.nativeEnum(AdminPermissions)).optional(), birthDate: z.date().optional(), @@ -66,6 +69,11 @@ const companyService = database.createService('companies', { schemaValidator: (obj) => companySchema.parseAsync(obj), }); +const usersServiceWithPrivateFields = database.createService('usersWithPrivateFields', { + schemaValidator: (obj) => userSchema.parseAsync(obj), + privateFields: USER_PRIVATE_FIELDS, +}); + describe('service.ts', () => { before(async () => { await database.connect(); @@ -1491,4 +1499,48 @@ describe('service.ts', () => { updatedUser?.permissions?.[1]?.should.be.undefined; updatedUser?.permissions?.length?.should.be.equal(0); }); + + it('should omit private fields using array configuration', async () => { + const userToInsertPayload = { + fullName: 'John Doe', + age: 30, + role: UserRoles.ADMIN, + passwordHash: '123456', + }; + + const user = await usersServiceWithPrivateFields.insertOne(userToInsertPayload); + + const publicUser = usersServiceWithPrivateFields.getPublic(user); + + // @ts-expect-error Property 'passwordHash' does not exist + publicUser?.passwordHash; + + publicUser?.fullName?.should.be.equal(userToInsertPayload.fullName); + publicUser?.age?.should.be.equal(userToInsertPayload.age); + publicUser?.role?.should.be.equal(userToInsertPayload.role); + }); + + it('should return original document when no privateFields configured', async () => { + const userToInsertPayload = { + fullName: 'John Doe', + age: 30, + role: UserRoles.ADMIN, + passwordHash: '123456', + }; + + const user = await usersService.insertOne(userToInsertPayload); + + const publicUser = usersService.getPublic(user); + + publicUser?.passwordHash?.should.be.equal(userToInsertPayload.passwordHash); + publicUser?.fullName?.should.be.equal(userToInsertPayload.fullName); + publicUser?.age?.should.be.equal(userToInsertPayload.age); + publicUser?.role?.should.be.equal(userToInsertPayload.role); + }); + + it('should handle null documents in getPublic', async () => { + const publicUser = usersServiceWithPrivateFields.getPublic(null); + + (publicUser === null).should.be.equal(true); + }); }); diff --git a/packages/node-mongo/src/types/index.ts b/packages/node-mongo/src/types/index.ts index b81b026c..a10d328f 100644 --- a/packages/node-mongo/src/types/index.ts +++ b/packages/node-mongo/src/types/index.ts @@ -1,5 +1,5 @@ import { - ClientSession, Collection, CollectionOptions, CreateCollectionOptions, Document, MongoClient, + ClientSession, Collection, CollectionOptions, CreateCollectionOptions, Document, FindOptions, MongoClient, } from 'mongodb'; export type DbChangeType = 'create' | 'update' | 'delete'; @@ -61,8 +61,8 @@ export type FindResult = { }; export type CreateConfig = { - validateSchema?: boolean, - publishEvents?: boolean, + validateSchema?: boolean; + publishEvents?: boolean; }; export type PopulateOptions = { @@ -83,7 +83,7 @@ export type ReadConfig = { // Type-safe discriminated unions for populate operations export type ReadConfigWithPopulate = ReadConfig & { - populate: PopulateOptions | PopulateOptions[]; + populate: PopulateOptions | PopulateOptions[]; }; export type ReadConfigWithoutPopulate = ReadConfig & { @@ -91,9 +91,9 @@ export type ReadConfigWithoutPopulate = ReadConfig & { }; export type UpdateConfig = { - skipDeletedOnDocs?: boolean, - validateSchema?: boolean, - publishEvents?: boolean, + skipDeletedOnDocs?: boolean; + validateSchema?: boolean; + publishEvents?: boolean; }; export type DeleteConfig = { @@ -118,7 +118,7 @@ interface IDatabase { ) => Promise, } -interface ServiceOptions { +interface ServiceOptions = []> { skipDeletedOnDocs?: boolean, schemaValidator?: (obj: any) => Promise, publishEvents?: boolean, @@ -128,6 +128,7 @@ interface ServiceOptions { collectionOptions?: CollectionOptions; collectionCreateOptions?: CreateCollectionOptions; escapeRegExp?: boolean; + privateFields?: PrivateFields; } export type UpdateFilterFunction = (doc: U) => Partial; diff --git a/packages/node-mongo/src/utils/helpers.ts b/packages/node-mongo/src/utils/helpers.ts index 3e9fc3b0..f53222ee 100644 --- a/packages/node-mongo/src/utils/helpers.ts +++ b/packages/node-mongo/src/utils/helpers.ts @@ -40,4 +40,13 @@ const addUpdatedOnField = (update: UpdateFilter): UpdateFilter => { } as unknown as UpdateFilter; }; -export { deepCompare, generateId, addUpdatedOnField }; +const omitPrivateFields = ( + doc: T | null, + privateFields: ReadonlyArray, +): Omit | null => { + if (!doc) return null; + + return _.omit(doc, privateFields); +}; + +export { deepCompare, generateId, addUpdatedOnField, omitPrivateFields };