kleindan.dev

Obsługa żądań POST i odbieranie danych od klienta w Express

W poprzedniej lekcji pracowaliśmy nad szkieletem aplikacji z fiszkami do nauki. Dziś będziemy kontynuować nasza pracę i dodamy możliwość dodawania własnych fiszek przez użytkownika aplikacji.

Wideo towarzyszące

Dodanie fiszek do kategorii

Zaczniemy od dodania jakichś przykładowych fiszek do naszych tymczasowych danych testowych, aby mieć co wyświetlić później w widoku kategorii.

> index.js
import express from "express";

const port = 8000;
const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));

app.get("/cards/categories/", (req, res) => {
  res.render("categories", {
    title: "Kategorie",
    categories: Object.entries(card_categories).map(
      ([id, category]) => category.name
    ),
  });
});

app.listen(port, () => {
  console.log(`Server listening on http://localhost:${port}`);
});

O ile rozwinięcie naszej struktury danych chyba nie wymaga większego komentarza, o tyle linie 34-36 już tak.

Funkcja Object.entries() zamienia obiekt JS w tablicę tablic (sic!) w formie [ [klucz1, wartość1], [klucz2, wartość2], ...]. Następnie korzystając z metody map() przetwarzamy tę tablicę na tablicę zawierającą tylko nazwy kategorii, aby do naszego widoku przekazać dane w takiej formie, w jakiej były poprzednio.

Stworzenie widoku pojedynczej kategorii

Kolejnym krokiem będzie dodanie widoku pokazującego wszystkie fiszki w danej kategorii. Nie będziemy dziś pracować nad prezentacją fiszek, to możemy spokojnie zrobić później. Póki co po prostu wyświetlmy je jako elementy listy.

> /views/category.ejs
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <ul id="flashcards">
    <% category.cards.forEach(function(card){ %>
    <li><%= card.front %> - <%= card.back %></li>
    <% }) %>
  </ul>
</main>

<%- include("foot.partial.ejs") %>

Kod widoku jest bardzo zbliżony do widoku z listą kategorii, z tą różnicą że tym razem chcemy przekazać do funkcji renderującej widok cały obiekt kategorii, a sam widok wydobędzie ze środka tablicę z fiszkami (linia 6).

Następnie stwórzmy funkcję obsługującą nową ścieżkę.

Parametry w ścieżkach

Istnieją różne sposoby dookreślania, jakie zasoby na serwerze nas interesują użytkownika. Obecna moda jest taka, żeby umieszczać parametry bezpośrednio w ścieżce żądania, ponieważ generuje to przyjemnie wyglądające linki. Spróbujmy zastosować to podejście w naszej aplikacji.

> index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ...

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));

app.get("/cards/categories/", (req, res) => {
  res.render("categories", {
    title: "Kategorie",
    categories: Object.entries(card_categories).map(
      ([id, category]) => category.name
    ),
  });
});

app.get("/cards/:category_id", (req, res) => {
  if (card_categories.hasOwnProperty(req.params.category_id)) {
    const category = card_categories[req.params.category_id];
    res.render("category", {
      title: category.name,
      category,
    });
  } else {
    res.sendStatus(404);
  }
});

// ...

Express pozwala nam definiować fragmenty ścieżek jako parametry, których następnie możemy użyć w funkcji obsługującej żądanie. Parametry w ścieżce definiujemy używając składni :nazwa_parametru, a następnie w obsłudze żądania możemy się do nich odnosić używając konstrukcji res.params.nazwa_parametru. Widzimy to zastosowane w kodzie powyżej w linijkach 16-18

Ponieważ parametr w ścieżce to informacja przychodząca od użytkownika, musimy się upewnić, że dane przychodzące z zewnątrz są prawidłowe. Dlatego nie założymy, że użytkownik wpisał poprawny adres i podany identyfikator istnieje w naszej “bazie danych”. Sprawdzamy w linii 17 czy identyfikator istnieje i dopiero po upewnieniu się wyciągamy z naszej struktury odpowiedni obiekt. W przeciwnym wypadku żądanie wskazuje na nieistniejącą kategorię, więc musimy zwrócić status HTTP 404 Not Found, co robimy w linii 24.

