Back-end w Node.js z Nest.js (REST API #1)

Dzień dobry! Mam nadzieje, że wszystko u was w porządku, 2020 był ciężki, trzymajcie się!

Dzisiaj zabiorę Was na przygodę z Nest.js - biblioteką która wprowadza poważne wzorce i architekturę do aplikacjach opartych na Express.js. Uczciwie przyznam, że od dawna spoglądam tam w poszukiwaniu inspiracji i rozwiązań, więc ten tutorial musiał powstać :)

Z powodu dużej popularności poprzednich wpisów o Node.js zadecydowałam, że będzie to tutorial dla kompletnie zielonych. Nie było to łatwe, gdyż Nest.js wykorzystuje kilka popularnych wzorców, których wytłumaczenie uczyniłoby ten wpis trudniejszym.

Dzisiaj będziemy dodawali, usuwali, edytowali i odczytywali opowiadania za pomocą REST API.

Aby ukończyć ten tutorial powinieneś:

  • umieć otworzyć konsolę
  • znać podstawy TypeScripta

Instalacja

Zakładam, że macie Node.js w wersji minimum 12. Można to sprawdzić wpisując node -v.

Zainstalujmy Nest.js CLI, która pozwoli na automatyzację niektórych czynności takich jak wygenerowanie podstaw projektu. Linuksiarze, pamiętajcie o uprawieniach przy instalacji globalnej paczki :)

npm i -g @nestjs/cli
// nest new <nazwa_projektu>
nest new tutek

Powyżej mamy wygenerowany projekt o nazwie tutek.  Sprawdźmy czy aplikacja wygenerowała się poprawnie wpisując w folderze tutek komendę:

npm run start

Poczekajmy chwilę aż transpilator skończy pracę, skrypt uruchomi aplikację i wejdźmy na http://localhost:3000/.

Powinniśmy zobaczyć napis Hello World! będący naszym pierwszym sukcesem! Gratulacje!

Teoria Nest.js dla początkujących programistów

Jeśli znasz Nest.js lub przeczytałeś dokumentację to ten podpunkt możesz pominąć. Może być tymczasowym zamiennikiem dla dokumentacji, szczególnie że wybiera kluczowe dla tutoriala kwestie i upraszcza/trywializuje je. Są to uogólnienia skierowane do początkującego programisty i nie powinny być odnośnikiem do żadnych rozważań.

Dzisiaj będziemy używać trzech podstawowych koncepcji z Nest.js: modułów, kontrolerów oraz providerów:

  • moduł to klasa grupująca kontrolery i providery. Nie jest jednak obiektem abstrakcyjnym, nastawionym jednie na wrapowanie. Pozwala definiować zależności z innymi modułami (przez importy i eksporty) oraz nakładać globalne właściwości na obiekty które zawiera.
  • kontrolery to klasy odpowiedzialne za zapytania i odpowiedzi naszego API. Najprościej mówiąc to dzięki nim mamy dostęp do danych pod adresem URL.
  • providery to ogólna nazwa dla pomocniczych klas. Logika/dostęp do danych/generowanie obiektów itd. nie powinna być kodowana w kontrolerze, a w osobnych klasach, gdyż może być tak, że będziemy chcieli z niej korzystać w innym kontrolerze, a kod powinien być reużywalny (w granicach rozsądku).

Moduł Story

Zanim zaczniemy kodowanie wyłączmy poprzedni serwer Node.js zamykając konsolę i otwórzmy nową aby wywołać: npm run start:dev. Ta komenda nie tylko transpiluje aplikację z TypeScripta na JavaScript i uruchamia ją, ale restartuje serwer po każdej zmianie w kodzie.

Po wygenerowaniu projektu powinniśmy mieć wygenerowany folder src:

  • usuńmy wszystkie pliki ts poza plikami: app.module.ts i main.ts.
  • stwórzmy folder story

W folderze story utwórzmy plik story.module.ts. Prosta aplikacja Nest.js będzie składała się z połączonych ze sobą modułów których struktura znajdzie odzwierciedlenie w logice.

// /src/story/story.module.ts
import { Module } from '@nestjs/common';
import { StoryController } from './story.controller';
import { StoryService } from './story.service';

@Module({
  controllers: [StoryController],
  providers: [StoryService],
})
export class StoryModule {}

