Skocz do treści

Już wkrótce odpalamy zapisy na drugą edycję next13masters.pl. Zapisz się na listę oczekujących!

REST API w node.js z HapiJS – definicja route'ów i prosta walidacja

Kontynuuję serię wpisów na temat tworzenia backendu w node.js z wykorzystaniem HapiJS. Dzisiaj o obsługiwaniu parametrów i zapytań oraz o walidacji. Zapraszam!

Ten artykuł jest częścią 2 z 5 w serii HapiJS.

Zdjęcie Michał Miszczyszyn
JavaScript2 komentarze

Jeśli cokolwiek okaże się niejasne to zachęcam do zadawania pytań w komentarzach.

Budowa URL-a

Nie byłbym sobą, gdybym nie spróbował najpierw wyjaśnić kilku pojęć, którymi będę się dzisiaj posługiwał. Weźmy taki przykładowy adres internetowy:

http://example.com/subpage?query=123&arg=val#home

Musimy umówić co do nazewnictwa poszczególnych fragmentów takiego adresu, aby łatwiej zrozumieć dalszą część artykułu :) Gotowi?

http protokół
example.com host/domena
/subpage ścieżka (path)
?query=123&arg=val query string
#home fragment
Co prawda nie każdy host jest domeną, ale w tym przypadku to nieistotne. Ważne, żebyśmy się dobrze rozumieli :) Szczególnie istotne są dla nas ścieżka oraz query string. Teraz możemy przejść do konkretów.

Obsługa ścieżek w HapiJS

Kontynuujemy w miejscu, w którym skończyliśmy po poprzednim wpisie. Mamy działający serwer w HapiJS oraz jeden endpoint /:
server.route({  
    method: 'GET',
    path: '/',
    handler(request, reply) {
        reply('Hello, world!');
    }
});
Jak już wiemy, oznacza to, że pod ścieżką / zobaczymy napis Hello, world!.

Ścieżki statyczne

Możemy dodać kolejne endpointy zwracające inne odpowiedzi pod różnymi adresami. Przykładowo chcemy, aby zapytanie GET /hello zwracało napis Siema:
server.route({  
    method: 'GET',
    path: '/hello',
    handler(request, reply) {
        reply('Siema');
    }
});
W podobny sposób możemy zbudować dowolną statyczną ścieżkę, np.: /about/services/programming

Parametry

Co jednak w sytuacji gdy chcemy, aby nasz adres wyglądał na przykład tak: /users/michal, gdzie zamiast “michal” możemy wpisać dowolne imię? Z pomocą przychodzą parametry.

W HapiJS parametry definiujemy zamykając je wewnątrz klamerek {}:

path: /users/{name}

To już mamy. Aby dobrać się do wartości przekazanej jako parametr musimy zajrzeć do obiektu, którego jeszcze nie dotykaliśmy – request. Zawiera on taki obiekt jak request.params, a w nim wszystkie parametry danego URL-a.

Powyższy przykład zaimplementowalibyśmy w ten sposób:

server.route({  
    method: 'GET',
    path: '/users/{name}',
    handler(request, reply) {
        reply(request.params.name);
    }
});

Po otwarciu adresu http://localhost:3000/users/Michal naszym oczom powinno ukazać się wpisane imię, tutaj: “Michal”.

Wyświetlenie wartości request.params.name w ten sposób otwiera podatność na atak XSS. W kolejnych przykładach będę korzystał z funkcji encodeURIComponent, aby ten problem wyeliminować.

Parametry opcjonalne

server.route({  
    method: 'GET',
    path: '/users/{name?}',
    handler(request, reply) {
        if (request.params.name) {
            reply(encodeURIComponent(request.params.name));
        } else {
            reply('Anonymous');
        }
    }
});
Powyższy przykład zadziała zarówno dla ścieżki /users jak i /users/Michal.

Parametry fragmentaryczne

Możemy również zawrzeć w parametrze tylko część segmentu ścieżki, np.:

path: '/photos/{name}.jpg'

Spowoduje, że wykonaniu GET /photos/michal.jpg pod zmienną request.params.name będzie tylko wartość “michal”.

Parametry wielosegmentowe

path: '/users/{name*2}'

Zadziała np. dla żądania GET /users/jan/kowalski. Zawartością zmiennej request.params.user będzie “jan/kowalski”.

Parametry catch-all

Czasami zachodzi również potrzeba przechwycenia po prostu całej ścieżki, którą wpisze użytkownik, niezależnie jak długa by ona nie była. W HapiJS możemy to zrobić z łatwością:

