Skocz do treści

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

Joi — walidacja danych

Walidacja danych to bardzo ciekawy i rozbudowany temat, a ja znalazłem swoją ulubioną paczkę do tego :) Ten wpis poświęcam w 100% bibliotece Joi. Wbrew pozorom, nie jest to wcale tak banalna sprawa, jakby się mogło wydawać! Joi służy do walidacji danych w Node.js. Można jej używać z dowolnym frameworkiem, ale, co dla mnie istotne, jest mocno zintegrowana z HapiJS!

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

Zdjęcie Michał Miszczyszyn
JavaScript22 komentarze

O Joi…

Dość nietypowo: wpis zaczynam od podsumowania. Chcę w ten sposób zachęcić Cię do bliższego poznania Joi, bo możliwości, które daje ta biblioteka są praktycznie nieograniczone. Gdy pierwszy raz zacząłem jej używać byłem pod ogromnym wrażeniem jak łatwe jest budowanie nawet najbardziej skomplikowanych zasad walidacji i nie tylko…

A więc:

  • Joi to biblioteka do walidacji danych — sprawdza czy dane pasują do podanego formatu
  • Joi może być bez problemu używana z dowolnym frameworkiem (mimo że jest ściśle zintegrowane z HapiJS)
  • Możesz użyć Joi w HapiJS do automatycznej walidacji elementów żądań: nagłówków, querystring, parametrów czy ciała (body)
  • Możesz także walidować swoje odpowiedzi, aby np. uniknąć przesłania do użytkownika danych, które powinny pozostać tajne
  • Joi daje łatwą możliwość precyzowania własnych komunikatów błędów

Zainteresowana/y? To jeszcze nie wszystko 😍 Ale zacznijmy od początku… Jeśli natomiast nie chce Ci się czytać to zapisz się na szkolenie z Hapi i Joi.

Walidacja w ogóle

Walidacja danych pochodzących od użytkownika (żądań) ma zasadniczo dwa cele. Po pierwsze pomaga programiście upewnić się, że użytkownik podaje prawidłowe dane, aby aplikacja mogła dalej działać. Przykładowo jeśli w formularzu oczekiwany jest wiek, a użytkownik wpisze tam rok urodzenia, Joi może taki przypadek wykryć, dzięki czemu aplikacja nie zapisze niepoprawnych danych. Jest tutaj też ogromna korzyść dla użytkownika: Dostaje jasny komunikat o błędzie i wie dokładnie co musi poprawić.

Drugi aspekt to bezpieczeństwo aplikacji. Walidacja pozwala Tobie (programiście) upewnić się, że gdy ktoś będzie próbował złośliwie przekazać spreparowaną treść żądania, to aplikacja nie zachowa się w sposób niebezpieczny — np. nie ujawni tajnych danych, albo nie pozwoli na atak SQL Injection lub inny. To ryzyko można w dużej mierze ograniczyć dzięki poprawnie skonfigurowanej i możliwie jak najbardziej ścisłej walidacji.

Joi idealnie sprawdza się do obu przypadków.

Joi w Node.js

Joi może być używana w samodzielnie, bez HapiJS. Praca z Joi sprowadza się do dwóch kroków:

  1. Stworzenie schema, czyli obiektu, który opisuje Twoje oczekiwania wobec danych.
  2. Sprawdzenie czy przychodzące dane pasują do danej schema’y.

Najprostszy możliwy przykład. Oczekujemy, że zmienna zawiera wiek — czyli liczbę pomiędzy 18 a 130 😉 Tak wygląda schema w Joi:

const schema = Joi.number().min(18).max(130);

Natomiast sam proces walidacji sprowadza się do wywołania jednej funkcji:

const age = 20;
Joi.assert(age, schema);

Gdyby do stałej age przypisać liczbę poniżej 18 lub powyżej 130, wywołanie Joi.assert zakończyłoby się rzuceniem przez bibliotekę wyjątku:

const age = 12;
Joi.assert(age, schema); // Error!

Wow, to było łatwe, prawda? Joi zawiera analogiczne, proste i intuicyjne metody dla wielu innych typów, a między innymi dla number, string, array, date czy object. Przykład dla obiektu:

const userSchema = Joi.object().keys({
  id: Joi.number().required(),});

Dla ułatwienia, można też nie pisać Joi.object().keys(…), wystarczy tylko sam literał obiektu. Jeszcze bardziej rozbudowany przykład:

