Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
13 changes: 4 additions & 9 deletions docs/packages/node-mongo/overview.mdx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that features will be on node-mongo beta right now, could you please rollback it?
When I publish changes, I will add these changes to the doc

Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ interface ServiceOptions {
|`escapeRegExp`|Escape `$regex` values to prevent special characters from being interpreted as patterns. |`false`|
|`collectionOptions`|MongoDB [CollectionOptions](https://mongodb.github.io/node-mongodb-native/4.10/interfaces/CollectionOptions.html)|`{}`|
|`collectionCreateOptions`|MongoDB [CreateCollectionOptions](https://mongodb.github.io/node-mongodb-native/4.10/interfaces/CreateCollectionOptions.html)|`{}`|
|`privateFields`|Fields that should be omitted from the public response.|`[]`|

### `CreateConfig`
Overrides `ServiceOptions` parameters for create operations.
Expand All @@ -231,6 +232,7 @@ Overrides `ServiceOptions` parameters for read operations.
```typescript
type ReadConfig = {
skipDeletedOnDocs?: boolean,
populate?: PopulateOptions | PopulateOptions[],
};
```

Expand Down Expand Up @@ -281,19 +283,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 Expand Up @@ -321,6 +315,7 @@ function createService<T extends IDocument>(collectionName: string, options: Ser

const userService = createService<UserType>('users', {
schemaValidator: (obj) => schema.parseAsync(obj),
privateFields: ['passwordHash', 'signupToken', 'resetPasswordToken'],
});

await userService.createOrUpdate(
Expand Down
38 changes: 33 additions & 5 deletions packages/node-mongo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,38 @@ 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 service = db.createService<User>("users", {
privateFields: ['passwordHash', 'signupToken', 'resetPasswordToken'],
});

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

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

### `updateOne`

```typescript
Expand Down Expand Up @@ -1046,15 +1078,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
2 changes: 1 addition & 1 deletion packages/node-mongo/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class Database extends EventEmitter {

createService<T extends IDocument>(
collectionName: string,
options?: ServiceOptions | undefined,
options?: ServiceOptions<T> | undefined,
): Service<T> {
return new Service<T>(
collectionName,
Expand Down
58 changes: 37 additions & 21 deletions packages/node-mongo/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,19 @@ 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> = {
skipDeletedOnDocs: true,
publishEvents: true,
outbox: false,
addCreatedOnField: true,
addUpdatedOnField: true,
escapeRegExp: false,
privateFields: [],
};

const isDev = process.env.NODE_ENV === 'development';
Expand All @@ -56,7 +57,7 @@ class Service<T extends IDocument> {

private _collectionName: string;

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

private db;

Expand All @@ -67,7 +68,7 @@ class Service<T extends IDocument> {
constructor(
collectionName: string,
db: IDatabase,
options: ServiceOptions = {},
options: ServiceOptions<T> = {},
) {
this._collectionName = collectionName;
this.db = db;
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,10 @@ class Service<T extends IDocument> {
this.collection = null;
}
};

getPublic = <U extends T = T>(doc: U | null): Partial<U> | null => {
return omitPrivateFields<U>(doc, this.options.privateFields || []);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please play with return type?
Ideally, If you pass a user with type { _id: string, password: string } and private fields ['password'] return type should be { _id: string } (accessing 'password' field should cause error)

also try to implement types overloading when you pass null, here is high-level example:

interface MyObject {
  foo: string;
  bar: number;
}

// Modified type example
interface ModifiedObject {
  foo: string;
  bar: number;
  baz: boolean;
}

// Overloads
function processObj(obj: null): null;
function processObj(obj: MyObject): ModifiedObject;

// Implementation (single signature)
function processObj(obj: MyObject | null): ModifiedObject | null {
  if (obj === null) return null;
  // Modify object as needed
  return { ...obj, baz: true };
}

}

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
49 changes: 49 additions & 0 deletions packages/node-mongo/src/tests/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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(),
Expand All @@ -66,6 +67,12 @@ const companyService = database.createService<CompanyType>('companies', {
schemaValidator: (obj) => companySchema.parseAsync(obj),
});

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


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

(publicUser?.passwordHash === undefined).should.be.equal(true);
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
Loading