2020-09-22 13:49:40 +08:00

718 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Building RESTful APIs with NestJS
In this post, I will demonstrate how to kickstart a simple RESTful APIs with NestJS from a newbie's viewpoint.
## What is NestJS?
As described in the [Nestjs](https://nestjs.com) website, Nestjs is *a progressive Node.js framework for building efficient, reliable and scalable server-side applications.*
Nestjs combines the best programming practice and the cutting-edge techniques from the NodeJS communities.
* A lot of NestJS concepts are heavily inspired by the effort of the popular frameworks in the world, esp. [Angular](https://www.angular.io) .
* Nestjs hides the complexities of web programming in NodeJS, it provides a common abstraction of the web request handling, you are free to choose [Expressjs](https://expressjs.com/) or [Fastify](https://www.fastify.io) as the background engine.
* Nestjs provides a lot of third party project integrations, from database operations, such as Mongoose, TypeORM, etc. to Message Brokers, such as Redis, RabbitMQ, etc.
If you are new to Nestjs like me but has some experience of [Angular](https://www.angular.io) , [TypeDI](https://github.com/typestack/typedi#usage-with-typescript) or [Spring WebMVC](http://www.spring.io), bootstraping a Nestjs project is really a piece of cake.
## Generating a NestJS project
Make sure you have installed the latest [Nodejs](https://nodejs.org/en/).
```bash
npm i -g @nestjs/cli
```
When it is finished, there is a `nest` command available in the `Path`. The usage of `nest` is similar with `ng` (Angular CLI), type `nest --help` in the terminal to list help for all commands.
```bash
nest --help
Usage: nest <command> [options]
Options:
-v, --version Output the current version.
-h, --help Output usage information.
Commands:
new|n [options] [name] Generate Nest application.
build [options] [app] Build Nest application.
start [options] [app] Run Nest application.
info|i Display Nest project details.
update|u [options] Update Nest dependencies.
add [options] <library> Adds support for an external library to your project.
generate|g [options] <schematic> [name] [path] Generate a Nest element.
Available schematics:
┌───────────────┬─────────────┐
│ name │ alias
│ application │ application │
│ class │ cl │
│ configuration │ config │
│ controller │ co │
│ decorator │ d │
│ filter │ f │
│ gateway │ ga │
│ guard │ gu │
│ interceptor │ in │
│ interface │ interface │
│ middleware │ mi │
│ module │ mo │
│ pipe │ pi │
│ provider │ pr │
│ resolver │ r │
│ service │ s │
│ library │ lib │
│ sub-app │ app │
└───────────────┴─────────────┘
```
Now generate a Nestjs project via:
```bash
nest new nestjs-sample
```
Open it in your favorite IDEs, such as [Intellij WebStorm](https://www.jetbrains.com/webstorm/) or [VSCode](https://code.visualstudio.com/).
## Exploring the project files
Expand the project root, you will see the following like tree nodes.
```bash
.
├── LICENSE
├── nest-cli.json
├── package.json
├── package-lock.json
├── README.md
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
```
The default structure of this project is very similar with the one generated by Angular CLI.
* *src/main.ts* is the entry file of this application.
* *src/app\** is the top level component in a nest application.
* There is an a*pp.module.ts* is a Nestjs `Module` which is similar with Angular `NgModule`, and used to organize codes in the logic view.
* The *app.service.ts* is an `@Injectable` component, similar with the service in [Angular](https://spring.io) or Spring's Service, it is used for handling business logic. A service is annotated with `@Injectable`.
* The *app.controller.ts* is the controller of MVC, to handle incoming request, and responds the handled result back to client. The annotatoin `@Controller()` is similar with Spring MVC's `@Controller`.
* The *app.controller.spec.ts* is test file for *app.controller.ts*. Nestjs uses [Jest](https://jestjs.io/) as testing framework.
* *test* folder is for storing e2e test files.
## Defining the APIs
I will reuse the concept I've used in the former examples - the blogging posts.
* GET /posts - Get all posts
* GET /posts/id - Get post by id
* POST /posts - Save a post
* PUT /posts/id - Update the certain post specified by id
* DELETE /posts/id - Delete a post
In the next steps, we will create:
* A new module `PostModule` to organize all these features.
* A `Post` interface to present the `Post` entity resource.
* A `PostService` component to serve the data for controller. In this post, we use a dummy data storage temporarily, and we will replace it with a real MongoDB in the future post.
* A `PostController` to expose APIs which is responsible for handling incoming requests.
## Creating PostService
Following the Nestjs coding style, first of all, let's generate a module named `post`:
```bash
nest g mo post
```
`PosModule` is imported into the top-level `AppModule`. Check the content of file *src/app/app.module.ts*:
```typescript
//... other imports
import { PostModule } from './post/post.module';
@Module({
imports: [PostModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
```
Then generate a `Post` service and interface respectively.
```bash
nest g s post
nest g interface post
```
The `PostService` is added to `PostModule` automatically when it is generated.
```typescript
//...other imports
import { PostService } from './post.service';
@Module({
providers: [PostService]
})
export class PostModule {}
```
After it is done there are 4 files created in the *src/app/post* folder, including a *spec* file for testing `PostService`.
```bash
post.interface.ts
post.module.ts
post.service.spec.ts
post.service.ts
```
Firstly, let's open `post.interface.ts` to model the `Post` entity.
```typescript
export interface Post {
id?: number;
title:string;
content: string;
createdAt?: Date,
updatedAt?: Date
}
```
In the above interface, add some fields as you see, here we make the `id`, `createdAt`, `updatedAt` *optional* now.
Let's create a method in `PostService` to fetch all posts.
```typescript
@Injectable()
export class PostService {
private posts: Post[] = [
{
id: 1,
title: 'Generate a NestJS project',
content: 'content',
createdAt: new Date(),
},
{
id: 2,
title: 'Create CRUD RESTful APIs',
content: 'content',
createdAt: new Date(),
},
{
id: 3,
title: 'Connect to MongoDB',
content: 'content',
createdAt: new Date(),
},
];
findAll(): Observable<Post> {
return from(this.posts);
}
}
```
In the codes, we used an array `posts` as the background data storage. The `findAll` method returns a `Observerable` which is created from the dummy `posts` we have declared.
Add a test for this method. Open `post.service.spec.ts`, add a new test case.
```typescript
describe('PostService', () => {
let service: PostService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostService],
}).compile();
service = module.get<PostService>(PostService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getAllPosts should return 3 posts', done => {
service
.findAll()
.pipe(take(3), toArray())
.subscribe({
next: data => expect(data.length).toBe(3),
error: error => console.log(error),
complete: done(),
});
});
}
```
Similar with Angular, in the `beforeEach` hook , it prepares a `TestingModule` to assemble the required resources in the test.
> Like the annotations (@BeforeEach, etc.) provided JUnit 5 in Java world, there are some similar hooks ready for preparing the test and doing clean work in a Jest test, such as `beforeAll, afterAll, beforeEache, afterEach` , etc.
In the newly added `it('getAllPosts should return 3 posts', done => {}` , it uses a `done` handler to mark the an asynchronous task is done. It is very helpful to test `Promise` or `Observerable` invocation in a test.
The `pipe` accepts a series of Rxjs operators for some `processor` working, such as filtering, transforming, collecting, etc. check the [Rxjs official docs](https://rxjs.dev/operator-decision-tree).
The `subscribe` accept a subscriber to handle the data stream, see the [Subscriber API](https://rxjs.dev/api/index/class/Subscriber) for more details.
Run `npm run test` command in the root folder to run all tests.
```bash
npm run test
> nestjs-sample@0.0.1 test D:\hantsylabs\nestjs-sample
> jest
...
Time: 4.563 s, estimated 11 s
Ran all test suites.
```
Awesome, it works.
> If you want to track the changes of test codes and rerun the test cases automatically, use `npm run test:watch` instead.
Let's modify the `findAll` slightly, make it accept a keyword to query the posts.
```typescript
@Injectable()
export class PostService {
//...
findAll(keyword?: string): Observable<Post> {
if (keyword) {
return from(this.posts.filter(post => post.title.indexOf(keyword) >= 0));
}
return from(this.posts);
}
}
```
Add another test to verify filtering the posts by keyword.
```typescript
it('getAllPosts with keyword should return 1 post', done => {
service
.findAll('Generate')
.pipe(take(3), toArray())
.subscribe({
next: data => expect(data.length).toBe(1),
error: error => console.log(error),
complete: done(),
});
});
```
When using a keyword *Generate*, only one item will be included in the data stream. Run the test again, it will pass.
Let's move the next one. Create a `findById` method in `PostService` class. When a post is found return the found post else return a Rxjs `EMPTY`.
```typescript
findById(id: number): Observable<Post> {
const found = this.posts.find(post => post.id === id);
if (found) {
return of(found);
}
return EMPTY;
}
```
And add a test for the case when a post is found.
```typescript
it('getPostById with existing id should return 1 post', done => {
service.findById(1).subscribe({
next: data => {
expect(data.id).toBe(1);
expect(data.title).toEqual('Generate a NestJS project');
},
error: error => console.log(error),
complete: done(),
});
});
```
When the `findById` return a `EMPTY`, I tried to use the same approach to verify the result in the `next` handler, but failed, the `next` is never called, I started a [topic on stackoverflow](https://stackoverflow.com/questions/62208107/how-test-a-rxjs-empty-operater-using-jest), and got two solutions for handling the `EMPTY` stream.
The first solution is utilizing the *testing* facility from Rxjs. Set up a `TestScheduler` for test, and use `expectObservable` to assert the data stream in [marble diagrams](https://rxjs-dev.firebaseapp.com/guide/testing/internal-marble-tests).
```typescript
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
expect(actual).toEqual(expected);
});
});
// This test will actually run *synchronously*
it('test complete for empty()', () => {
testScheduler.run(helpers => {
const { cold, expectObservable, expectSubscriptions } = helpers;
expectObservable(service.findById(10001)).toBe('|');
});
});
```
The second test is using a signal myself to make sure it is completed.
```typescript
it('getPostById with none existing id should return empty', done => {
let called = false;
service.findById(10001).subscribe({
next: data => {
console.log(data);
called = true;
},
error: error => {
console.log(error);
called = true;
},
complete: () => {
expect(called).toBeFalsy();
done();
},
});
```
Another workaround is converting the `EMPTY` stream to an array and asserting it is an empty array in the `next` handler.
```typescript
it('getPostById with none existing id should return empty', done => {
service
.findById(10001)
.pipe(toArray())
.subscribe({
next: data => expect(data.length).toBe(0),
error: error => console.log(error),
complete: done(),
});
});
```
Ok, we have resolved the `emtpy` stream testing issue. Go ahead.
Next, let's create a method to save a new `Post` into the data storage. Since we are using an array for the dummy storage, it is simple when saving a new `Post`, just append it into the end of the existing `posts`:
```typescript
save(data: Post): Observable<Post> {
const post = { ...data, id: this.posts.length + 1, createdAt: new Date() };
this.posts = [...this.posts, post];
return from(this.posts);
}
```
> Here, we return the new array as result. In a real world application, most of case it is better to return the new persisted object or the id of the new post.
Create a test for saving a new post, verify if the length of the array is increased and if the `createdAt` is set in the new post.
```typescript
it('save should increase the length of posts', done => {
service
.save({
id: 4,
title: 'test title',
content: 'test content',
})
.pipe(toArray())
.subscribe({
next: data => {
expect(data.length).toBe(4);
expect(data[3].createdAt).toBeTruthy();
},
error: error => console.log(error),
complete: done(),
});
});
```
Execute `npm run test` to make make sure it works.
Similarly, create a `update` method in `PostService` class to update the existing post.
```typescript
update(id: number, data: Post): Observable<Post> {
this.posts = this.posts.map(post => {
if (id === post.id) {
post.title = data.title;
post.content = data.content;
post.updatedAt = new Date();
}
return post;
});
return from(this.posts);
}
```
Create a test for `update` method. In the test, we updated the first item in the `posts` data store, and update it, and finally verify the changes.
```typescript
it('update should change the content of post', done => {
service
.update(1, {
id: 1,
title: 'test title',
content: 'test content',
createdAt: new Date(),
})
.pipe(take(4), toArray())
.subscribe({
next: data => {
expect(data.length).toBe(3);
expect(data[0].title).toBe('test title');
expect(data[0].content).toBe('test content');
expect(data[0].updatedAt).not.toBeNull();
},
error: error => console.log(error),
complete: done(),
});
});
```
Create a method to delete post by id.
```typescript
deleteById(id: number): Observable<boolean> {
const idx: number = this.posts.findIndex(post => post.id === id);
if (idx >= 0) {
this.posts = [
...this.posts.slice(0, idx),
...this.posts.slice(idx + 1),
];
return of(true);
}
return of(false);
}
```
Create a test case in `post.service.test` to verify the functionality of the `deleteById` method.
```typescript
it('deleteById with existing id should return true', done => {
service.deleteById(1).subscribe({
next: data => expect(data).toBeTruthy,
error: error => console.log(error),
complete: done(),
});
});
it('deleteById with none existing id should return false', done => {
service.deleteById(10001).subscribe({
next: data => expect(data).toBeFalsy,
error: error => console.log(error),
complete: done(),
});
});
```
OK, all methods used for CRUD functionalities are ready, let's move to the controller.
## Creating PostController
Like Spring WebMVC, in the NestJS world, the controller is responsible for handling incoming requests from the client, and sending back the handled result to the client.
Generate a controller using `nest` command:
```bash
nest g co post
```
It will add two files into the *src/app/post* folder.
```bash
post.controller.spec.ts
post.controller.ts
```
And `PostController` is registered in `PostModule` automatcially when it is generated.
```typescript
//...other imports
import { PostController } from './post.controller';
@Module({
controllers: [PostController],
providers: [PostService]
})
export class PostModule {}
```
Open the *post.controller.ts* file and enrich the `PostController` class as we planned.
```typescript
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get('')
getAllPosts(@Query('q') keyword?: string): Observable<BlogPost[]> {
return this.postService.findAll(keyword).pipe(take(10), toArray());
}
@Get(':id')
getPostById(@Param('id', ParseIntPipe) id: number): Observable<BlogPost> {
return this.postService.findById(id);
}
@Post('')
createPost(@Body() post: BlogPost):Observable<BlogPost[]> {
return this.postService.save(post).pipe(toArray());
}
@Put(':id')
updatePost(@Param('id', ParseIntPipe) id: number, @Body() post: BlogPost): Observable<BlogPost[]> {
return this.postService.update(id, post).pipe(toArray());
}
@Delete(':id')
deletePostById(@Param('id', ParseIntPipe) id: number): Observable<boolean> {
return this.postService.deleteById(id);
}
}
```
In the above codes:
* In the `constructor`, we add `PostService` as its arguments, the `PostService` component will be injected automatically.
* The `Controller` annotation indicates it is a controller, and it uses `/posts` as base uri all methods.
* The `Get`, `Post`, `Put`, and `Delete` on methods are used for handling different HTTP methods.
* The `@Param` binds the path parameters (`:id`) to the method arguments, and there is `ParseIntPipe` applied in the second arguments of Param. Pipe is a Angular concept, here it is more close to Spring's converters and validators.
* `@Body` parses the request body into a `Post` object.
> In the Typescript language, we call the `@Controller` syntax as Decorator. For those who are familiar with Spring WebMVC, using the word **annotation** is easier to understand.
Now we add tests for `PostController`.
Open *post.controller.spec.ts* , add the following tests.
```typescript
describe('Post Controller', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PostService],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('GET on /posts should return all posts', async () => {
const posts = await controller.getAllPosts().toPromise();
expect(posts.length).toBe(3);
});
it('GET on /posts/1 should return one post ', done => {
controller.getPostById(1).subscribe(data => {
expect(data.id).toEqual(1);
done();
});
});
it('POST on /posts should return all posts', async () => {
const post:Post = {id:4, title:'test title', content:'test content'};
const posts = await controller.createPost(post).toPromise();
expect(posts.length).toBe(4);
});
it('PUT on /posts/1 should return all posts', done => {
const post:Post = {id:4, title:'test title', content:'test content'};
controller.updatePost(1, post).subscribe(data => {
expect(data.length).toBe(3);
expect(data[0].title).toEqual('test title');
expect(data[0].content).toEqual('test content');
expect(data[0].updatedAt).toBeTruthy();
done();
});
});
it('DELETE on /posts/1 should return true', done => {
controller.deletePostById(1).subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
it('DELETE on /posts/1001 should return false', done => {
controller.deletePostById(1001).subscribe(data => {
expect(data).toBeFalsy();
done();
});
});
});
```
Run the tests again and make sure it works.
## Run the application
Now let's try to run the application.
Open a terminal, and go to the root folder of the project, and execute the following command.
```bash
>npm run start
> nestjs-sample@0.0.1 start D:\hantsylabs\nestjs-sample
> nest start
[Nest] 9956 - 06/13/2020, 12:04:50 PM [NestFactory] Starting Nest application...
[Nest] 9956 - 06/13/2020, 12:04:50 PM [InstanceLoader] AppModule dependencies initialized +16ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [InstanceLoader] PostModule dependencies initialized +1ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RoutesResolver] AppController {}: +10ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {, GET} route +4ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RoutesResolver] PostController {/posts}: +1ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {/posts, GET} route +1ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {/posts/:id, GET} route +2ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {/posts, POST} route +1ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {/posts/:id, PUT} route +2ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [RouterExplorer] Mapped {/posts/:id, DELETE} route +2ms
[Nest] 9956 - 06/13/2020, 12:04:50 PM [NestApplication] Nest application successfully started +7ms
```
When it is started up, it serves at http://localhost:3000.
Let's test the exposed APIs via `curl`:
```bash
>curl http://localhost:3000/posts
[{"id":1,"title":"Generate a NestJS project","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":2,"title":"Create CRUD RESTful APIs","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":3,"title":"Connect to MongoDB","content":"content","createdAt":"2020-06-13T04:20:21.920Z"}]
>curl http://localhost:3000/posts/1
{"id":1,"title":"Generate a NestJS project","content":"content","createdAt":"2020-06-13T04:20:21.920Z"}
>curl http://localhost:3000/posts -d "{\"title\":\"new post\",\"content\":\"content of my new post\"}" -H "Content-Type:application/json" -X POST
[{"id":1,"title":"Generate a NestJS project","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":2,"title":"Create CRUD RESTful APIs","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":3,"title":"Connect to MongoDB","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"title":"new post","content":"content of my new post","id":4,"createdAt":"2020-06-13T04:20:52.526Z"}]
>curl http://localhost:3000/posts/1 -d "{\"title\":\"updated post\",\"content\":\"content of my upated post\"}" -H "Content-Type:application/json" -X PUT
[{"id":1,"title":"updated post","content":"content of my upated post","createdAt":"2020-06-13T04:20:21.920Z","updatedAt":"2020-06-13T04:21:08.259Z"},{"id":2,"title":"Create CRUD RESTful APIs","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":3,"title":"Connect to MongoDB","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"title":"new post","content":"content of my new post","id":4,"createdAt":"2020-06-13T04:20:52.526Z"}]
>curl http://localhost:3000/posts/1 -X DELETE
true
>curl http://localhost:3000/posts
[{"id":2,"title":"Create CRUD RESTful APIs","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"id":3,"title":"Connect to MongoDB","content":"content","createdAt":"2020-06-13T04:20:21.920Z"},{"title":"new post","content":"content of my new post","id":4,"createdAt":"2020-06-13T04:20:52.526Z"}]
```
In the further post, we will connect to a real database to replace the dummy data service.
Grab [the source codes from my github](https://github.com/hantsy/nestjs-sample).