const userSchema = {
  id: Joi.number().required(),
  name: Joi.string().required(),
  address: Joi.string(),
  language: Joi.string().valid(['pl', 'en']).required()
};

Ostatnie pole w obiekcie, language, ma ściśle zdefiniowane poprawne wartości. W przypadku błędu, automatycznie generowana informacja zwrotna będzie bardzo pomocna:

[1] language must be one of [pl, en]

Zwróć też uwagę, że domyślnie wszystkie pola w obiekcie są opcjonalne. Aby to zmienić, dodaję do schema wywołanie metody .required().

Sprawdzanie czy dane pasują do schema'y

Pokazałem jeden sposób walidowania danych — poprzez wywołanie funkcji Joi.assert, która rzuca wyjątek w wypadku błędu. W wielu miejscach jest to przydatne, bo, przykładowo, HapiJS automatycznie taki wyjątek wychwyci i zwróci odpowiedź z błędem do użytkownika.

Jednak wyjątek to nie zawsze to czego możesz chcieć, ale na szczęście Joi oferuje też drugą metodę: Joi.validate(). Przyjmuje ona 3 argumenty: dane, schema i funkcję (callback w stylu Node).

Joi.validate(age, schema, (err, val) => {
  if (err) {
    console.log('Walidacja się nie udała!');
  } else {
    console.log('Wszystko w porządku.');
  }
});

Warto pamiętać, że Joi.validate wywołuje callback synchronicznie.

Joi a typy

Jeśli zaczęłaś/zacząłeś się już bawić z Joi to mogłaś/eś zauważyć, że string '123' zostanie prawidłowo zwalidowany przez Joi jako liczba. Czy to bug?! Nie! Joi.number() waliduje JavaScriptowe liczby, ale także coś co wygląda jak liczba, czyli na przykład string '567'. Czy to pożądane zachowanie?

Załóżmy, że chcesz zwalidować querystring — czyli parametry dopisywane po adresie w postaci mojadres.com?param1=abc&param2=123. Domyślnie wszystkie są traktowane przez Node jako stringi — czyli po sparsowaniu otrzymujesz coś na kształt obiektu { param1: 'abc', param2: '123' }. Jednak przecież wyraźnie drugi parametr jest liczbą!

Joi potrafi obsłużyć tę sytuację prawidłowo. Co więcej — po zwalidowaniu automatycznie dokonuje konwersji tego stringa na liczbę:

const schema = Joi.number();
const kindOfNumber = '123';
Joi.validate(kindOfNumber, schema, (err, value) => {
  // value tutaj jest już liczbą!
  console.log(typeof value); // 'number'
});

Taka konwersja typów jest również bardzo przydatna w przypadku dat. Daty przekazywane z przeglądarki do Node zawsze są stringami (lub liczbą w postaci timestampa). Joi potrafi automatycznie sparsować i przekonwertować te formaty na obiekt Date — a więc Ty możesz operować już bezpośrednio na datach 😎

Opcje Joi

convert

Jeśli powyższe zachowanie jest niepożądane to, hej, Joi można dodatkowo konfigurować ;) Możesz to zmienić na poziomie schema'y:

const schema = Joi.number().options({ convert: false });

Lub bezpośrednio przy wywołaniu Joi.validate

Joi.validate(kindOfNumber, schema, { convert: false }, (err, value) => {
  // błąd!
});

abortEarly

Drugim zachowaniem, o którym do tej pory jakoś nie miałem okazji wspomnieć, jest to, że Joi zaprzestaje walidacji po pierwszym napotykanym błędzie. Jest to mądre podejście, bo nie ma sensu niepotrzebnie męczyć serwera — jeśli dane są niepoprawne to najczęściej chcesz jak najszybciej przestać się nimi zajmować i zwrócić błąd:

const schema = {
  id: Joi.number(),
  name: Joi.string(),
};

const clearlyNotUser = {
  id: 'lol',
  name: 12,
};

Joi.validate(clearlyNotUser, schema, (err, value) => {
  // błąd! Ale obiekt err zawiera informacje tylko o niepoprawnym id
});

Czasem może to być niepożądane. Wyobraź sobie, że dokonujesz walidacji formularza, który uzupełnia użytkownik. Nie chcesz go męczyć i zwracać mu błędy po kolei, jeden po jednym, prawda? Lepiej byłoby zwrócić wszystkie błędy na raz, aby użytkownik mógł poprawić wszystkie błędne pola. Pomoże w tym opcja abortEarly:

