Moduł 2 - Zajęcia 4 - Rest API

1.1 Przykład aplikacji

Po części teoretycznej przejdźmy do praktyki i stworzymy prostą aplikację opartą o express.

Framework Express zapewnia swój generator aplikacji https://expressjs.com/en/starter/generator.html . Generator zorientowany jest na architekturę aplikacji MVC i tworzy następującą strukturę katalogów:

Aby zainstalować template należy użyć polecenia:

            npx express-generator --view=ejs simple-express
          

npx - narzędzie, które jest już w systemie, jeśli zainstalowany został Node.js w wersji wyższej niż 8.x. Pozwala ono wykonywać polecenia innych narzędzi, nie instalując ich globalnie w systemie.

Dalej wskazujemy, że chcemy wykorzystać szablony ejs parametrem --view=ejs

Jako ostatni parametr wskazujemy nazwę aplikacji (i folderu) simple-express.

Aplikacja znajduje się w pliku app.js. Pierwsze, co powinniśmy zrobić, to zmienić var na const w całej aplikacji. Po tej operacji plik app.js powinien wyglądać następująco:

          const createError = require('http-errors');
          const express = require('express');
          const path = require('path');
          const cookieParser = require('cookie-parser');
          const logger = require('morgan');

          const indexRouter = require('./routes/index');
          const usersRouter = require('./routes/users');

          const app = express();

          // view engine setup
          app.set('views', path.join(__dirname, 'views'));
          app.set('view engine', 'ejs');

          app.use(logger('dev'));
          app.use(express.json());
          app.use(express.urlencoded({ extended: false }));
          app.use(cookieParser());
          app.use(express.static(path.join(__dirname, 'public')));

          app.use('/', indexRouter);
          app.use('/users', usersRouter);

          // catch 404 and forward to error handler
          app.use(function (req, res, next) {
            next(createError(404));
          });

          // error handler
          app.use(function (err, req, res, next) {
          // set locals, only providing error in development
            res.locals.message = err.message;
            res.locals.error = req.app.get('env') === 'development' ? err : {};

          // render the error page
            res.status(err.status || 500);
            res.render('error');
          });

          module.exports = app;
        

Na początku podłączone są wszystkie niezbędne pakiety, potrzebne do działania aplikacji.

Następnie zażądane są moduły zawierające routing.

          const indexRouter = require('./routes/index');
          const usersRouter = require('./routes/users');
        

Później tworzymy egzemplarz aplikacji i ustawiamy wykorzystanie szablonów ejs.

          const app = express();

          // view engine setup
          app.set('views', path.join(__dirname, 'views'));
          app.set('view engine', 'ejs');
        

Następnie pojawią się podłączenia middleware

          app.use(logger('dev'));
          app.use(express.json());
          app.use(express.urlencoded({ extended: false }));
          app.use(cookieParser());
        

Podłącza się logger, opracowywanie JSON i danych z formularzy, a na koniec moduł do pracy z cookie.

Dalej dodawane jest opracowywanie zasobów statycznych z folderu public:

          app.use(express.static(path.join(__dirname, 'public')));
        

Następnie mamy podłączenie routerów w aplikacji:

          app.use('/', indexRouter);
          app.use('/users', usersRouter);
        

Pamiętaj, że porządek podłączanego programu pośredniczącego ma znaczenie. Na końcu aplikacji pojawia się opracowywanie błędów. Najpierw zachodzi opracowywanie nieistniejącej ścieżki czyli klasyczny błąd 404.

          app.use(function (req, res, next) {
            next(createError(404));
          });
        

Następnie z kolei mamy handler błędów które zostaną "wyrzucone" podczas obsługi ścieżek.

          app.use(function (err, req, res, next) {
          // set locals, only providing error in development
            res.locals.message = err.message;
            res.locals.error = req.app.get('env') === 'development' ? err : {};

          // render the error page
            res.status(err.status || 500);
            res.render('error');
          });
        

Tutaj zachodzi opracowywanie błędu. Podajemy zmienne message i error do szablonu error.ejs i renderujemy go.

Wewnątrz folderu z naszą aplikacją trzeba zainstalować wszystkie pakiety z pliku package.json poprzez polecenie:

            npm i
          

Teraz dla ułatwienia sobie pracy zainstalujemy pakiet nodemon. Pozwala on wykonywać live reload serwera w trakcie pracy nad kodem. Dodamy wymaganą zależność do devDependencies:

            npm i nodemon -D
          

Następnie w pliku package.json dla uruchomienia aplikacji w trybie deweloperskim dodajemy skrypt start:dev:

            "scripts": {
              "start": "node ./bin/www",
              "start:dev": "nodemon ./bin/www"
            },
          

Uruchomienie aplikacji w trybie deweloperskim będzie następowało przez polecenie:

            npm run start:dev
          

