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 |
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 zapytanieGET /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 poprzezrequest.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:
Teraz zapytanieserver.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() } } } });
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 |
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, skoropage
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 |
{
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ń idzieGET /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.