Moduł jest miejscem w którym importujemy (czyli czynimy nasz moduł Story zależnym od tego zasobu) oraz eksportujemy (czyli pozwalamy innym modułom korzystać z naszego moduł Story). Moduł może zawierać dużo więcej, nasz będzie jednak pustą definicją klasy, w której - za pomocą dekoratora - importujemy kontroler StoryController w który będzie zdefiniowane nasze API oraz serwis StoryService z którego będziemy pobierać dane.

Struktura danych

W naszej aplikacji będziemy wykorzystywać dwa typy (zalecane przez developerów Nest.js):

  • interfejs Story
  • klasę createStoryDTO
  • powinny jeszcze istnieć analogiczne klasy DTO, na razie popełnijmy świadomy błąd żeby wszystko uprosić

Mam nadzieje, że wiecie co to jest interfejs, z DTO sprawa wymaga wytłumaczenia. DTO to typ obiektów które będziemy przesyłać po HTTP. Na przykład - jak zobaczcie w przykładzie - tworząc Story nie potrzebne jest przesyłanie całego obiektu. Ba! Jest to niemożliwe, bo nie znamy jego id PRZED utworzeniem.

export interface Story {
    id: string;
    name: string
}
src/story/dto/createStory.dto.ts
import { Story } from "../interfaces/story.interface";

export class createStoryDto {
    public name: string
}

Nie zaszalałam, prawda? Prosto i klarowanie :)

Serwis StoryService

StoryService jest punktem z którego w całej aplikacji będziemy pobierać dane o Story. Zwykle serwis odwołuje się do jakiegoś źródła, na przykład bazy danych. Dzisiaj jednak - na potrzeby tutoriala - będziemy trzymać dane w pamięci co oznacza, że będą istnieć tak długo jak instancja serwera: od uruchomienia node.js do jego zamknięcia/przeładowania.

// src/story/story.service.ts

import { Injectable } from '@nestjs/common';
import { Story } from './interfaces/story.interface';

@Injectable()
export class StoryService {
  private stories: Story[] = [];

  constructor() {
    // możecie to odkomentować jeśli chcecie mieć jakieś dane na początku
    // this.stories.push({ id: '2', name: 'ExampleStoryName'});
  }

  getStories(): Story[] {
    return this.stories;
  }

  getStory(id: string): Story {
    return this.stories.find((story: Story) => story.id === id);
  }

  create(newStory: Story): void {
    this.stories.push(newStory);
  }

  remove(id: string): void {
    const index = this.stories.findIndex((story: Story) => story.id === id);
    if (index > -1) {
      this.stories.splice(index, 1);
    }
  }

  update(newStory: Story): void {
    this.stories = this.stories.map((story: Story) => story.id === newStory.id ? newStory : story);
  }
}

Klasa StoryService posiada dekorator o nazwie @Injectable. Jeśli chcecie się w to zagłębić to warto poczytać o wzorcu Dependency Injection, dzisiaj wystarczy Wam informacja, że jest konieczny aby przekazać w module StoryModule klasę do  providers.

Następnie inicjalizujemy prywatną właściwość (property) stories, które będzie naszym źródłem danych :).

Mam nadzieje, że metody są jasne, ale podsumujmy:

  • metoda getStories zwraca wszystkie story w pamięci
  • metoda getStory(id) zwraca story o id równym parametrowi.
  • metoda create(story) tworzy nowe story
  • metoda remove(id) usuwa story o danym id
  • metoda update(story) uaktulnia story o danym id

Kontroler StoryController