Po uruchomieniu, aplikacja powinna wyglądać następująco po przejściu na adres [localhost:3000](http://localhost:3000) w przeglądarce:

Aplikacja wykonuje renderowanie swojego jedynego szablonu. Samo renderowanie wykonuje się w pliku routingu routes/index.js.

            router.get('/', (req, res, next) => {
              res.render('index', { title: 'Express' });
            });
          

Przyszedł czas na zmianę naszej aplikacji - dodamy formularz, abyśmy mogli przyjąć dane od użytkownika. Plik index.ejs powinien teraz wyglądać tak:

            <!DOCTYPEhtml>
            <html>
              <head>
                <title><%= title %></title>
                <link rel="stylesheet" href="/stylesheets/style.css" />
              </head>
              <body>
                <form action="/login" method="POST">
                  <label for="email">Email</label>
                  <input type="text" name="email" id="email" />
                  <label for="password">Hasło</label>
                  <input type="password" name="password" id="password" />
                  <button type="submit">Zaloguj się</button>
                </form>
              </body>
            </html>
          

Dla lepszego wyglądu dodamy następujące style do pliku public/stylesheets/style.css.

            form {
              display: flex;
              flex-direction: column;
              width: 400px;
            }

            input,
            button {
              margin-bottom: 15px;
            }
          

Potrzebny jest nam program opracowywania dla ścieżki /login, do którego będą przychodzić dane z formularza. Dodajmy go, ale najpierw potrzebujemy nowy szablon response.ejs, gdzie będziemy pokazywać dane z formularza.

            <!DOCTYPEhtml>
            <html>
            <head>
              <title><%= title %></title>
              <link rel='stylesheet' href='/stylesheets/style.css' />
            </head>
            <body>
              <p>Email: <%= email %></p>
              <p>Password: <%= password %></p>
              <a href='/'>Wróć do strony główej</a>
            </body>
            </html>
          

Teraz w pliku routingu dodamy program opracowywania ścieżki /login.

            router.post('/login', (req, res, next) => {
              const { email, password } = req.body;
              res.render('response', { title: 'Simple express app', email, password });
            });
          

Jest dość uproszczony. Przyjmujemy w nim tylko dwie zmienne i przekazujemy je do renderowania szablonu response.ejs, aby pokazać, że dane zostały otrzymane. Jeżeli wszystko zostało wykonane prawidłowo, wtedy przy wysyłaniu formularza będziemy po prostu widzieli, co wysłaliśmy na serwer

Ten przykład pokazuje przekazanie danych z frontendu do backendu, wykorzystując formularz.

Teraz w pliku routingu user.js dodamy następujący obiekt z kontaktami:

            const express = require('express');
            const router = express.Router();
            const contacts = [
              { id: '1', username: 'Felix', surname: 'Brown', email: 'felix@test.com' },
              { id: '2', username: 'Sonya', surname: 'Redhead', email: 'sonya@test.com' },
              { id: '3', username: 'Conan', surname: 'Barbarian', email: 'conan@test.com' },
            ];
            /* GET users listing. */
            router.get('/', function (req, res, next) {
              res.json(contacts);
            });

            module.exports = router;
          

Jeżeli zwrócimy się po ścieżce /users , serwer powinien odesłać nam JSON z tablicą kontaktów.

Dla lepszego odczytywania plików JSON w przeglądarce można wykorzystać następującą aplikację: https://github.com/callumlocke/json-formatter. Dostępna jest ona także jako rozszerzenie dla Chrome. Gdy je zainstalujesz, zawsze będziesz widział dane JSON w czytelnej wersji.

Dodajmy program opracowywania dla otrzymania konkretnego użytkownika zgodnie z jego identyfikatorem:

            router.get('/:id', function (req, res, next) {
              const { id } = req.params;
              const contact = contacts.filter(el => el.id === id);
              res.json(contact);
            });
          

Teraz po zwróceniu się do url /users/2 otrzymujemy dane Rudej Soni:

Taki sposób przekazywania danych będziemy najczęściej wykorzystywać do redagowania i usuwania konkretnego podmiotu zgodnie z jego unikalnym identyfikatorem.

Z pełnym kodem wskazanego przykładu można zapoznać się tutaj: https://glitch.com/~simple-express-nodebook

2.1 Zmienne środowiskowe

Gdy zaczynamy pisać aplikację webową, prędzej czy później spotkamy się z potrzebą wykorzystania zewnętrznych źródeł danych, serwisów, danych kont API i tak dalej. Dostęp do tych źródeł prawie zawsze realizowany jest przy pomocy tajnych kluczy. Jest to spory problem, gdy kod aplikacji jest przekazywany do ogólnodostępnego repozytorium, takiego jak GitHub. Kod dostępny jest dla wszystkich, którzy mają do niego dostęp, co oznacza, że nasze tajne klucze również są widoczne. Nawet prywatne repozytoria mogą nie być do końca bezpieczne.

Jak rozwiązywany jest ten problem? Dobrą praktyką jest wykorzystanie zmiennych środowiskowych. To innymi słowy lokalne zmienne, które są dostępne dla naszej aplikacji, ustalane podczas jej uruchamiania.

Dostęp do tych zmiennych często realizowany jest przy pomocy modułu dotenv. Paczka ta ładuje zmienne środowiskowe z pliku .env, który należy utworzyć np. w głównym katalogu naszej aplikacji. Następnie podłączamy moduł do naszej aplikacji, po czym dodaje on zmienne środowiskowe do obiektu process.env, przy użyciu którego możemy wykorzystać je w aplikacji. Sam plik .env powinniśmy dodać do pliku .gitignore aby przez przypadek nie znalazł się w repozytorium.

Na początku zainstalujmy pakiet.

            npm install dotenv
          

Teraz dodajmy do pliku app.js następującą instrukcję.

            require('dotenv').config();
          

Następnie tworzymy plik .env w katalogu root naszej aplikacji i dodajemy do niego zmienne.

            SECRET_KEY=123456
            NODE_ENV=development
          

Teraz w pliku aplikacji app.js dostępne będą wszystkie zmienne, które dodajemy do pliku .env. Przykłady powyżej, dostępne są teraz w aplikacji w następujący sposób:

            process.env.SECRET_KEY;
            process.env.NODE_ENV;
          

Dalej będziemy wykorzystywać zmienne środowiskowe do dostępu do tajnych danych, takich jak klucze szyfrujące dla cookie lub jwt, url podłączenia do bazy danych i inne.

3.1 Logowanie

W prawie każdej aplikacji niezbędne jest odnotowywanie zapytań do serwera. Generator aplikacji wykorzystuje w tym celu moduł morgan — elastyczny komponent pośredniczący do logowania informacji o zapytaniach z możliwością ustawienia formatu.

Aby wykorzystać ten moduł należy skorzystać z następujących instrukcji:

            const logger = require('morgan');
            ...
            app.use(logger('dev'))
          

Przy tworzeniu middleware, wykorzystujemy jeden z dostępnych formatów naszych logów - dev. Istnieje pięć zdefiniowanych formatów, które możemy wykorzystać

  • combined - wykorzystuje tryb combined serwera Apache:

    :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"

  • common - wykorzystuje tryb common serwera Apache:

    :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length]

  • dev - format dziennika z kolorowym kodowaniem (po statusie odpowiedzi).
    • zielony dla kodów zakończonych sukcesem,
    • czerwony dla kodów z błędami serwera,
    • żółty dla kodów z błędami klienta,
    • turkusowy dla kodów przekierowania
    • biały dla kodów informacyjnych

    :method :url :status :response-time ms - :res[content-length]

  • short krótszy odpowiednik formatu domyślnego

    :remote-addr :remote-user :method :url HTTP/:http-version :status :res[content-length] - :response-time ms

  • tiny - najkrótszy ze zdefiniowanych formatów, zawiera tylko czas odpowiedzi i kilka elementów uzupełniających.

    :method :url :status :res[content-length] - :response-time ms