Joi.validate(clearlyNotUser, schema, {abortEarly: false}, (err, value) => {
  // błąd! Obiekt err zawiera informację o obu błędach
});

stripUnknown

Wspomniałem już gdzieś o tym, że Joi może Ci pomóc zabezpieczyć zwracane dane przed przypadkowym przesłaniem do użytkownika pól, które nigdy nie powinny ujrzeć światła dziennego. Wyobraź sobie taki scenariusz: W bazie danych przechowujesz użytkowników, ich imiona, nazwiska, ale też maile i hashe haseł. Każdy użytkownik może podejrzeć imiona innych użytkowników. Ale przypadkiem popełniłaś/eś błąd i Twoje API zwraca wszystkie dane na temat każdego użytkownika:

const users = [
  { name: 'Michal', password: 'abcdefghijklmnopqrstu', email: '[email protected]' },];

Ups! Tej sytuacji można było uniknąć poprzez odpowiednie filtrowanie danych i usunięcie niepotrzebnych pól — ale to kiepska opcja, bo jeśli w przyszłości do obiektu użytkownika dojdą inne dane, których też nie chcemy zwracać, to będziemy musieli pamiętać, aby zmodyfikować ten kod, który dane filtruje. Zdecydowanie lepsza byłaby biała lista. Można by też po prostu pobierać z bazy tylko imiona. Jasne :) Ale załóżmy, że ignorujemy na razie taką możliwość, albo chcemy mieć też drugą warstwę bezpieczeństwa. Pomoże nam w tym Joi!

Wystarczy użyć opcji stripUnknown:

const users = [];

const usersNameResponseSchema = Joi.array().items({
  name: Joi.string(),
});

Joi.validate(users, usersNameResponseSchema, { stripUnknown: true }, (err, value) => {
  console.log(value) // [ { name: 'Michal' } ]
});

Jak widzisz, Joi może pomóc zarówno przy bezpieczeństwie danych wejściowych, jak i wyjściowych. Jeśli spytasz mnie o zdanie — stripUnknown to moja ulubiona opcja.

Definiowanie schema

Teraz chciałbym na konkretnych przykładach pokazać jak ja definiuję swoje schema’y. Nie wiem czy to najlepszy sposób, ale na pewno dobry i przetestowany w boju ;) Więc do dzieła!

Po pierwsze: Schema’y można deklarować raz a używać w wielu miejscach. Co mam na myśli? No na przykład wyobraź sobie, że chcesz napisać schema’y dla żądań pobrania, dodania i edycji użytkowników (np. GET, POST, PUT). Zacznij od zdefiniowania kształtu obiektu użytkownika:

const userSchema = Joi.object({
  name: Joi.string().required(),
  language: Joi.string().valid(['pl', 'en']).required(),
  address: Joi.string().optional().allow(''), // pusty string jest też poprawny
});

Następnie stwórz schema’y opisujące kolejne żądania i odpowiedzi:

const userWithIdSchema = userSchema.keys({ id: Joi.number().required() });

const createUserRequestSchema = userSchema;
const createUserResponseSchema = userWithIdSchema;

const updateUserRequestSchema = userWithIdSchema;
const updateUserResponseSchema = userWithIdSchema;

const getUserResponseSchema = Joi.array().items(userSchema);

W ten sposób raz zadeklarowany kształt obiektu użytkownika został rozszerzony (o id) i użyty wielokrotnie w różnych kontekstach (np. w odpowiedzi na GET zwracana jest tablica użytkowników). Dzięki takiemu podejściu późniejsze zmiany w obiekcie użytkownika będą wymagały modyfikacji schema’y tylko w jednym miejscu.

Nota poboczna: Fani TypeScripta pewnie teraz zastanawiają się czy to oznacza, że trzeba napisać schema i potem jeszcze interface lub type dla każdego z żądań i każdej odpowiedzi? Niestety — tak. Istnieje jednak skrypt, który pozwala to zautomatyzować i aktualnie go testuję. Jednak jest to bardzo testowa wersja, a autor ogłosił, że porzucił pracę nad nim. Jeśli jednak ostatecznie uznam ten skrypt za przydatny to możliwe, że sam przejmę jego dalszy rozwój.

Prosta logika w Joi

Ja przenoszę do Joi niektóre elementy (te proste) logiki aplikacji. Na przykład gdy użytkownik się rejestruje to proszę go o dwukrotne podanie hasła, a następnie na backendzie sprawdzam czy oba hasła są takie same. To częsty scenariusz, prawda? Napisanie tego fragmentu kodu jest proste, ale jednocześnie wydaje się wtórne. Na szczęście Joi potrafi dokonać również takiej walidacji!