Jeżeli udało się odnaleźć odpowiednią kategorię na podstawie parametru w ścieżce, możemy wyrenderować odpowiednią odpowiedź HTML.

Przeniesienie fiszek do osobnego modułu

Ponieważ nasz plik index.js się rozrasta, być może to dobry moment na podzielenie go na mniejsze części. Dobrą linią podziału będzie wyciągnięcie kodu związanego z fiszkami i ich kategoriami do osobnego modułu. Stwórzmy więc plik models/flashcards.js.

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

export function getCategorySummaries() {
  return Object.entries(card_categories).map(([id, category]) => {
    return { id, name: category.name };
  });
}

export function hasCategory(categoryId) {
  return card_categories.hasOwnProperty(categoryId);
}

export function getCategory(categoryId) {
  if (hasCategory(categoryId))
    return { id: categoryId, ...card_categories[categoryId] };
  return null;
}

export default {
  getCategorySummaries,
  hasCategory,
  getCategory,
};

Słowo komentarza do linii 36. Tworzymy tam obiekt będący kopią obiektu z tablicy card_categories z dodatkową właściwością id. Dzięki temu wszelkie dane potrzebne podczas renderowania odpowiedzi HTML będą dostępne w jednym obiekcie.

Po przeniesieniu naszych danych i dodaniu kilku funkcji pozwalających na dostęp do tych danych, możemy uporządkować znów nasz plik index.js

> index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import express from "express";
import flashcards from "./models/flashcards.js";

const port = 8000;

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));

app.get("/cards", (req, res) => {
  res.render("categories", {
    title: "Kategorie",
    categories: flashcards.getCategorySummaries(),
  });
});

app.get("/cards/:category_id", (req, res) => {
  const category = flashcards.getCategory(req.params.category_id);
  if (category != null) {
    res.render("category", {
      title: category.name,
      category,
    });
  } else {
    res.sendStatus(404);
  }
});

app.listen(port, () => {
  console.log(`Server listening on http://localhost:${port}`);
});

Formularz dodawania nowych fiszek

Skoro już mamy jakiś sposób na wyświetlenie naszych fiszek, to możemy spróbować stworzyć mechanizm dodawania nowych pozycji do listy. Zacznijmy od stworzenia nowego szablonu EJS z formularzem HTML

> /views/forms/new_card.ejs
<form action="/cards/<%=category.id%>/new" method="POST">
  <div>
    <label for="card_front">Awers</label>
    <input
      type="text"
      name="front"
      id="card_front"
      required
      minlength="1"
      maxlength="500"
    />
  </div>
  <div>
    <label for="card_back">Rewers</label>
    <input
      type="text"
      name="back"
      id="card_front"
      required
      minlength="1"
      maxlength="500"
    />
  </div>
  <div>
    <button>Dodaj</button>
  </div>
</form>

W formularzu definiujemy dwa pola do wypełnienia, które mają zawierać tekst awersu i rewersu nowej fiszki. Te dane trafią do naszego serwera pod nazwami front (linia 6) i back (linia 17). Oczekujemy jakiegoś tekstu po obu stronach fiszki, więc oba pola są wymagane i ograniczymy ich długość do maksymalnie 500 znaków, co prawdopodobnie i tak jest nadmiarową długością. Ponieważ fiszki będą tworzone w kontekście kategorii, nie jest to osobne pole w formularzu, ta informacja będzie zakodowana w ścieżce, pod którą wyślemy nasze żądanie (linia 1).

Tak stworzony formularz dołączmy do naszej strony listującej fiszki w danej kategorii.

> /views/category.ejs
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <ul id="cards">
    <% category.cards.forEach(function(card){ %>
    <li><%= card.front %> - <%= card.back %></li>
    <% }) %>
  </ul>
  <div>
    <h3>Nowa fiszka</h3>
    <%- include("forms/new_card.ejs") %>
  </div>
</main>

<%- include("foot.partial.ejs") %>

Po wypełnieniu formularza i wysłaniu go do serwera, nasza aplikacja powinna odpowiedzieć błędem. Aby to naprawić, musimy dodać handler dla odpowiednich ścieżek.

Obsługa żądania POST