Często nasze logi muszą zostać opracowane przez zewnętrzne oprogramowanie, dzięki użyciu Morgan, można tworzyć również własne formaty logów. W tym celu należy przekazać specjalny string zawierający odpowiednie markery. Na przykład przekazując następujący format :method :url :response-time ms będziemy widzieć zapisy w rodzaju:

            GET / 15 ms:
          

Domyślnie dostępne są następujące markery. Możesz również określać niestandardowe markery i dodać je do informacji wyprowadzanych przez logger.

4.1 Czym jest REST

4.2 Wprowadzenie

REST (REpresentational State Transfer) - to inaczej przekazywanie reprezentatywnego stanu. Jest to lista zasad projektowania architektury, dla zwiększenia skalowalności i elastyczności komunikacji sieciowych. Zasady te odpowiadają na szereg pytań:

  • Jakie istnieją komponenty systemu?
  • Jak komunikują się one między sobą?
  • Jak można zagwarantować możliwość zmiany i rozwinięcia różnych części systemu w dowolnym czasie?
  • Jak można skalować systemy obsługi coraz większej ilości użytkowników?

Roy Fielding po raz pierwszy wprowadził termin REST w 2000 roku w swojej pracy doktorskiej "Architectural Styles and the Design of Network-based Software Architectures". Można ją znaleźć tutaj.

W momencie publikacji dysertacji Internet był już bardzo popularny. Fielding zrobił krok do tyłu i przeanalizował cechy, które sprawiły, że Internet odniósł większy sukces niż konkurencyjne rozwiązania. Przedstawił on schematyczną strukturę, która uczyni komunikację internetową "podobną do sieci". Dlatego też, REST to ogólny zbiór zasad, niespecyficznych dla Internetu. Można je stosować do innych typów sieci, na przykład do wewnętrznych systemów. REST nie jest również protokołem, ponieważ nie definiuje on szczegółów implementacji. W pracy doktorskiej Fieldinga przedstawiony został szereg architektonicznych ograniczeń, które powinien spełniać system, aby być RESTful.

4.3 Czym jest URI i URL?

Zanim przejdziemy do tego, jakie architektoniczne ograniczenia nakłada REST, przeanalizujmy terminologię URL i URI. Terminy URI i URL często wykorzystywane są zamiennie, ale to nie do końca to samo.

::: tip URI: Reprezentuje sobą identyfikator konkretnego zasobu, jak np. strona, książka lub dokument.

::: tip URL: Reprezentuje szczególny typ identyfikatora zasobu, który zawiera również informację o tym, jak otrzymać do niego dostęp.

W zasadzie URI to szersze pojęcie i zawiera w sobie URL. Jeżeli chcemy przeprowadzić jakieś upraszczające analogie, to można założyć, że URI jest rodzajem nazwy, a URL to konkretna nazwa i sposób, jak do niej dotrzeć. Na przykład ISBN książki to URI, a https://goit.ua to URL - mamy tu nazwę oraz sposób dotarcia do niej, czyli protokół.

To oznacza, że jeżeli protokół (https, ftp itd.) jest obecny w ścieżce, to powinniśmy nazywać ją adresem URL, nawet jeśli jest ona także URI.

Tradycyjnie zapisujemy URL w formacie:

            <scheme>://[<login>[:<password>]@]<host>[:<port>]][/<path>][?<query>][#<fragment>]
          

Przykład:

            http://user:password@host.com:80/resourse/path/?query=name&ttt=123#hash
          

W tym zapisie:

  • scheme - to protokół sieciowy, poprzez który następuje zwrócenie się do zasobu;
  • login - nieobowiązkowy parametr, nazwa użytkownika wykorzystywana aby uzyskać dostęp do zasobu;
  • password - hasło dla wskazanego użytkownika, jeśli jest wymagane;
  • host - zapisana w całości domenowa nazwa hosta w systemie DNS (goit.ua) lub adres IP hosta w postaci czterech grup liczb dziesiętnych (jeśli korzystamy z formatu IPV4), rozdzielonych kropkami;
  • port - port hosta dla danego połączenia. Pamiętamy klasyczna ścieżkę do aplikacji express: http://localhost:3000 gdzie 3000 to właśnie port. Pojawia się pytanie, dlaczego port nie pokazuje się dla URL w przeglądarce. Wynika to z tego, że domyślnie, dla protokołu http port to 80, a dla https jest równy 443 i przeglądarka podstawia go za nas.
  • path - ścieżka prowadząca do miejsca znajdowania się zasobu;
  • query - łańcuch parametrów zapytania z przekazywanymi na serwer (metodą GET) parametrami. Zaczyna się od symbolu ?, następnie znajdziemy delimiter poszczególnych parametrów - znak &. Przykład: ?foo=123&baz=234&bar=value;
  • fragment - identyfikator z poprzedzającym symbolem #. Często nazywamy go kotwicą. Z takim odnośnikiem przeglądarka otworzy stronę i przewinie okno przeglądania do wskazanego elementu z odpowiednim atrybutem id, na przykład: https://example.com/#contact