path: '/users/{name*}'

Zadziała zarówno dla GET /users/jan, GET /users/jan/kowalski jak i GET /users/jan/kowalski/123/abc/def.

Obsługa query string w HapiJS

HapiJS obsługuje i automatycznie zamienia na obiekt również query string. Mamy do niego dostęp poprzez request.query. Spróbujmy:
server.route({  
  method: 'GET',
  path: '/search',
  handler(request, reply) {
    reply(request.query);
  }
});
Po wywołaniu GET /search?text=node.js&page=2&lang=pl wyświetli nam się w przeglądarce taki obiekt:

Walidacja żądań

Widzimy, że Hapi daje nam pewne możliwości parametryzacji ścieżek i zapytań. Czy jednak możemy jakoś konkretnie sprecyzować, że oczekujemy aby parametr {id} był liczbą, a parametr {name} literami? Albo, czy możliwe jest, żeby tylko wybrane pola z query string były akceptowane, a inne nie? Tak! Z pomocą przychodzi nam paczka Joi.

Joi

Joi jest biblioteką służącą do walidacji struktury obiektów zgodnie z podanymi parametrami. Między innymi, bo ponadto umożliwia również automatyczną zmianę nazw lub ignorowanie nieznanych pól, co jest bardzo przydatne przy zwracaniu odpowiedzi w formacie JSON. O tym kiedy indziej, a na razie przyjrzymy się Joi bliżej. Instalujemy:

npm install joi --save

I dodajemy na samej górzej naszego pliku:

const Joi = require('joi');  

Walidacja w HapiJS

