diff --git a/examples/nestjs-mongoose-book-manager/README.md b/examples/nestjs-mongoose-book-manager/README.md index 0149f73..10e10cf 100644 --- a/examples/nestjs-mongoose-book-manager/README.md +++ b/examples/nestjs-mongoose-book-manager/README.md @@ -1,12 +1,12 @@ -This is an example of how to use `monguito` in a NestJS application that uses MongoDB. It is a dummy book +This is an example of how to use `monguito` in a NestJS application that uses MongoDB. It is a dummy book manager that exposes three simple endpoints i.e., create, update, and delete a book, as well as list all books. A book may be of type `Book` or any of its subtypes i.e., `PaperBook` and `AudioBook`. > **Warning** > > Some basic knowledge on [NestJS](https://docs.nestjs.com/) is assumed, as well as that you have read the main -> documentation of [monguito](../../README.md). The goal of this documentation is not to provide a comprehensive -guide on `monguito` usage. Thus, you may want to check the [sample application code](./src) as you go reading. +> documentation of [monguito](../../README.md). The goal of this documentation is not to provide a comprehensive +> guide on `monguito` usage. Thus, you may want to check the [sample application code](./src) as you go reading. # Main Contents @@ -35,7 +35,7 @@ MongoDB instance, assuming that Docker Desktop is running. $ yarn start:dev # run the NestJS application with no MongoDB Docker container -$ yarn start +$ yarn start ``` # Bottom-up Book Manager Application Implementation @@ -115,13 +115,14 @@ directly implement the `Repository` interface. The definition of `MongooseBookRe @Injectable() export class MongooseBookRepository extends MongooseRepository - implements Repository { + implements Repository +{ constructor(@InjectConnection() connection: Connection) { super( { - Default: {type: Book, schema: BookSchema}, - PaperBook: {type: PaperBook, schema: PaperBookSchema}, - AudioBook: {type: AudioBook, schema: AudioBookSchema}, + Default: { type: Book, schema: BookSchema }, + PaperBook: { type: PaperBook, schema: PaperBookSchema }, + AudioBook: { type: AudioBook, schema: AudioBookSchema }, }, connection, ); @@ -130,7 +131,7 @@ export class MongooseBookRepository async deleteById(id: string): Promise { if (!id) throw new IllegalArgumentException('The given ID must be valid'); return this.entityModel - .findByIdAndUpdate(id, {isDeleted: true}, {new: true}) + .findByIdAndUpdate(id, { isDeleted: true }, { new: true }) .exec() .then((book) => !!book); } @@ -174,8 +175,7 @@ export class BookController { constructor( @Inject('BOOK_REPOSITORY') private readonly bookRepository: Repository, - ) { - } + ) {} @Get() async findAll(): Promise { @@ -187,7 +187,7 @@ export class BookController { @Body({ transform: (plainBook) => deserialise(plainBook), }) - book: Book, + book: Book, ): Promise { return this.save(book); } @@ -195,7 +195,7 @@ export class BookController { @Patch() async update( @Body() - book: PartialBook, + book: PartialBook, ): Promise { return this.save(book); } @@ -261,8 +261,7 @@ part of the second step: writing the last required class `AppModule`. The defini ], controllers: [BookController], }) -export class AppModule { -} +export class AppModule {} ``` This class module specifies the Mongoose connection required to instantiate `MongooseBookRepository` at the `imports` @@ -321,7 +320,7 @@ export const rootMongooseTestModule = ( }); ``` -You may perceive that the port and dbName input parameters match those of the database connection +You may perceive that the `port` and `dbName` input parameters match those of the database connection specified [earlier](#book-manager-module). This is no coincidence. Finally, you can then use the `mongoServer` instance to perform several explicit Mongoose-based DB operations such as diff --git a/package.json b/package.json index 0c6326e..489ae1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "monguito", - "version": "3.3.0", + "version": "3.4.0", "description": "MongoDB Abstract Repository implementation for Node.js", "author": { "name": "Josu Martinez", diff --git a/src/index.ts b/src/index.ts index a0cf5f9..bbc67d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { Entity } from './util/entity'; import { IllegalArgumentException, UndefinedConstructorException, + ValidationException, } from './util/exceptions'; import { AuditableSchema, BaseSchema, extendSchema } from './util/schema'; @@ -18,6 +19,7 @@ export { MongooseRepository, Repository, UndefinedConstructorException, + ValidationException, extendSchema, isAuditable, }; diff --git a/src/mongoose.repository.ts b/src/mongoose.repository.ts index 9f41fbb..c5141aa 100644 --- a/src/mongoose.repository.ts +++ b/src/mongoose.repository.ts @@ -12,6 +12,7 @@ import { Entity } from './util/entity'; import { IllegalArgumentException, UndefinedConstructorException, + ValidationException, } from './util/exceptions'; import { SearchOptions } from './util/search-options'; @@ -103,19 +104,29 @@ export abstract class MongooseRepository> ): Promise { if (!entity) throw new IllegalArgumentException('The given entity must be valid'); - let document; - if (!entity.id) { - document = await this.insert(entity as S, userId); - } else { - document = await this.update( - entity as PartialEntityWithIdAndOptionalDiscriminatorKey, - userId, + try { + let document; + if (!entity.id) { + document = await this.insert(entity as S, userId); + } else { + document = await this.update( + entity as PartialEntityWithIdAndOptionalDiscriminatorKey, + userId, + ); + } + if (document) return this.instantiateFrom(document) as S; + throw new IllegalArgumentException( + `There is no document matching the given ID ${entity.id}. New entities cannot not specify an ID`, ); + } catch (error) { + if (error.message.includes('validation failed')) { + throw new ValidationException( + `Some fields of the given entity do not specify valid values`, + error, + ); + } + throw error; } - if (document) return this.instantiateFrom(document) as S; - throw new IllegalArgumentException( - `There is no document matching the given ID ${entity.id}. New entities cannot not specify an ID`, - ); } /** @@ -171,7 +182,7 @@ export abstract class MongooseRepository> } catch (error) { if (error.message.includes('duplicate key error')) { throw new IllegalArgumentException( - `The given entity with ID ${entity.id} includes a field which value is expected to be unique`, + `The given entity includes a field which value is expected to be unique`, ); } throw error; diff --git a/src/util/exceptions.ts b/src/util/exceptions.ts index 654edbb..109cf92 100644 --- a/src/util/exceptions.ts +++ b/src/util/exceptions.ts @@ -1,17 +1,35 @@ +abstract class Exception extends Error { + readonly error?: Error; + + constructor(message: string, error?: Error) { + super(message); + this.error = error; + } +} + /** * Models a client provided illegal argument exception. */ -export class IllegalArgumentException extends Error { - constructor(message: string) { - super(message); +export class IllegalArgumentException extends Exception { + constructor(message: string, error?: Error) { + super(message, error); } } /** * Models an undefined persistable domain object constructor exception. */ -export class UndefinedConstructorException extends Error { - constructor(message: string) { - super(message); +export class UndefinedConstructorException extends Exception { + constructor(message: string, error?: Error) { + super(message, error); + } +} + +/** + * Models a persistable domain object schema validation rule violation exception. + */ +export class ValidationException extends Exception { + constructor(message: string, error?: Error) { + super(message, error); } } diff --git a/test/repository/book.repository.test.ts b/test/repository/book.repository.test.ts index b23e443..81a497b 100644 --- a/test/repository/book.repository.test.ts +++ b/test/repository/book.repository.test.ts @@ -1,5 +1,8 @@ import { Optional } from 'typescript-optional'; -import { IllegalArgumentException } from '../../src/util/exceptions'; +import { + IllegalArgumentException, + ValidationException, +} from '../../src/util/exceptions'; import { AudioBook, Book, ElectronicBook, PaperBook } from '../domain/book'; import { closeMongoConnection, @@ -872,35 +875,70 @@ describe('Given an instance of book repository', () => { }); describe('and does not specify an ID', () => { - it('then inserts the book', async () => { - const bookToInsert = new Book({ - title: 'Continuous Delivery', - description: - 'Reliable Software Releases Through Build, Test, and Deployment Automation', - isbn: '9780321601919', + describe('and some field values are invalid', () => { + it('then throws an exception', async () => { + const bookToInsert = new Book({ + title: 'Continuous Delivery', + description: + 'Reliable Software Releases Through Build, Test, and Deployment Automation', + isbn: undefined as unknown as string, + }); + + await expect( + bookRepository.save(bookToInsert), + ).rejects.toThrowError(ValidationException); }); + }); - const book = await bookRepository.save(bookToInsert); - expect(book.id).toBeTruthy(); - expect(book.title).toBe(bookToInsert.title); - expect(book.description).toBe(bookToInsert.description); + describe('and all field values are valid', () => { + it('then inserts the book', async () => { + const bookToInsert = new Book({ + title: 'Continuous Delivery', + description: + 'Reliable Software Releases Through Build, Test, and Deployment Automation', + isbn: '9780321601919', + }); + + const book = await bookRepository.save(bookToInsert); + expect(book.id).toBeTruthy(); + expect(book.title).toBe(bookToInsert.title); + expect(book.description).toBe(bookToInsert.description); + }); }); }); }); + describe('and that is of a subtype of Book', () => { - it('then inserts the book', async () => { - const bookToInsert = new PaperBook({ - title: 'Implementing Domain-Driven Design', - description: 'Describes Domain-Driven Design in depth', - edition: 1, - isbn: '9780321834577', + describe('and some field values are invalid', () => { + it('then throws an exception', async () => { + const bookToInsert = new PaperBook({ + title: 'Implementing Domain-Driven Design', + description: 'Describes Domain-Driven Design in depth', + edition: undefined as unknown as number, + isbn: '9780321834577', + }); + + await expect( + bookRepository.save(bookToInsert), + ).rejects.toThrowError(ValidationException); }); + }); - const book = await bookRepository.save(bookToInsert); - expect(book.id).toBeTruthy(); - expect(book.title).toBe(bookToInsert.title); - expect(book.description).toBe(bookToInsert.description); - expect(book.edition).toBe(bookToInsert.edition); + describe('and all field values are valid', () => { + it('then inserts the book', async () => { + const bookToInsert = new PaperBook({ + title: 'Implementing Domain-Driven Design', + description: 'Describes Domain-Driven Design in depth', + edition: 1, + isbn: '9780321834577', + }); + + const book = await bookRepository.save(bookToInsert); + expect(book.id).toBeTruthy(); + expect(book.title).toBe(bookToInsert.title); + expect(book.description).toBe(bookToInsert.description); + expect(book.edition).toBe(bookToInsert.edition); + }); }); }); }); @@ -908,17 +946,34 @@ describe('Given an instance of book repository', () => { describe('that is not new', () => { describe('and that is of Book supertype', () => { describe('and that specifies partial contents of the supertype', () => { - it('then updates the book', async () => { - const bookToUpdate = { - id: storedBook.id, - description: - 'A Novel About IT, DevOps, and Helping Your Business Win', - } as Book; + describe('and some field values are invalid', () => { + it('then throws an exception', async () => { + const bookToUpdate = { + id: storedBook.id, + description: + 'A Novel About IT, DevOps, and Helping Your Business Win', + isbn: undefined as unknown as string, + } as Book; + + await expect( + bookRepository.save(bookToUpdate), + ).rejects.toThrowError(ValidationException); + }); + }); - const book = await bookRepository.save(bookToUpdate); - expect(book.id).toBe(storedBook.id); - expect(book.title).toBe(storedBook.title); - expect(book.description).toBe(bookToUpdate.description); + describe('and all field values are valid', () => { + it('then updates the book', async () => { + const bookToUpdate = { + id: storedBook.id, + description: + 'A Novel About IT, DevOps, and Helping Your Business Win', + } as Book; + + const book = await bookRepository.save(bookToUpdate); + expect(book.id).toBe(storedBook.id); + expect(book.title).toBe(storedBook.title); + expect(book.description).toBe(bookToUpdate.description); + }); }); }); describe('and that specifies all the contents of the supertype', () => { @@ -938,42 +993,79 @@ describe('Given an instance of book repository', () => { }); }); }); + describe('and that is of Book subtype', () => { describe('and that specifies partial contents of the subtype', () => { - it('then updates the book', async () => { - const bookToUpdate = { - id: storedAudioBook.id, - hostingPlatforms: ['Spotify'], - } as AudioBook; + describe('and some field values are invalid', () => { + it('then throws an exception', async () => { + const bookToUpdate = { + id: storedAudioBook.id, + hostingPlatforms: ['Spotify'], + isbn: undefined as unknown as string, + } as AudioBook; + + await expect( + bookRepository.save(bookToUpdate), + ).rejects.toThrowError(ValidationException); + }); + }); - const book = await bookRepository.save(bookToUpdate); - expect(book.id).toBe(storedAudioBook.id); - expect(book.title).toBe(storedAudioBook.title); - expect(book.description).toBe(storedAudioBook.description); - expect(book.hostingPlatforms).toEqual( - bookToUpdate.hostingPlatforms, - ); + describe('and all field values are valid', () => { + it('then updates the book', async () => { + const bookToUpdate = { + id: storedAudioBook.id, + hostingPlatforms: ['Spotify'], + } as AudioBook; + + const book = await bookRepository.save(bookToUpdate); + expect(book.id).toBe(storedAudioBook.id); + expect(book.title).toBe(storedAudioBook.title); + expect(book.description).toBe(storedAudioBook.description); + expect(book.hostingPlatforms).toEqual( + bookToUpdate.hostingPlatforms, + ); + }); }); }); + describe('and that specifies all the contents of the subtype', () => { - it('then updates the book', async () => { - const bookToUpdate = new AudioBook({ - id: storedAudioBook.id, - title: 'Don Quixote', - description: 'Important classic in Spanish literature', - hostingPlatforms: ['Spotify'], - format: 'mp3', - isbn: '0142437239', + describe('and some field values are invalid', () => { + it('then throws an exception', async () => { + const bookToUpdate = new AudioBook({ + id: storedAudioBook.id, + title: 'Don Quixote', + description: 'Important classic in Spanish literature', + hostingPlatforms: undefined as unknown as string[], + format: 'mp3', + isbn: '0142437239', + }); + + await expect( + bookRepository.save(bookToUpdate), + ).rejects.toThrowError(ValidationException); }); + }); - const book = await bookRepository.save(bookToUpdate); - expect(book.id).toBe(bookToUpdate.id); - expect(book.title).toBe(bookToUpdate.title); - expect(book.description).toBe(bookToUpdate.description); - expect(book.hostingPlatforms).toEqual( - bookToUpdate.hostingPlatforms, - ); - expect(book.format).toEqual(bookToUpdate.format); + describe('and all field values are valid', () => { + it('then updates the book', async () => { + const bookToUpdate = new AudioBook({ + id: storedAudioBook.id, + title: 'Don Quixote', + description: 'Important classic in Spanish literature', + hostingPlatforms: ['Spotify'], + format: 'mp3', + isbn: '0142437239', + }); + + const book = await bookRepository.save(bookToUpdate); + expect(book.id).toBe(bookToUpdate.id); + expect(book.title).toBe(bookToUpdate.title); + expect(book.description).toBe(bookToUpdate.description); + expect(book.hostingPlatforms).toEqual( + bookToUpdate.hostingPlatforms, + ); + expect(book.format).toEqual(bookToUpdate.format); + }); }); }); });