4.4 Architektoniczne ograniczenia rozwiązań RESTful

Klient-serwer

Pierwsze ograniczenie wynika z tego, że sieć powinna składać się z klientów i serwerów. Serwer to komputer, który przechowuje zasoby, a klientami mogą być na przykład przeglądarki, które chcą wykorzystać zasoby przechowywane na serwerze. Gdy przeglądasz Internet, twój komputer zachowuje się jak klient i wysyła zapytania HTTP na serwer w celu uzyskania dostępu do informacji i zarządzania nimi. Każdy system RESTful powinien działać zgodnie z modelem klient-serwer, nawet jeśli dany element systemu czasem zachowuje się jak klient, a czasem jak serwer (ponieważ serwery mogą komunikować się między sobą i wtedy jeden z nich przyjmuje rolę klienta w danych połączeniu).

Alternatywą dla architektury klient-serwer, odmienną od RESTful, jest architektura na podstawie zdarzeń. W tym modelu każdy komponent nieprzerwanie transmituje zdarzenia, oczekując przy tym odpowiednich zdarzeń od innych komponentów. Nie ma bezpośredniej komunikacji, tylko transmisja i nasłuchiwanie. REST wymaga indywidualnej współpracy elementów systemu, dlatego architektura integracji na podstawie zdarzeń nie będzie RESTful.

Stateless

Brak stanu nie oznacza, że serwery i klienci nie mają swoich stanów. Oznacza to jednak, że nie mogą śledzić swoich stanów nawzajem. Gdy klient nie jest aktualnie połączony z serwerem, serwer nie wie o jego istnieniu i na odwrót. Każde zapytanie analizowane jest oddzielnie, co oznacza brak ciągłej sesji.

Jeden interfejs

Ograniczenie to gwarantuje, że istnieje wspólny język między serwerami i klientami, który pozwala wymieniać lub zmieniać część bez naruszenia pracy całego systemu. Jest to osiągalne kosztem 4 dodatkowych ograniczeń:

  • identyfikacja zasobów;
  • manipulowanie zasobami przy pomocy widoków;
  • informacyjne (samoopisujące się) wiadomości;
  • hipermedia.
Pierwsze ograniczenie interfejsu: identyfikacja źródeł

Pierwsze z tych ograniczeń wpływa na sposób identyfikacji zasobów. W terminologii REST zasobem może być cokolwiek - dokument HTML, plik, informacja o zamówieniu i tak dalej. Każdy zasób powinien być jednoznacznie identyfikowalny stabilnym identyfikatorem. "Stabilny" identyfikator oznacza, że nie zmienia się on przy interakcji z zasobem, ani nawet przy zmianie stanu zasobu. Jeżeli zasób naprawdę przemieścił się do innego identyfikatora, serwer powinien dać klientowi odpowiedź wskazującą, że zapytanie było błędne i dać mu odnośnik do nowego położenia zasobu.

Internet wykorzystuje URL do identyfikacji zasobów oraz HTTP jako standard łączności. Aby otrzymać zasób, który przechowywany jest na serwerze, klient wysyła zapytanie HTTP - GET na URL, który identyfikuje ten zasób. Za każdym razem, gdy wprowadzasz do swojej przeglądarki adres, tworzy ona zapytanie GET na podany URL. Jeżeli otrzymuje status 200 i dokument w formacie który rozumie (np.HTML), wyświetla stronę w oknie, aby można było ją przejrzeć.

Drugie ograniczenie interfejsu: manipulowanie zasobami przed widoki

Drugie ograniczenie mówi o tym, że klient zarządza zasobami, wysyłając "widoki" na serwer - powinien być to obiekt JSON lub XML, zawierający dane, który użytkownik chciałby dodać, usunąć lub zmienić. W REST serwer w pełni kontroluje zasoby i odpowiada za wszelkie zmiany. Jako zasoby mogą służyć zapisy w bazie danych, pliki i tak dalej. Gdy klient chce wprowadzić zmiany do zasobów, wysyła do serwera widok tego, jak powinien wyglądać otrzymany zasób. Serwer przyjmuje zapytanie jako propozycję, ale ma pełną kontrolę i sam podejmuje działanie w zależności od zdefiniowanych zasad.

Najprostszy przykład to blog. Gdy użytkownik tworzy nowy wpis na blogu, komputer-klient zawiadamia serwer, że należy go dodać. W tym celu wysyła on zapytanie HTTP przy użyciu metody POST, lub ewentualnie PUT z zawartością dla nowego posta na blogu. Serwer odpowiada do klienta i przekazuje informację, czy wpis był utworzony lub czy powstał problem z jego utworzeniem.

Trzecie ograniczenie interfejsu: samoopisujące się (self-descriptive) wiadomości

Wiadomości informacyjne - kolejne ograniczenie, pomaga ujednolicić interfejs między klientami i serwerem. Wiadomość samoopisująca się to taka, która zawiera wszystkie niezbędne informacje, które niezbędne są odbiorcy dla jej zrozumienia.

W celu zrozumienia, jak odnosi się to do Internetu, przeanalizujemy później zbiór metod zapytań HTTP i kody odpowiedzi. Poniżej zobaczmy krótki przykład:

Gdy użytkownik wprowadza http://www.example.com w polu adresu swojej przeglądarki internetowej, przeglądarka wysyła następujące zapytanie HTTP:

            GET / HTTP/1.1
            Host: www.example.com
          

