Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions packages/node-mongo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,40 @@ Fetches the first document that matches the filter. Returns `null` if document w

**Returns** `Promise<T | null>`.

### `getPublic`

```typescript
getPublic<U extends T = T>(doc: U | null): Partial<U> | 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<U> | 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<User, typeof USER_PRIVATE_FIELDS>("users", {
privateFields: USER_PRIVATE_FIELDS,
});

const user = await service.findOne({ _id: userId });

const publicUser = service.getPublic(user);
```

### `updateOne`

```typescript
Expand Down Expand Up @@ -1046,15 +1080,11 @@ Extending API for a single service.
```typescript
const service = db.createService<User>("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,
});
```

Expand Down
8 changes: 4 additions & 4 deletions packages/node-mongo/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ class Database extends EventEmitter {
await this.client.close();
};

createService<T extends IDocument>(
createService<T extends IDocument, PrivateFields extends ReadonlyArray<keyof T> = []>(
collectionName: string,
options?: ServiceOptions | undefined,
): Service<T> {
return new Service<T>(
options?: ServiceOptions<T, PrivateFields> | undefined,
): Service<T, PrivateFields> {
return new Service<T, PrivateFields>(
collectionName,
this as IDatabase,
options,
Expand Down
66 changes: 43 additions & 23 deletions packages/node-mongo/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDocument, ReadonlyArray<keyof IDocument>> = {
skipDeletedOnDocs: true,
publishEvents: true,
outbox: false,
addCreatedOnField: true,
addUpdatedOnField: true,
escapeRegExp: false,
privateFields: [],
};

const isDev = process.env.NODE_ENV === 'development';

class Service<T extends IDocument> {
class Service<T extends IDocument, PrivateFields extends ReadonlyArray<keyof T> = []> {
private client?: MongoClient;

private collection: Collection<T> | null;

private _collectionName: string;

private options: ServiceOptions;
private options: ServiceOptions<T, PrivateFields>;

private db;

Expand All @@ -67,14 +68,14 @@ class Service<T extends IDocument> {
constructor(
collectionName: string,
db: IDatabase,
options: ServiceOptions = {},
options: ServiceOptions<T, PrivateFields> = {},
) {
this._collectionName = collectionName;
this.db = db;
this.options = {
...defaultOptions,
...options,
};
} as ServiceOptions<T, PrivateFields>;
this.waitForConnection = db.waitForConnection;

if (this.options.outbox) {
Expand Down Expand Up @@ -239,11 +240,13 @@ class Service<T extends IDocument> {
readConfig: ReadConfig = {},
findOptions: FindOptions = {},
): Promise<(U & PopulateTypes) | U | null> {
const { populate } = readConfig;

const collection = await this.getCollection<U>();

filter = this.handleReadOperations(filter, readConfig);

if (readConfig.populate) {
if (populate) {
const docs = await this.populateAggregate<U, PopulateTypes>(collection, filter, readConfig, findOptions);

return docs[0] || null;
Expand All @@ -270,14 +273,15 @@ class Service<T extends IDocument> {
readConfig: ReadConfig & { page?: number; perPage?: number } = {},
findOptions: FindOptions = {},
): Promise<FindResult<U & PopulateTypes> | FindResult<U>> {
const { populate, page, perPage } = readConfig;

const collection = await this.getCollection<U>();
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<U, PopulateTypes>(collection, filter, readConfig, findOptions)
: await collection.find<U>(filter, findOptions).toArray();

Expand All @@ -292,7 +296,7 @@ class Service<T extends IDocument> {
findOptions.limit = perPage;

const [results, count] = await Promise.all([
readConfig.populate
populate
? this.populateAggregate<U, PopulateTypes>(collection, filter, readConfig, findOptions)
: collection.find<U>(filter, findOptions).toArray(),
collection.countDocuments(filter),
Expand Down Expand Up @@ -347,12 +351,14 @@ class Service<T extends IDocument> {
createConfig: CreateConfig = {},
insertOneOptions: InsertOneOptions = {},
): Promise<U> => {
const { publishEvents } = createConfig;

const collection = await this.getCollection<U>();

const validEntity = await this.validateCreateOperation<U>(object, createConfig);

const shouldPublishEvents = typeof createConfig.publishEvents === 'boolean'
? createConfig.publishEvents
const shouldPublishEvents = typeof publishEvents === 'boolean'
? publishEvents
: this.options.publishEvents;

if (shouldPublishEvents) {
Expand Down Expand Up @@ -384,14 +390,16 @@ class Service<T extends IDocument> {
createConfig: CreateConfig = {},
bulkWriteOptions: BulkWriteOptions = {},
): Promise<U[]> => {
const { publishEvents } = createConfig;

const collection = await this.getCollection<U>();

const validEntities = await Promise.all(objects.map(
(o) => this.validateCreateOperation<U>(o, createConfig),
));

const shouldPublishEvents = typeof createConfig.publishEvents === 'boolean'
? createConfig.publishEvents
const shouldPublishEvents = typeof publishEvents === 'boolean'
? publishEvents
: this.options.publishEvents;

if (shouldPublishEvents) {
Expand Down Expand Up @@ -455,6 +463,8 @@ class Service<T extends IDocument> {
updateConfig: UpdateConfig = {},
updateOptions: UpdateOptions = {},
): Promise<U | null> {
const { validateSchema, publishEvents } = updateConfig;

const collection = await this.getCollection<U>();

filter = this.handleReadOperations(filter, updateConfig);
Expand Down Expand Up @@ -515,16 +525,16 @@ class Service<T extends IDocument> {
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) {
Expand Down Expand Up @@ -579,6 +589,8 @@ class Service<T extends IDocument> {
updateConfig: UpdateConfig = {},
updateOptions: UpdateOptions = {},
): Promise<U[]> {
const { validateSchema, publishEvents } = updateConfig;

const collection = await this.getCollection<U>();

filter = this.handleReadOperations(filter, updateConfig);
Expand Down Expand Up @@ -654,8 +666,8 @@ class Service<T extends IDocument> {
});
}

const shouldValidateSchema = typeof updateConfig.validateSchema === 'boolean'
? updateConfig.validateSchema
const shouldValidateSchema = typeof validateSchema === 'boolean'
? validateSchema
: Boolean(this.options.schemaValidator);

if (shouldValidateSchema) {
Expand All @@ -676,8 +688,8 @@ class Service<T extends IDocument> {
},
);

const shouldPublishEvents = typeof updateConfig.publishEvents === 'boolean'
? updateConfig.publishEvents
const shouldPublishEvents = typeof publishEvents === 'boolean'
? publishEvents
: this.options.publishEvents;

if (shouldPublishEvents) {
Expand All @@ -701,7 +713,7 @@ class Service<T extends IDocument> {
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 <U extends T = T>(
Expand Down Expand Up @@ -977,6 +989,14 @@ class Service<T extends IDocument> {
this.collection = null;
}
};

getPublic(doc: null): null;

getPublic(doc: T): Omit<T, PrivateFields[number]>;

getPublic(doc: T | null): Omit<T, PrivateFields[number]> | null {
return omitPrivateFields<T, PrivateFields[number]>(doc, this.options.privateFields || []);
}
}

export default Service;
2 changes: 1 addition & 1 deletion packages/node-mongo/src/tests/service-extending.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class CustomService<T extends IDocument> extends Service<T> {
};
}

function createService<T extends IDocument>(collectionName: string, options: ServiceOptions = {}) {
function createService<T extends IDocument>(collectionName: string, options: ServiceOptions<T> = {}) {
return new CustomService<T>(collectionName, database, options);
}

Expand Down
52 changes: 52 additions & 0 deletions packages/node-mongo/src/tests/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ enum AdminPermissions {
EDIT = 'edit',
}

const USER_PRIVATE_FIELDS = ['passwordHash'] as const;

const userSchema = z.object({
_id: z.string(),
createdOn: z.date().optional(),
updatedOn: z.date().optional(),
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(),
Expand All @@ -66,6 +69,11 @@ const companyService = database.createService<CompanyType>('companies', {
schemaValidator: (obj) => companySchema.parseAsync(obj),
});

const usersServiceWithPrivateFields = database.createService<UserType, typeof USER_PRIVATE_FIELDS>('usersWithPrivateFields', {
schemaValidator: (obj) => userSchema.parseAsync(obj),
privateFields: USER_PRIVATE_FIELDS,
});

describe('service.ts', () => {
before(async () => {
await database.connect();
Expand Down Expand Up @@ -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);
});
});
Loading