Teraz możemy przystąpić do konfigurowania walidacji w Hapi. Weźmy jeden z poprzednich przykładów, ścieżkę /users/{name?}. Z jakiegoś powodu chcemy, aby name zawierało w sobie wyłącznie liczby. Możemy to osiągnąć dodając nowe pole config.validate.params.name do definicji route’a:
server.route({  
  method: 'GET',
  path: '/users/{name?}',
  handler(request, reply) {
    if (request.params.name) {
      reply(encodeURIComponent(request.params.name));
    } else {
      reply('Anonymous');
    }
  },

// dodajemy: config: { validate: { params: { name: Joi.number() } } } });

Teraz zapytanie GET /users/michal zakończy się błędem, natomiast GET /users/11 spowoduje wyświetlenie odpowiedzi. Jakiekolwiek zapytanie, które nie przejdzie walidacji automatycznie zostanie odrzucone z kodem błędu 400.

Złożona walidajca w Joi

W podobny sposób możemy dokonać również bardziej skomplikowanej walidacji. Wróćmy do endpointa /search. Chcemy, aby użytkownik zawsze musiał podać w query string pole text będące stringiem. Natomiast pola page i lang powinny być opcjonalne. page niech zawiera tylko liczby, a lang wyłącznie wybrane kody krajów: pl, gb lub de.
pole obowiązkowe? dozwolone wartości
text string
page liczba
lang tylko: pl, gb lub de
Zbudujmy odpowiednią regułę w Joi:
text: Joi.string().required()  
page: Joi.number()  
lang: Joi.only(['pl', 'gb', 'de'])  
Łącznie wygląda to tak:
server.route({  
  method: 'GET',
  path: '/search',
  handler(request, reply) {
    reply(request.query);
  },
  config: {
    validate: {
      query: {
        text: Joi.string().required(),
        page: Joi.number(),
        lang: Joi.only(['pl', 'gb', 'de'])
      }
    }
  }
});
I tak, przykładowo, zapytanie GET /search?text=abc&lang=us zwróci nam błąd, gdyż podany kod kraju nie jest jednym z dozwolonych. Warto też zwrócić uwagę na to, jak ten błąd wygląda i co dokładnie zawiera. W zwróconej odpowiedzi, oprócz kodu i nazwy błędu, jest też bardzo opisowe pole message, które w tym przypadku zawiera taki komunikat:

child "lang" fails because ["lang" must be one of [pl, gb, de]]

Jest to bardzo zrozumiała wiadomość, którą w zasadzie moglibyśmy wyświetlić użytkownikom (po jakimś sformatowaniu). Dodam jeszcze, że Joi posiada także możliwość definiowania własnych wiadomości o błędach.

Wartości domyślne

W powyższym przykładzie bardzo przydałaby się możliwość podania wartości domyślnych, skoro page i lang są opcjonalne. Joi również posiada taką opcję:
page: Joi.number().default(1)  
lang: Joi.only(['pl', 'gb', 'de']).default('pl')  

Więcej opcji…

Biblioteka Joi daje name ogromne możliwości wpływania na kształt odbieranych i wysyłanych obiektów, a jej integracja z frameworkiem HapiJS jest doskonała.

Nie ma tutaj jednak miejsca na to, abym opisał wszystkie opcje konfiguracji Joi. Żądnych wiedzy odsyłam do dokumentacji Joi. Długiej, bo i opcji jest ogrom!

Przykładowy projekt

Posiedliśmy już wiedzę wystarczającą do wykonania prostej aplikacji w node.js z wykorzystaniem HapiJS. Chcielibyśmy, aby użytkownik miał możliwość dodawania nowych osób do swojej książki kontaktów. Każda osoba będzie składała się z imienia i nazwiska. Oczywiście, dodamy również opcję podejrzenia wszystkich kontaktów. Potrzebujemy do tego tylko kilku endpointów:
GET /contacts wyświetlenie kontaktów
POST /contacts dodanie nowego kontaktu
Dla uproszczenia, każdy kontakt będzie się składał tylko z imienia i nazwiska:
{
    name: 'Jan',
    surname: 'Kowalski'
}

Implementacja

Najpierw tworzymy tablicę na kontakty. Aby nie odbiegać za mocno od tematu, na razie będą one przechowywane tak po prostu, w tablicy, a nie w bazie danych:
const contacts = [];  

Ze względu na to, że kontakty przechowywane są w tablicy w pamięci, będą one dostępne w naszej aplikacji tylko do ponownego uruchomienia serwera.

Definiujemy zaprojektowane przed chwilą endpointy. Na pierwszy ogień idzie GET /contacts, gdyż ma po prostu zwracać kontakty:
server.route({  
  method: 'GET',
  path: '/contacts',
  handler(request, reply) {
    reply({
      contacts
    });
  }
});
POST /contacts musi pobrać przesłane dane i dodać je do tablicy. Do tego wymagana jest krótka walidacja. Na koniec zwracamy odpowiedź z kodem HTTP 201, który oznacza pomyślne utworzenie kontaktu:
server.route({  
  method: 'POST',
  path: '/contacts',
  config: {
    validate: {
      payload: Joi.object({
        contact: Joi.object({
          name: Joi.string().required(),
          surname: Joi.string().required()
        }).required()
      })
    }
  },
  handler(request, reply) {
    const contact = request.payload.contact;
    contacts.push(contact);
    reply({contact}).code(201);
  }
});
Korzystamy tutaj z request.payload, o którym wcześniej nie wspominałem. Jest to po prostu body wysłanego żądania POST.

Testowanie backendu

I już! Utworzyliśmy pierwszy w pełni funkcjonalny backend do aplikacji. Jak go przetestować? O bardziej zaawansowanych metodach opowiem w jednym z kolejnych wpisów, a na razie zróbmy to po prostu z konsoli przeglądarki.

Otwieramy adres http://localhost:3000/contacts – powinniśmy zobaczyć odpowiedź z serwera. Na razie jest to obiekt z pustą tablicą:

W konsoli przeglądarki wykonujemy proste żądanie POST aby dodać nowy kontakt:

fetch('/contacts', {  
    method: 'POST',
    body: JSON.stringify({  
        contact: { name: 'Jan', surname: 'Kowalski' }  
    })
});

Teraz po odświeżeniu strony zobaczymy dodane kontakty!

Przy okazji podpowiedź: Warto zainstalować do swojej przeglądarki rozszerzenie formatujące JSON. Wszystko staje się wtedy znacznie bardziej czytelne:

Podsumowanie

Uff, dzisiaj spora dawka wiedzy za nami. Nauczyliśmy się obsługiwać parametry w ścieżkach oraz query string. Poznaliśmy bibliotekę Joi i nauczyliśmy się ją wykorzystywać do walidowania zapytań do naszego API. Zwieńczeniem lekcji było stworzenie backendu do prostej aplikacji w node.js.

Cały kod dostępny jest na moim GitHubie: https://github.com/mmiszy/hapijs-tutorial/tree/czesc-2

W kolejnym wpisie wykorzystamy Joi do zmieniania kształtu danych, które są zwracane przez nasze API. Ponadto, zmienimy trochę strukturę naszej aplikacji i podzielimy plik index.js na logiczne fragmenty.

👉  Znalazłeś/aś błąd?  👈Edytuj ten wpis na GitHubie!

Autor