Ta wiadomość jest informacyjna, ponieważ zawiadamia serwer, jaka metoda HTTP została wykorzystana oraz jaki jest protokół użytkownika (HTTP 1.1).

Serwer może wysłać odpowiedź podobną do tej:

            HTTP/1.1 200 OK
            Content-Type: text/html

            <!DOCTYPE html>
            <html lang="en-US">
              <head>
                <meta charset="UTF-8" />
                <title>Your Site Title Here</title>
              </head>
              <body>
                Hello world!
                <a href="https://goit.ua">GoIT world!</a>
                <img src="https://goit.ua/wp-content/themes/1/images/Layer.png">
              </body>
            </html>
          

Ta wiadomość jest informacyjna (samoopisowa), ponieważ informuje klienta, jak powinien interpretować ciało wiadomości wskazując na przykład, że Content-type to text/html. Klient otrzymuje w jednej wiadomości wszystko co jest mu potrzebne, aby opracować ją w odpowiedni sposób.

Dodatkowo widzimy kod odpowiedzi 200 OK, który oznacza, że nie było problemów z realizacją naszego żądania.

Czwarte ograniczenie interfejsu: hipermedia

Ostatnie ograniczenie interfejsu, to ograniczenie hipermediów. Hipermedia to nazwa dla danych wysyłanych z serwera do klienta, które będą zawierać informację o tym, co klient może zrobić dalej, innymi słowy, jakie dalsze zapytania możemy wykonać. W REST serwery powinny wysyłać do klientów tylko hipermedia. HTML to jedna z odmian hipermediów.

Zobaczmy przykład:

            <a href="https://goit.ua">GoIT world!</a>
          

Informuje przeglądarkę, że powinna wykonać zapytanie GET na https://goit.ua, jeśli użytkownik kliknie na odnośnik.

            <img src="https://goit.ua/wp-content/themes/1/images/Layer.png" />
          

Informuje przeglądarkę, że trzeba niezwłocznie wysłać zapytanie GET na https://goit.ua/wp-content/themes/1/images/Layer.png, aby mogła pokazać zdjęcie (również przykład hipermediów) użytkownikowi.

Buforowanie

Buforowanie odnosi się do ograniczenia, przy którym odpowiedzi serwera powinny być oznaczone jako buforowalne lub niebuforowalne. Mówimy o nim wtedy, gdy klient zapisuje poprzednie odpowiedzi otrzymane od serwera, ponieważ, gdy te dane znów będą przydatne, nie musi robić zapytań do sieci, a wykorzysta już posiadaną część danych. Buforowanie częściowo lub w pełni eliminuje niektóre interakcje klient-serwer, rozwijając skalowalność i wydajność aplikacji. Wykorzystywane jest np. przy serwisach streamingowych takich jak Netflix.

Wielowarstwowy (wielopoziomowy) system

Wielowarstwowy (wielopoziomowy) system polega na tym, że komponentów może być więcej niż tylko serwery i klienci. Oznacza to, że w systemie może być więcej niż jeden poziom. Jednakże każdy komponent powinien być tak ograniczony, aby widzieć i współdziałać tylko z następną warstwą. Dla przykładu, proxy to dodatkowy komponent, który przekazuje zapytania HTTP na odpowiednie serwery lub inne proxy. Serwery proxy są przydatne dla implementacji dodatkowych warstw bezpieczeństwa. Serwer proxy pełni rolę serwera dla klienta, który wysyła zapytanie, a następnie działa już jako klient, gdy przekazuje to zapytanie dalej do właściwych serwerów.

API Gateway to kolejny przykład dodatkowej warstwy, która przekazuje zapytanie HTTP do odpowiedniego serwera, a następnie odpowiada otrzymanymi informacjami. Wykorzystywana jest często przy architekturze mikroserwisów.

Kod zapytania

Nieobowiązkowe ograniczenie, które odnosi się do możliwości serwera dotyczących wysyłania wykonalnego kodu do klienta. Na przykład gdy dokument HTML jest załadowany, przeglądarka automatycznie ładuje kod plików JavaScript z serwera i wykonuje go lokalnie.

4.5 Podsumowanie

W ten sposób system RESTful to dowolna sieć podlegająca analizowanym ograniczeniom. System RESTful powinien być elastyczny dla różnych rodzajów wykorzystania, skalowalny dla podsumowania dużej ilości użytkowników i komponentów i możliwy do adaptowania z biegiem czasu. Pamiętaj jednak, że REST to projekt teoretyczny a nie konkretne implementacje.

5.1 Podstawowe metody HTTP

5.2 Idempotentność

Właściwość operacji, gdzie przy powtórnym stosowaniu do obiektu, da zawsze taki sam rezultat.

  • dodawanie zera: a=a+0=(a+0)+0=((a+0)+0)+0=...
  • mnożenie przez jeden: x = x*1 = (x*1)*1 = ((x*1)*1)*1 = ...;

Z punktu widzenia usługi RESTful, aby operacja była idempotentną, klienci muszą móc wykonywać jedno i to samo wywołanie kilka razy i otrzymywać ten sam rezultat co za pierwszym razem. Może to nam przypomnieć operację przypisania z programowania. Innymi słowy, wysłanie kilku jednakowych zapytań ma taki sam efekt, jak wysłanie tylko jednego z nich. Choć idempotentne operacje dają ten sam rezultat na serwerze, sama odpowiedź może się różnić, to znaczy stan zasobu może zmieniać się między zapytaniami z uwagi na inne operacje wykonane w międzyczasie.

Podstawowe lub najczęstsze wykorzystanie metod HTTP GET/POST/PUT/DELETE:

  • GET prosi o dane dotyczące zasobu/zasobów
  • POST tworzy nowy zasób na podstawie wysłanych danych
  • PUT całościowo zastępuje dany zasób wysłanymi danymi lub tworzy go jeśli nie istnieje
  • DELETE usuwa wskazany zasób;
  • PATCH częściowo aktualizuje dany zasób/li>