Po pierwsze będziemy potrzebowali nowej funkcji w naszym module przechowującym dane, takiej która będzie mogła faktycznie dodać nowe dane do naszej struktury danych.

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const card_categories = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

export function getCategorySummaries() {
  return Object.entries(card_categories).map(([id, category]) => {
    return { id, name: category.name };
  });
}

export function hasCategory(categoryId) {
  return card_categories.hasOwnProperty(categoryId);
}

export function getCategory(categoryId) {
  if (hasCategory(categoryId))
    return { id: categoryId, ...card_categories[categoryId] };
  return null;
}

export function addCard(categoryId, card) {
  if (hasCategory(categoryId)) card_categories[categoryId].cards.push(card);
}

export default {
  getCategorySummaries,
  hasCategory,
  getCategory,
  addCard,
};

Następnie stwórzmy nowy handler dla ścieżki, pod którą chcemy wysyłać żądania POST.

> index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import express from "express";
import flashcards from "./models/flashcards.js";

const port = 8000;

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));
app.use(express.urlencoded());

app.get("/cards", (req, res) => {
  res.render("categories", {
    title: "Kategorie",
    categories: flashcards.getCategorySummaries(),
  });
});

app.get("/cards/:category_id", (req, res) => {
  const category = flashcards.getCategory(req.params.category_id);
  if (category != null) {
    res.render("category", {
      title: category.name,
      category,
    });
  } else {
    res.sendStatus(404);
  }
});

app.post("/cards/:category_id/new", (req, res) => {
  const category_id = req.params.category_id;
  if (!flashcards.hasCategory(category_id)) {
    res.sendStatus(404);
  } else {
    flashcards.addCard(category_id, {
      front: req.body.front,
      back: req.body.back,
    });
    res.redirect(`/cards/${category_id}`);
  }
});

app.listen(port, () => {
  console.log(`Server listening on http://localhost:${port}`);
});

Aby mieć dostęp do danych wysłanych w formularzu przez użytkownika, potrzebujemy dodać do naszej aplikacji jeszcze jeden moduł frameworku Express, mianowicie urlencoded. Dodajemy go do naszej aplikacji w linii 9. Po jego dodaniu, dane wpisane do formularza w kolejnych handlerach pojawią się w obiektach żądania po polem res.body. Tutaj wykorzystujemy dane z formularza w liniach 36-37.

Powtórzę po raz kolejny, dane przychodzące do serwera są niezweryfikowane. Za każdym razem kiedy znajdujemy się w takiej sytuacji musimy dane z zewnątrz zweryfikować i upewnić się, że są poprawne. Dlatego najpierw w linii 32 upewniamy się, że podana kategoria istnieje, zanim zaczniemy przetwarzać pozostałe dane. Póki co w liniach 35-39 zakładamy, że dane z formularza są poprawne, dodajemy nową fiszkę do naszej kolekcji i przekierowujemy użytkownika z powrotem do widoku kategorii.

Przekierowanie klienta do innej ścieżki

Przekierowanie po udanej obsłudze żądania POST ma na celu uniknięcie sytuacji, w której użytkownik po dodaniu nowej karty odświeży widok strony i przypadkowo doda duplikat właśnie stworzonej fiszki. Pod spodem odbywa się to poprzez zwrócenie kodu HTTP 301 Found wraz z podaniem nagłówka Location. Po otrzymaniu takiej odpowiedzi przeglądarka sama zmienia adres na ten wskazany przez Location i wysyła nowe żądanie GET do serwera.

Walidacja danych od użytkownika

Ostatnim krokiem powinno być upewnienie się, że otrzymane dane są poprawne. Podobnie jak sprawdzony wcześniej parametr :category_id, req.body.front i req.body.backdanymi zewnętrznymi, które musimy sprawdzić zanim ich użyjemy. Stwórzmy w tym celu funkcję pomocniczą w naszym module models/flashcards.js

> models/flashcards.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ...

export function validateCardData(card) {
  var errors = [];
  var fields = ["front", "back"];
  for (let field of fields) {
    if (!card.hasOwnProperty(field)) errors.push(`Missing field '${field}'`);
    else {
      if (typeof card[field] != "string")
        errors.push(`'${field}' expected to be string`);
      else {
        if (card[field].length < 1 || card[field].length > 500)
          errors.push(`'${field}' expected length: 1-500`);
      }
    }
  }
  return errors;
}

