Skip to content

Latest commit

 

History

History
717 lines (564 loc) · 24.6 KB

guide.md

File metadata and controls

717 lines (564 loc) · 24.6 KB

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 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 .
  • 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 or Fastify 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 , TypeDI or Spring WebMVC, bootstraping a Nestjs project is really a piece of cake.

Generating a NestJS project

Make sure you have installed the latest Nodejs.

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.

❯ 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:

nest new nestjs-sample

Open it in your favorite IDEs, such as Intellij WebStorm or VSCode.

Exploring the project files

Expand the project root, you will see the following like tree nodes.

.
├── 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 app.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 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 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:

nest g mo post

PosModule is imported into the top-level AppModule. Check the content of file src/app/app.module.ts:

//... 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.

nest g s post
nest g interface post

The PostService is added to PostModule automatically when it is generated.

//...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.

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.

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.

@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.

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.

The subscribe accept a subscriber to handle the data stream, see the Subscriber API for more details.

Run npm run test command in the root folder to run all tests.

❯ npm run test

> [email protected] 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.

@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.

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.

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.

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, 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.

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.

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.

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:

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.

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.

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.

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.

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.

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:

nest g co post

It will add two files into the src/app/post folder.

post.controller.spec.ts
post.controller.ts

And PostController is registered in PostModule automatcially when it is generated.

//...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.

@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.

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.

>npm run start
> [email protected] 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:

>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.