5.3 HTTP metoda GET

Wykorzystuje się dla otrzymania (lub odczytania) widoku zasobu. W przypadku pomyślnego żądania, metoda GET zwraca widok zasobu w formacie XML lub JSON w połączeniu z kodem stanu HTTP 200 (OK). W przypadku niepowodzenia zwracany jest kod 404 (NOT FOUND) lub 400 (BAD REQUEST). Jest to bezpieczna (idempotentna) metoda. Przeznaczona jest tylko dla otrzymania informacji i nie powinna zmieniać stanu serwera lub powodować efektów ubocznych.

5.4 HTTP metoda POST

Zapytanie najczęściej wykorzystywane jest do tworzenia nowych zasobów. Przy tworzeniu nowego zasobu, zapytanie POST wysyła widok, a serwer bierze na siebie odpowiedzialność za przypisanie nowemu zasobowi ID i tak dalej. Przy pomyślnym utworzeniu zasobu zwracany jest kod HTTP - 201 Create, a także może być odesłany nagłówek Location z adresem utworzonego zasobu. Metoda POST nie jest bezpieczna ani idempotentna, ponieważ pojawia się efekt uboczny - utworzenie zasobu, wiele takich samych zapytań POST może utworzyć wiele nowych zasobów.

5.5 HTTP metoda PUT i PATCH

Wykorzystuje się je dla aktualizacji zasobu (lub utworzenia nowego dla PUT). Ciało zapytania przy wysyłaniu zapytania PUT do istniejącego zasobu URL powinno zawierać zaktualizowane dane oryginalnego zasobu (w pełni) lub tylko aktualizowaną część - PATCH. Przy sukcesie aktualizacji zwraca kod 200 (lub 204 jeśli endpoint nie odpowiada żadną zawartością). Metoda PUT uważana jest za niebezpieczną operację, ponieważ w procesie wykonania zachodzi modyfikacja (lub utworzenie) egzemplarza zasobu po stronie serwera, ale ta metoda jest idempotentna. Wiele takich samych zapytań PUT będzie miało taki sam efekt jak jedno zapytanie - podobnie jest w przypadku PATCH.

5.6 HTTP metoda DELETE

Wykorzystuje się do usunięcia zasobu identyfikowanego przez konkretny URL (ID). Przy usunięciu zasobu z sukcesem zwracany jest kod 200 (OK) HTTP razem z ciałem odpowiedzi, zawierającym dane usuniętego zasobu. Możliwe jest również wykorzystanie HTTP kodu 204 (NO CONTENT) bez ciała odpowiedzi. Zgodnie ze specyfikacją HTTP, metoda DELETE jest idempotentna. Jeżeli wykonujesz zapytanie DELETE do zasobu, zostaje on usunięty. Powtórne zapytanie DELETE do zasobu zakończy się tak samo: zasób nadal jest usunięty - nie istnieje.

5.7 Kody odpowiedzi HTTP

Jak widać dowolna odpowiedź od serwera powinna przekazywać kod stanu HTTP. Pokazuje on, czy dane zapytanie HTTP zakończyło się powodzeniem. Kody dzielą się na pięć grup, zobaczmy najpopularniejsze wśród nich:

  1. Informacyjne 100 - 199 (używane najrzadziej)
                      100: Continue
                  
  2. Zakończone pomyślnie 200 - 299
                      200: OK
                      201: Created
                      202: Accepted
                      204: No Content
                  
  3. Przekierowania 300 - 399
                      301: Moved Permanently
                      307: Temporary Redirect
                  
  4. Błędy klienta 400 - 499
                      400: Bad Request
                      401: Unauthorized
                      403: Forbidden
                      404: Not Found
                  
  5. Błędy serwera 500 - 599
                      500: Internal Server Error
                      501: Not Implemented
                      502: Bad Gateway
                      503: Service Unavailable
                      504: Gateway Timeout
                  

Więcej szczegółów znajdziesz na stronie RFC 2616 lub prościej MDN

6.1 CORS

Cross-Origin Resource Sharing (CORS) to mechanizm, który przy pomocy nagłówków HTTP daje przeglądarce pozwolenie na ładowanie zasobów z konkretnego źródła na zapytanie aplikacji webowej, pochodzącej z odpowiedniego źródła.

Przykładem krzyżowego zapytania jest kod JavaScript aplikacji webowej, umieszczonej w domenie http://example.com, która próbuje przy pomocy zapytania Fetch otrzymać dane Web-API z innej domeny http://api-example.com/data.

W celach bezpieczeństwa wszystkie przeglądarki domyślnie przerywają wszystkie krzyżowe zapytania HTTP, które tworzone są przez JavaScript klienta. Nazywa się to przestrzeganiem zasady jednego źródła i wynika z tego, że aplikacja webowa otrzymana z konkretnej domeny (Github pages) nie może wykonywać zapytań do zasobów HTTP z innej domeny (Heroku). Aby otrzymać odpowiedź na żądanie, serwer webowy, na którym jest realizowane API, powinien zawierać odpowiednie nagłówki CORS.

Mechanizm CORS czyni bezpiecznym zapytania krzyżowe i przekazywanie danych między przeglądarkami webowymi i serwerami webowymi.

Dla Node.js to moduł cors, dostępny przez rejestr npm. Instalacja przeprowadzana jest przy pomocy:

            npm install cors
          

Przykład:

            const express = require('express');
            const cors = require('cors');
            const app = express();

            app.use(cors());

            app.get('/', (req, res, next) => {
              res.json({ message: 'CORS is activated' });
            });

            app.listen(3000, function () {
              console.log('CORS-enabled web server listening on port 3000');
            });
          