Kontroler - jeśli chodzi o back-end w Nest.js - jest klasą odpowiadającą za endpointy (czyli dostępne w API ścieżki takie jak http://mojastrona.pl/story). Kontrolery w Nest.js są ograne za pomocą biblioteki routing-controllers o których możecie przeczytać w moim poprzednim wpisie: tutaj.

// src/story/story.controller.

import { Controller, Get, Put, Delete, Param, Body, Post } from '@nestjs/common';
import { createStoryDto } from './dto/createStory.dto';
import { Story } from './interfaces/story.interface';
import { StoryService } from './story.service';

@Controller('/story')
export class StoryController {
  constructor(private readonly storyService: StoryService) {}

  @Get()
  getStories(): Story[] {
    return this.storyService.getStories();
  }

  @Get(':id')
  getStory(@Param('id') id: string): Story {
    return this.storyService.getStory(id);
  }

  @Post()
  createStory(@Body() updateCatDto: createStoryDto) {
    // proszę tak nie robić, to powinno zostać utworzone inaczej
    // przez generowanie UUID;
    // często id tworzy za nas baza danych przez autoinkrementację
    const id = `${Math.floor(Math.random() * 1000)}`;
    this.storyService.create({...updateCatDto, id });
  }

  @Put(':id')
  updateStory(@Param('id') id: string, @Body() updateCatDto: createStoryDto) {
    this.storyService.update({ ...updateCatDto, id });
  }

  @Delete(':id')
  removeStory(@Param('id') id: string) {
    this.storyService.remove(id);
  }
}

Klasa StoryController jest oznaczona dekoratorem @Controller. Przyjmuje on jako parametr string, który jest ścieżką bazową dla całego kontrolera. Warto wspomnieć, że ten dekorator zmienia odpowiedź (jak to dekorator) i domyślnie wszystkie obiekty zostaną zwrócone jako JSON. Nie trzeba nic robić, wszystko dzieje się pod spodem dzięki mocy Nest.js.

W powyższym kontrolerze stworzone są metody udostępniające poniższe ścieżki:

  • GET http://localhost:3000/story/ zwracająca tablicę obiektów Story
  • GET http://localhost:3000/story/{id} zwracająca pojedynczy obiekt Story
  • PUT http://localhost:3000/story/{id} zmieniająca obiekt o id 2 na podstawie przesyłanych danych
  • DELETE http://localhost:3000/story/{id} usuwający obiekt o id równym 2
  • POST http://localhost:3000/story/ tworzący nowy obiekt na podstawie przesłanych danych

Podłączenie modułu Story do App

Jeśli usunęliście niepotrzebne pliki z modułu App jak napisałam wcześniej to powinniście mieć app.module.ts i main.ts. Ten pierwszy musimy lekko zmodyfikować.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { StoryModule } from './story/story.module';

@Module({
  imports: [StoryModule],
})
export class AppModule {}

Uruchomienie i testowanie

Jeśli uruchomiliście aplikację za pomocą npm run start:dev i odkomentowaliście linijkę w contructorze StoryService to przy tworzeniu tego obiektu powinno dodać się jedno Story i możemy je zobaczyć wpisując w przeglądarkę http://localhost:3000/story.

Gratulacje! Twoje API zwraca dane.

Teraz pewnie zastanawiasz się jak przetestować resztę. Masz wiele wyjść, ale ja proponuję dwa narzędzia:

  • biblioteka curl korzysta z terminala/konsoli żeby wysłać zapytanie i zwraca odpowiedź
  • Postman to program w którym można wyklikać, pobierać, zapisywać, katalogować zapytania, ustawiać zmienne itd. Można też wyklikać tylko proste zapytanie i wysłać.

Osobiście używam Postmana w którym wybieram typ zapytania z dropdowna, wpisuję ścieżkę i potem Body -> Raw i wybieram Content-Type jako JSON z dropdowna.

Używając curl w ten sposób będziecie dodawać nowe story: curl -v -X POST -H "Content-Type: application/json" -d '{"name":"Testowa nazwa"}' http://localhost:3000/story.

Teraz po odświeżeniu strony powinniście zobaczyć nowe story pod adresem http://localhost:3000/story.

Podsumowanie

Mam nadzieje, że dzisiaj w godzinę albo dwie napisaliście ze mną swoje pierwsze API przy użyciu Nest.js. Czeka nas jeszcze dużo pracy związanej z walidacją danych, podłączeniem bazy, autoryzacją i obsługą błędów, jednak mam nadzieje, że widzicie jak prosto można stworzyć szkielet.

Pozdrawiam Was serdecznie i dziękuję za poświęcony czas. Jeśli się spodobało, skorzystaliście lub znaleźliście błędy to zapraszam do komentowania i dyskusji.

Materiały do uzupełnienia wiedzy

Ten wpis miał być krótki i treściwy, a wręcz dowodzić jak można pozwolić komuś na naukę, której mi jest najbliżej: modyfikację gotowych rozwiązań i naukę poprzez czytanie kodu. Mam jednak wielkie parcie na szerzenie wiedzy, więc dla ciekawskich zostawiłam kilka linków:

  • co to jest ten dekorator (oznaczony @)? FSGeek dobrze to tłumaczy w tym artykule.
  • co to jest Dependency Injection? O tym opowie Wam Michał z TypeOfWeb w tutaj.
  • Trochę teorii o tworzeniu API oraz HTTP opowiedzą dwaj Grzegorze: Grzesiek z DevMobile.pl w tym wpisie i Grzesiek z gregkaqa.pl tutaj przy okazji pokazując Wam Postmana (bardzo polecam).

Kamila

Dziękuję za poświęcony czas, będzie mi bardzo miło jak zostawisz komentarz :)