Aby móc porównywać pola pomiędzy sobą musisz nadać im jakieś identyfikatory (nazwy), a potem odwołać się do nich przez funkcję ref:

const signUpRequestSchema = {
  login: Joi.string().required(),
  password: Joi.string().required(),
  repeatPassword: Joi.any().valid(Joi.ref('password')).required()
};

W ten sposób jeśli password i repeatPassword nie będą takie same to Joi zwróci błąd.

language

Niestety błąd ten nie jest zbyt przyjazny dla użytkownika:

"repeatPassword" must be one of [ref:password]

Szczęśliwie, Joi pozwala również na całkowitą zmianę treści błędów:

const signUpRequestSchema = {
  login: Joi.string().required(),
  password: Joi.string().required(),
  repeatPassword: Joi.any().valid(Joi.ref('password')).required().options({ language: { any: { allowOnly: 'must match password' } } })
};
"repeatPassword" must match password

W ten sposób nadpisałem komunikat dla tego konkretnego błędu. Zaletą opcji language jest to, że wszystkie komunikaty błędów można by np. zapisać sobie w pliku JSON i wczytać warunkowo — np. zależnie od języka użytkownika. Komunikaty można znaleźć tutaj: https://github.com/hapijs/joi/blob/master/lib/language.js

Możliwości budowania relacji i logiki jest jeszcze więcej, ale w tym wpisie nie ma miejsca abym opisał wszystko :) Joi pozwala także na definiowanie pól wzajemnie wykluczających się albo alternatyw (and, or itp.) Więcej w dokumentacji! https://github.com/hapijs/joi/blob/master/API.md

Własne walidatory

Jeśli powyższe możliwości to nadal dla Ciebie za mało to, nie bój nic, Joi pozwala na definiowanie całkowicie dowolnych walidatorów i konwerterów! Co to takiego? Walidator sprawdza poprawność danych, a konwerter zmienia typ (jak w przykładzie z Joi.number() i stringiem '123').

Konkretny przykład, prawdziwy, z aplikacji, nad którą pracuję. Umożliwiam użytkownikom zaznaczenie kilku elementów w aplikacji i kliknięcie guzika „pobierz”. Wtedy do API idzie request z listą ID elementów oddzielonych przecinkami (aby zapis był krótki, bo tych ID może być sporo):

https://example.typeofweb.com/download?ids=1,2,15,66

Chciałbym dokonać walidacji parametru ids. Ma to być ciąg liczb oddzielonych od siebie przecinkami. Dodatkowo chciałbym, aby Joi dokonał konwersji tego ciągu na tablicę liczb. Czy jest to możliwe? Ależ tak! Wystarczy zadeklarować własne metody:

const JoiCommaDelimited = Joi.extend({
  name: 'commaDelimited',
  base: Joi.string(),
  language: {
    items: '{{error}}',
  },
  pre(value, _state, _options) {
    return value.split(',');
  },
  rules: [{
    name: 'items',
    params: {
      items: Joi.any()
    },
    validate(params, value, state, options) {
      const validation = Joi.array().items(params.items).validate(value);
      if (validation.error) {
        return this.createError('commaDelimited.items', { error: validation.error }, state, options);
      } else {
        return validation.value;
      }
    }
  }]
});

Na spokojnie przeanalizuj ten kod — nie jest taki straszny jak się początkowo wydaje :) Jego wykorzystanie:

const schema = {
  items: JoiCommaDelimited.commaDelimited().items(Joi.number())
};

Joi.validate({items: '1,2,3,50'}, schema, (err, value) => {
  console.log(value) // { items: [ 1, 2, 3, 50 ] }
});

W ten sposób niezależnie od tego czy jest to ids=1 czy ids=1,2,55 — zawsze otrzymuję tablicę liczb. Ponadto, gdyby ktoś próbował przekazać tam coś innego niż liczbę np. ids=ab,2,c Joi zwróci błąd! Pięknie :)

Podsumowanie

Umiesz już dokonywać nawet bardzo skomplikowanej walidacji dzięki Joi. Potrafisz definiować kształt obiektów, tworzyć prostą logiką, a także korzystać z zaawansowanych możliwości definiowania własnych walidatorów. Czy już lubisz Joi? Jestem przekonany, że zgodzisz się ze mną, że jest to niezwykle przyjemna biblioteka, która na pewno przypadnie Ci do gustu 👍

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

Autor