W funkcji middleware cors, możemy przekazać jako argument konfigurowalny obiekt z następującymi właściwościami:

  • origin: Dostosowuje nagłówek CORS Access-Control-Allow-Origin. Najczęściej to łańcuch "*", który oznacza zapytanie od dowolnej domeny. Możliwa jest konkretna wartość w typie "http://example.com" i będą wtedy dozwolone tylko zapytania z "http://example.com". Można wykorzystywać regularne wyrażenie lub tablicę łańcuchów lub regularnych wyrażeń, jeśli chcemy aby dostęp do API był możliwy z różnych domen.
  • methods: Dostosowuje nagłówek CORS Access-Control-Allow-Methods. Żąda łańcucha z metodami HTTP, na przykład "GET, PUT, POST", prawidłowa jest też tablica ['GET', 'PUT', 'POST'], które mogą kierować zapytania między domenami.
  • allowedHeaders: Dostosowuje nagłówek CORS Access-Control-Allow-Headers. Żąda łańcucha z separatorami w formie przecinków, na przykład "Content-Type, Authorization" lub tablicy ['Content-Type', 'Authorization'] - definiuje jakie nagłówki są dozwolone przy zapytaniu.
  • exposedHeaders: Dostosowuje nagłówek CORS Access-Control-Expose-Headers. Zarządza nagłówkami użytkownika.
  • credentials: Dostosowuje nagłówek CORS Access-Control-Allow-Credentials. Ustaw true dla przekazania nagłówka, w przeciwnym razie nie pokazuje się.
  • maxAge: Dostosowuje nagłówek CORS Access-Control-Max-Age. Ustaw liczbę całkowitą dla przekazania nagłówka, w przeciwnym razie jest pomijany.
  • preflightContinue: Definiuje czy przekazać odpowiedź wstępnego sprawdzenia CORS następującemu programowi opracowywania.
  • optionsSuccessStatus: Dostarcza kod stanu do wykorzystania przy zakończonych sukcesem zapytaniach korzystających z metody OPTIONS, ponieważ niektóre starzejące się przeglądarki (IE11, różne SmartTV) nie potrafią zrozumieć statusu 204

Konfiguracja domyślnie to:

            {
                origin: '*',
                methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
                preflightContinue: false,
                optionsSuccessStatus: 204
            }
          

7.1 Tworzenie URL dla REST API

W tym rozdziale porozmawiamy o prawidłowym nazywaniu zasobów dla API. Gdy zasoby są nazwane nieprawidłowo, API jest nieintuicyjne i trudne w użytkowaniu. Często URL zapytania zasobu nazywany jest endpoint-em API.

Co jest dobrą praktyką dla prawidłowego nadawania nazw?

Po pierwsze należy wykorzystywać dla opisania bazowych URL rzeczowniki w liczbie mnogiej - users, contacts. Należy także stosować konkretne i czytelne nazwy news, videos, a nie abstrakcyjne items lub elements. Dodatkowe parametry niezbędne dla obsługi żądania należy podawać za znakiem ? czyli korzystają z queryparams. Przykładem jest

  • wykorzystanie paginacji /users?limit=25&offset=50
  • filtrowanie odpowiedzi /friends?fields=id,name,picture.

W oparciu o wcześniejsze informacje, następujące nazwy będą złą praktyką:

            /api/users/13/remove // powinniśmy użyć metody HTTP DELETE
            /api/getusers // niepotrzebny czasownik
            /api/v1/users-get // niepotrzebny czasownik
          

Przeanalizujmy dobre praktyki nazywania URL dla różnych sytuacji.

Dodanie nowego klienta do systemu:

            HTTP metoda: POST
            URL: http://www.example.com/customers
          

Odpytanie o dane klienta z identyfikatorem 112233:

            HTTP metoda: GET
            URL: http://www.example.com/customers/112233
          

Ten sam URL wykorzystujemy dla metod HTTP - PUT i DELETE odpowiednio dla aktualizacji i usunięcia.

Utworzenie nowego produktu:

            HTTP metoda: POST
            URL: http://www.example.com/products
          

Dla odczytania, aktualizacji, usunięcia produktu z 432111, odpowiednio:

            HTTP metoda: GET, PUT, DELETE
            URL: http://www.example.com/products/432111
          

Utworzenie nowego zamówienia dla klienta na zewnątrz kontekstu klienta.

            HTTP metoda: POST
            URL: http://www.example.com/orders
          

Utworzenie tego samego zamówienia, ale w kontekście konkretnego klienta z ID 332244.

            HTTP metoda: POST
            URL: http://www.example.com/customers/332244/orders
          

Lista zamówień należących do klienta ID 332244:

            HTTP metoda: GET
            URL: http://www.example.com/customers/332244/orders
          

URL dla dodania nowej pozycji w zamówieniu z ID 1234, dla klienta z ID 332244:

            HTTP metoda: POST
            URL: http://www.example.com/customers/332244/orders/1234/lineorders
          

Otrzymanie listy zamówienia po ID zamówienia bez znajomości ID konkretnego klienta.

            HTTP metoda: GET
            URL: http://www.example.com/orders/8769
          

Paginacja zachodzi przez queryparams przy pomocy parametrów

  • offset - który mówi ile wyników chcemy pominąć
  • limit - maksymalna ilość zwracanych elementów.

Mogą się one nazywać inaczej, na przykład skip, limit.

            HTTP metoda: GET
            URL: http://api.example.com/resources?offset=0&limit=25
          

Skomplikowane filtrowanie po wartościach. Można wykorzystać separator podwójnego dwukropka ::, który oddziela nazwę właściwości od wartości którą należy porównać.

            HTTP metoda: GET
            URL: http://www.example.com/users?filter="name::sam|city::denver"
          