export default {
  getCategorySummaries,
  hasCategory,
  getCategory,
  addCard,
  validateCardData,
};

Funkcja validateCardData upewnia się, że podany jako argument obiekt zawiera pola front i back, że są one typu string oraz że mają spodziewaną długość. W przeciwnym wypadku dodaje stosowny komunikat do zwracanej tablicy zawierającej błędy walidacji. Jeżeli funkcja nie wykryje żadnych błędów, zwraca po prostu pustą tablicę.

Pozostaje otwarte pytanie, co zrobić, jeżeli faktycznie wykryjemy jakiś błąd w przesłanych danych. Wypada poinformować użytkownika o wykrytych błędach i dać mu szansę na poprawienie danych i ponowne ich przesłanie. Zaproponuję stworzenie nowego szablonu EJS, który wykorzystamy dokładnie w tym celu.

> /views/new_card.ejs
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <ul id="errors">
    <% errors.forEach(function(error){ %>
    <li><%= error %></li>
    <% }) %>
  </ul>
  <%- include("forms/new_card.ejs") %>
</main>

<%- include("foot.partial.ejs") %>

Dobrym nawykiem w przypadku zgłaszania użytkownikowi błędów w formularzu jest wypełnienie formularza danymi wprowadzonymi podczas pierwszej próby przesłania. Nasz formularz jest krótki, zawiera ledwie dwa pola, ale nadal jeżeli część danych została wpisana poprawnie albo wymaga niewielkich poprawek, to dlaczego zmuszać użytkownika do ponownego ich wpisywania?

Aby wstępnie wypełnić formularz musimy zmodyfikować jego szablon:

> /views/forms/new_card.ejs
<form action="/cards/<%=category.id%>/new" method="POST">
  <div>
    <label for="card_front">Awers</label>
    <input
      type="text"
      name="front"
      id="card_front"
      required
      minlength="1"
      maxlength="500"
      <% if (locals.front){ %>
      value="<%= front %>"
      <% }%>
    />
  </div>
  <div>
    <label for="card_back">Rewers</label>
    <input
      type="text"
      name="back"
      id="card_front"
      required
      minlength="1"
      maxlength="500"
      <% if (locals.back){ %>
      value="<%= back %>"
      <% }%>
    />
  </div>
  <div>
    <button>Dodaj</button>
  </div>
</form>

Wykorzystajmy naszą nową funkcję i szablon do lepszego obsłużenia błędów w funkcji obsługującej żądanie post:

> index.js
// ...

app.post("/cards/:category_id/new", (req, res) => {
  const category_id = req.params.category_id;
  if (!flashcards.hasCategory(category_id)) {
    res.sendStatus(404);
  } else {
    let card_data = {
      front: req.body.front,
      back: req.body.back,
    };
    var errors = flashcards.validateCardData(card_data);
    if (errors.length == 0) {
      flashcards.addCard(category_id, card_data);
      res.redirect(`/cards/${category_id}`);
    } else {
      res.status(400);
      res.render("new_card", {
        errors,
        title: "Nowa fiszka",
        front: req.body.front,
        back: req.body.back,
        category: {
          id: category_id,
        },
      });
    }
  }
});

// ...

Aby przetestować działanie naszej funkcji będziemy musieli wykazać się odrobiną kreatywności, ponieważ skonstruowaliśmy formularz tak, że teoretycznie nie powinien dopuścić wysłania nieprawidłowych danych. Aby to obejść, możemy zmodyfikować formularz na stronie używając narzędzi deweloperskich i usunąć obwarowania. Alternatywnie możemy użyć narzędzia curl i wysłać do naszej aplikacji nieprawidłowe żądania w ten sposób:

> curl --request POST -d "front=mango" -d "back=" http://localhost:8000/cards/j-angielski-food/new
> curl --request POST -d "front=awers" -d "back=rewers" http://localhost:8000/cards/nieistniejaca-kategoria/new

Co prawda w odpowiedzi dostaniemy dość długi dokument HTML, ale przy odrobinie determinacji możemy się upewnić, że wszystko działa tak, jak się tego spodziewamy.

Zadania praktyczne