Sortowanie. Jeden ze sposobów, w którym dla każdej przekazywanej właściwości przeprowadzane jest sortowanie w porządku rosnącym, a dla każdej właściwości z prefiksową pauzą ("-"), sortowanie zachodzi w porządku malejącym. Separator dla każdej nazwy właściwości to pionowa kreska ("|").

            HTTP metoda: GET
            URL: http://www.example.com/users?sort=lastName|firstName|-birthdate
          

8.1 Przykład REST API aplikacji

Przeanalizujmy proste Web-API. Realizujemy standardową listę zadań. API będzie zawierało pełen zbiór operacji CRUD (Create, Read, Update, Delete) dla naszych zadań (tasks).

Będziemy pracować z dwoma URL-ami

/api/tasks/ - pełny CRUD, obsługa wszystkich potrzebnych metod;

/api/tasks/:id/status - do zmiany statusu zadania, czy jest wykonane, czy nie.

Wszystkie zapytania do naszego API będziemy wykonywać przy pomocy narzędzia Postman.

8.2 Podstawowy plik aplikacji

            const express = require('express');
            const cors = require('cors');
            const routerApi = require('./api');

            const app = express();

            // parse application/json
            app.use(express.json());
            // cors
            app.use(cors());

            app.use('/api', routerApi);

            app.use((_, res, __) => {
              res.status(404).json({
                status: 'error',
                code: 404,
                message: 'Use api on routes: /api/tasks',
                data: 'Not found',
              });
            });

            app.use((err, _, res, __) => {
              console.log(err.stack);
              res.status(500).json({
                status: 'fail',
                code: 500,
                message: err.message,
                data: 'Internal Server Error',
              });
            });

            const PORT = process.env.PORT || 3000;

            app.listen(PORT, function () {
              console.log(`Server running. Use our API on port: ${PORT}`);
            });
          

Podłączamy tu opracowywanie danych w formacie JSON, włączamy cors i dodajemy opracowywanie błędów.

8.3 Realizacja API

W pliku routingu opisujemy całą logikę działania naszego API.

Do generowania losowego, unikalnego identyfikatora zadania wykorzystujemy moduł nanoid:

            const { nanoid } = require('nanoid');
          

Wszystkie dane o zadaniach przechowujemy w tablicy tasks, gdzie na "sztywno" przypisujemy jedno zadanie.

            let tasks = [
              {
                id: nanoid(),
                title: 'Work',
                text: 'Do it!',
                done: false,
              },
            ];
          

Później do tej tablicy będziemy dodawać nowe zadania, usuwać je i modyfikować.

8.4 Odczytanie

Pierwszy endpoint to otrzymanie listy wszystkich zadań:

            router.get('/tasks', (req, res, next) => {
              res.json({
                status: 'success',
                code: 200,
                data: {
                  tasks,
                },
              });
            });
          

Tu wszystko jest dość proste, wysyłamy obiekt ze statusem 'success', kodem 200 i właściwością data. To nasz payload, w którym umieścimy tablicę zadań. Wykorzystamy program Postman i wyślemy nasze pierwsze zapytanie:

Funkcja do otrzymania zadania po konkretnym ID

            router.get('/tasks/:id', (req, res, next) => {
              const { id } = req.params;
              const [task] = tasks.filter(el => el.id === id);
              res.json({
                status: 'success',
                code: 200,
                data: { task },
              });
            });
          

8.5 Utworzenie nowego zadania

            router.post('/tasks', (req, res, next) => {
            const { title, text } = req.body;
            const task = {
              id: nanoid(),
              title,
              text,
              done: false,
            };

            tasks.push(task);

            res.status(201).json({
              status: 'success',
              code: 201,
              data: { task },
            });
          });
          

Przy wysyłaniu Postman wybieramy zakładkę Body, tryb raw format JSON i ustawiamy metodę HTTP na POST. W body naszego zapytania wpisujemy:

            {
              "title": "Vacation",
              "text": "Enjoy"
            }
          

Widzimy, że utworzyliśmy nowy zasób. Zwróć uwagę, że dostajemy status 201, a powtórne zapytanie do listy zadań pokaże nam już dwa zadania.

8.6 Aktualizacja zadania

            router.put('/tasks/:id', (req, res, next) => {
              const { id } = req.params;
              const { title, text } = req.body;
              const [task] = tasks.filter(el => el.id === id);
              task.title = title;
              task.text = text;
              res.json({
                status: 'success',
                code: 200,
                data: { task },
              });
            });
          

Jeśli chcemy modyfikować nasze zadanie, możemy to zrobić przez nowy obiekt przy pomocy Postmana i metody HTTP - PUT:

Wywołanie pełnej listy zadań pokaże nam już zaktualizowaną listę:

8.7 Częściowa aktualizacja

Dla aktualizacji statusu wykorzystujemy oddzielny URL i metodę HTTP - PATCH:

            router.patch('/tasks/:id/status', (req, res, next) => {
              const { id } = req.params;
              const { done } = req.body;
              const [task] = tasks.filter(el => el.id === id);
              task.done = done;
              res.json({
                status: 'success',
                code: 200,
                data: { task },
              });
            });
          

Tutaj zmieniamy wartość tylko właściwości done danego zadania

            {
              "done": true
            }
          

8.8 Usuwanie

Ostatnie działanie, to usunięcie zadania z listy:

            router.delete('/tasks/:id', (req, res, next) => {
              const { id } = req.params;
              const newtasks = tasks.filter(el => el.id !== id);
              tasks = [...newtasks];
              res.status(204).json();
            });
          

Zainstaluj Postman i znając pełny URL aplikacji: https://nodebook-api.glitch.me/api/tasks/ spróbuj samodzielnie powtórzyć wskazane działanie po utworzeniu i aktualizacji listy zadań. Alternatywnie możesz korzystać też z programu Insomnia