Przejdź do treści

Temat 31 · 3 × 45 min

REST, CRUD i wysyłanie formularzy

W lekcji async/await i fetch pobierałeś dane z serwera. Prawdziwa aplikacja musi je też wysyłać: dodać zadanie do listy, zapisać komentarz, usunąć produkt z koszyka. Serwery udostępniają takie operacje jako REST API – zestaw adresów i metod HTTP, które działają tak samo niezależnie od języka po drugiej stronie (PHP, Node, Python). W tej lekcji nauczysz się rozmawiać z takim API z przeglądarki: wysyłać JSON, obsługiwać formularz bez przeładowania strony, postawić własny testowy serwer i połączyć fetch z PHP, które znasz z części serwerowej INF.03.

Czym jest REST: zasoby, adresy, metody

REST (Representational State Transfer) to umowa, jak projektować API nad HTTP. Trzy zasady wystarczą ci na start:

  1. Wszystko jest zasobem (resource) i ma adres: /zadania, /zadania/7, /uzytkownicy/3/zamowienia. Adres mówi co, nie co zrobić – nie ma /usunZadanie.php?id=7.
  2. Metoda HTTP mówi, co robisz z zasobem. Te same cztery operacje, które znasz z baz danych jako CRUD (Create, Read, Update, Delete), mają swoje metody.
  3. Dane podróżują w obie strony jako JSON, a wynik operacji opisuje kod statusu.
OperacjaMetodaAdresCiało żądaniaTypowa odpowiedź
Read – listaGET/zadaniabrak200 + tablica
Read – jedenGET/zadania/7brak200 + obiekt albo 404
CreatePOST/zadanianowy obiekt (bez id)201 + obiekt z nadanym id
Update – całyPUT/zadania/7pełny obiekt200 + obiekt
Update – częśćPATCH/zadania/7tylko zmienione pola200 + obiekt
DeleteDELETE/zadania/7brak200 z obiektem albo 204 bez treści
Schemat: przeglądarka wysyła żądanie HTTP z metodą, adresem, nagłówkami i ciałem JSON; serwer odpowiada kodem statusu, nagłówkami i ciałem JSON
Rys. 1. Żądanie i odpowiedź HTTP. Z przeglądarki wychodzi metoda + adres + nagłówki + (opcjonalnie) ciało; wraca status + nagłówki + ciało.

Różnica między PUT a PATCH jest praktyczna: PUT wysyłasz, gdy masz cały zaktualizowany obiekt (np. po edycji formularza), PATCH – gdy zmieniasz jedno pole (np. odhaczasz zadanie jako wykonane). GET i DELETE nie mają ciała – parametry przekazujesz w adresie: /zadania?wykonane=true.

Sprawdź się

Użytkownik kliknął „Wykonane” przy zadaniu o id 12. Które żądanie najlepiej opisuje tę operację w stylu REST?

Kody statusu: co odpowiedział serwer

Kod statusu to trzycyfrowa liczba w odpowiedzi. Pierwsza cyfra mówi, jaka to kategoria. W kodzie masz ją pod response.status, a response.ok to skrót na „status od 200 do 299”.

KodZnaczenieKiedy go zobaczyszCo zrobić w JS
200 OKudało sięGET, PUT, PATCH, czasem DELETEczytaj response.json()
201 Createdutworzono zasóbpo POSTodczytaj nadane id z odpowiedzi
204 No Contentudało się, brak treścipo DELETEnie wywołuj response.json() – treść jest pusta
400 Bad Requestzły format danychbrakuje pola, zły JSONpokaż błąd walidacji użytkownikowi
401 Unauthorizedniezalogowanybrak tokenu / sesjiprzekieruj do logowania
403 Forbiddenzalogowany, ale bez uprawnieńcudzy zasóbkomunikat „brak dostępu”
404 Not Foundnie ma takiego adresu/zasobuliterówka w URL, usunięty rekordkomunikat; fetch nie rzuca błędu!
500 Internal Server Errorbłąd po stronie serwerabug w PHP, padła bazakomunikat „spróbuj później”, zgłoś
Sprawdź się

Co wypisze ten kod, jeśli serwer odpowie statusem 404 i pustym ciałem?

const res = await fetch('/zadania/999');
console.log(res.ok, res.status);

Funkcja pomocnicza, którą warto mieć w każdym projekcie – zamienia odpowiedź na dane albo rzuca czytelny błąd:

api.js
async function pobierzJson(response) {
  if (!response.ok) {
    throw new Error(`Serwer odpowiedział ${response.status}`);
  }
  // 204 No Content – nie ma czego parsować
  if (response.status === 204) return null;
  return response.json();
}

// symulacja trzech odpowiedzi (prawdziwe zwróci fetch)
const odpowiedzi = [
  { ok: true, status: 200, json: async () => ({ id: 1, tytul: 'Nauka' }) },
  { ok: true, status: 204, json: async () => { throw new Error('puste ciało'); } },
  { ok: false, status: 404, json: async () => ({}) },
];

async function sprawdzWszystkie() {
  for (const res of odpowiedzi) {
    try {
      console.log(res.status, '→', await pobierzJson(res));
    } catch (e) {
      console.log(res.status, '→ błąd:', e.message);
    }
  }
}
sprawdzWszystkie();
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik

Żądanie z ciałem JSON

fetch(adres) to zawsze GET. Każdą inną metodę opisujesz drugim argumentem – obiektem opcji. Trzy rzeczy muszą się zgadzać:

post.js
const nowe = { tytul: 'Powtórzyć REST', wykonane: false };

const res = await fetch('http://localhost:3000/zadania', {
  method: 'POST',                                        // 1. metoda
  headers: { 'Content-Type': 'application/json' },       // 2. mówisz serwerowi: to JSON
  body: JSON.stringify(nowe),                            // 3. obiekt → napis JSON
});
const zapisane = await res.json();                        // serwer odsyła obiekt z id
console.log(zapisane.id);
Sprawdź się

Programista zapomniał o JSON.stringify i wysłał body: nowe. Co dostanie serwer jako treść żądania?

fetch('/zadania', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: nowe,
});

Warto zamknąć powtarzający się kod w jedną funkcję. Poniżej wersja, którą będziesz używać przez resztę lekcji (w zadaniach też):

api.js
const BAZA = 'http://localhost:3000';

function zbudujOpcje(metoda, dane) {
  const opcje = { method: metoda, headers: {} };
  if (dane !== undefined) {
    opcje.headers['Content-Type'] = 'application/json';
    opcje.body = JSON.stringify(dane);
  }
  return opcje;
}

async function zapytanie(sciezka, metoda = 'GET', dane) {
  const res = await fetch(BAZA + sciezka, zbudujOpcje(metoda, dane));
  if (!res.ok) throw new Error(`${metoda} ${sciezka}: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

// cztery operacje CRUD w jednej linii każda:
const api = {
  lista: () => zapytanie('/zadania'),
  dodaj: (zadanie) => zapytanie('/zadania', 'POST', zadanie),
  zmien: (id, zmiany) => zapytanie(`/zadania/${id}`, 'PATCH', zmiany),
  usun: (id) => zapytanie(`/zadania/${id}`, 'DELETE'),
};

// podgląd opcji, które wyjdą do serwera:
console.log(zbudujOpcje('GET'));
console.log(zbudujOpcje('PATCH', { wykonane: true }));
// bez uruchomionego json-server (o nim niżej) żądanie się nie uda – zobaczysz komunikat z catch;
// prawdziwe dane zobaczysz w placu zabaw niżej
api
  .lista()
  .then((dane) => console.log('lista:', dane))
  .catch((e) => console.log('Brak serwera na localhost:3000?', e.message));
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik

Plac zabaw: CRUD na JSONPlaceholder

Zanim postawisz własny serwer, poćwicz na publicznym fałszywym API JSONPlaceholder. Ma zasoby /posts (100), /comments (500), /todos (200), /users (10) i przyjmuje wszystkie metody. Uwaga: odpowiedzi na POST/PUT/PATCH/DELETE są udawane – serwer odsyła to, co by zrobił (np. nowy post dostaje id: 101), ale nic nie zapisuje. Do nauki to idealne: nie zepsujesz niczego.

CRUD na jsonplaceholder
const BAZA = 'https://jsonplaceholder.typicode.com';
const wynik = document.getElementById('wynik');

async function zapytanie(sciezka, metoda = 'GET', dane) {
  const opcje = { method: metoda, headers: {} };
  if (dane !== undefined) {
    opcje.headers['Content-Type'] = 'application/json; charset=UTF-8';
    opcje.body = JSON.stringify(dane);
  }
  const res = await fetch(BAZA + sciezka, opcje);
  if (!res.ok) throw new Error(`${metoda} ${sciezka}: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

function pokaz(opis, dane) {
  wynik.textContent = `${opis}\n\n${JSON.stringify(dane, null, 2)}`;
}

async function obsluz(opis, akcja) {
  wynik.textContent = 'Czekam na serwer…';
  try {
    pokaz(opis, await akcja());
  } catch (e) {
    wynik.textContent = 'Błąd: ' + e.message;
  }
}

document.getElementById('btn-lista').addEventListener('click', () =>
  obsluz('GET /todos?_limit=3', () => zapytanie('/todos?_limit=3')));

document.getElementById('btn-dodaj').addEventListener('click', () =>
  obsluz('POST /todos → serwer nadaje id (udawane: 201)', () =>
    zapytanie('/todos', 'POST', { title: 'Nauczyć się REST', completed: false, userId: 1 })));

document.getElementById('btn-zmien').addEventListener('click', () =>
  obsluz('PATCH /todos/1 → tylko pole completed', () =>
    zapytanie('/todos/1', 'PATCH', { completed: true })));

document.getElementById('btn-usun').addEventListener('click', () =>
  obsluz('DELETE /todos/1 → 200 i pusty obiekt', () => zapytanie('/todos/1', 'DELETE')));
Podgląd

Zmień w edytorze '/todos?_limit=3' na '/todos/9999' w przycisku GET i zobacz, jak zachowa się zapytanie przy 404.

Formularz bez przeładowania strony

Formularz z method="post" po kliknięciu „Wyślij” przeładowuje stronę – przeglądarka sama wysyła dane i wyświetla odpowiedź serwera. W aplikacji JS chcesz przejąć ten moment: zatrzymać domyślną akcję, zebrać dane, wysłać je fetch i pokazać wynik bez migania strony. Schemat jest zawsze ten sam:

  1. nasłuchuj zdarzenia submit na formularzu (nie click na przycisku – wtedy działa też Enter i walidacja HTML5 required),
  2. event.preventDefault(),
  3. zbierz dane: new FormData(form),
  4. zablokuj przycisk na czas wysyłki, wyślij, pokaż status, odblokuj.
Sprawdź się

Co się stanie po kliknięciu „Wyślij”, jeśli w obsłudze submit zabraknie preventDefault()?

form.addEventListener('submit', async (e) => {
const res = await fetch('/api', { method: 'POST', body: new FormData(form) });
status.textContent = 'Zapisano';
});
Komentarz bez przeładowania
const form = document.getElementById('form-komentarz');
const status = document.getElementById('status');
const lista = document.getElementById('lista');

form.addEventListener('submit', async (event) => {
  event.preventDefault();                               // 1. nie przeładowuj
  const dane = Object.fromEntries(new FormData(form));  // 2. { imie: '…', tresc: '…' }
  const przycisk = form.querySelector('button');
  przycisk.disabled = true;                             // 3. blokada przed podwójnym kliknięciem
  status.textContent = 'Wysyłam…';

  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json; charset=UTF-8' },
      body: JSON.stringify({ name: dane.imie, body: dane.tresc, postId: 1 }),
    });
    if (!res.ok) throw new Error(`status ${res.status}`);
    const zapisany = await res.json();                  // serwer nadał id (tu: 501)

    const li = document.createElement('li');
    li.textContent = `#${zapisany.id} ${zapisany.name}: ${zapisany.body}`;
    lista.prepend(li);
    form.reset();                                       // 4. wyczyść pola
    status.textContent = 'Zapisano.';
  } catch (e) {
    status.textContent = 'Nie udało się: ' + e.message;
  } finally {
    przycisk.disabled = false;
  }
});
Podgląd

Zwróć uwagę: pole imie z required minlength="2" jest sprawdzane przez przeglądarkę zanim odpali się submit. To za darmo dostajesz walidację z lekcji o walidacji.

FormData: dane formularza jako obiekt

FormData to wbudowana klasa, która zbiera pary nazwa → wartość ze wszystkich kontrolek formularza z atrybutem name (bez name pole jest pomijane). Masz dwa sposoby jej użycia:

ChceszZróbCo leci do serwera
wysłać JSONJSON.stringify(Object.fromEntries(new FormData(form))) + nagłówek Content-Type: application/json{"imie":"Ola","tresc":"…"}
wysłać plik lub do $_POST w PHPbody: new FormData(form) – bez ustawiania Content-Typemultipart/form-data jak klasyczny formularz
formdata.js
const fd = new FormData(form);          // z formularza…
fd.append('zrodlo', 'strona-www');      // …plus dodatkowe pole
fd.get('imie');                         // 'Ola' – zawsze napis (albo File)
fd.has('tresc');                        // true
for (const [nazwa, wartosc] of fd) console.log(nazwa, wartosc);

// checkboxy: obecne tylko gdy zaznaczone; kilka o tej samej nazwie → getAll
fd.getAll('zainteresowania');           // ['js', 'php']
Object.fromEntries(fd);                 // uwaga: z duplikatów zostaje ostatni!

Własne API w minutę: json-server

json-server to narzędzie z npm, które z jednego pliku JSON robi pełne REST API z zapisem na dysk. Aktualna wersja to 1.0.0-beta.x (wymaga Node 22.12+). Potrzebujesz Node.js (lekcja o modułach i narzędziach opisuje instalację).

db.json
{
  "zadania": [
    { "id": "1", "tytul": "Powtórzyć fetch", "wykonane": true },
    { "id": "2", "tytul": "Zrobić CRUD", "wykonane": false }
  ]
}
Terminal
npx json-server db.json
# JSON Server started on PORT :3000
# http://localhost:3000

Od tej chwili masz pod http://localhost:3000/zadania wszystkie trasy z tabeli REST, a każdy POST/PATCH/DELETE zmienia plik db.json. Przydatne szczegóły wersji 1.x:

  • id jest napisem; przy POST bez id serwer sam je wygeneruje (losowy krótki napis),
  • filtrowanie i sortowanie w adresie: /zadania?wykonane=true, /zadania?_sort=-id, /zadania?_page=1&_per_page=10,
  • serwer sam udostępnia pliki z folderu ./public – wrzuć tam index.html i app.js, otwórz http://localhost:3000/ i masz front i API pod jednym adresem (zero problemów z CORS),
  • DELETE zwraca 200 i usunięty obiekt (nie 204),
  • --port 4000 zmienia port; json-server --help pokaże resztę.

Pełna lista zadań z zapisem na serwerze – struktura, którą rozbudujesz w zadaniu 3:

public/index.html
<!doctype html>
<html lang="pl">
<head><meta charset="utf-8"><title>Zadania</title></head>
<body>
  <form id="form-zadanie">
    <input name="tytul" placeholder="Nowe zadanie" required>
    <button>Dodaj</button>
  </form>
  <ul id="lista"></ul>
  <p id="status"></p>
  <script src="app.js" defer></script>
</body>
</html>
public/app.js
const lista = document.getElementById('lista');
const status = document.getElementById('status');
const form = document.getElementById('form-zadanie');

async function zapytanie(sciezka, metoda = 'GET', dane) {
  const opcje = { method: metoda, headers: {} };
  if (dane !== undefined) {
    opcje.headers['Content-Type'] = 'application/json';
    opcje.body = JSON.stringify(dane);
  }
  const res = await fetch(sciezka, opcje);          // ten sam adres co strona
  if (!res.ok) throw new Error(`${metoda} ${sciezka}: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

function rysuj(zadania) {
  lista.innerHTML = '';
  for (const z of zadania) {
    const li = document.createElement('li');
    li.innerHTML = `
      <label><input type="checkbox" data-id="${z.id}" ${z.wykonane ? 'checked' : ''}> ${z.tytul}</label>
      <button data-usun="${z.id}">Usuń</button>`;
    lista.append(li);
  }
}

async function odswiez() {
  try {
    rysuj(await zapytanie('/zadania'));
    status.textContent = '';
  } catch (e) {
    status.textContent = 'Błąd: ' + e.message;
  }
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const { tytul } = Object.fromEntries(new FormData(form));
  await zapytanie('/zadania', 'POST', { tytul, wykonane: false });
  form.reset();
  odswiez();
});

// delegacja: jeden nasłuchiwacz na całą listę (lekcja 27)
lista.addEventListener('change', async (e) => {
  if (e.target.matches('input[type=checkbox]')) {
    await zapytanie(`/zadania/${e.target.dataset.id}`, 'PATCH', { wykonane: e.target.checked });
  }
});
lista.addEventListener('click', async (e) => {
  const id = e.target.dataset.usun;
  if (id) {
    await zapytanie(`/zadania/${id}`, 'DELETE');
    odswiez();
  }
});

odswiez();

Po każdej zmianie zajrzyj do db.json – zobaczysz, że serwer naprawdę zapisał dane. To ten moment, w którym „front-end” i „back-end” przestają być abstrakcją.

fetch do skryptu PHP na XAMPP

Z części serwerowej INF.03 znasz PHP i formularze z $_POST. fetch może gadać z PHP dokładnie tak samo – różnica jest tylko w tym, jak wysyłasz dane i co PHP odsyła.

Wariant A – FormData, PHP czyta $_POST jak zawsze:

wyslij-formdata.js
const res = await fetch('zapisz.php', { method: 'POST', body: new FormData(form) });
const odp = await res.json();
zapisz.php
<?php
header('Content-Type: application/json; charset=utf-8');   // mówimy przeglądarce: odsyłam JSON

$imie  = trim($_POST['imie']  ?? '');
$tresc = trim($_POST['tresc'] ?? '');

if ($imie === '' || $tresc === '') {
    http_response_code(400);                                 // zły format → 400
    echo json_encode(['blad' => 'Wypełnij oba pola'], JSON_UNESCAPED_UNICODE);
    exit;
}

// tu normalnie: INSERT do bazy przez mysqli, jak na INF.03
echo json_encode(['ok' => true, 'imie' => $imie, 'dlugosc' => mb_strlen($tresc)], JSON_UNESCAPED_UNICODE);

Wariant B – ciało JSON, PHP czyta php://input:

api.php
<?php
header('Content-Type: application/json; charset=utf-8');

$surowe = file_get_contents('php://input');   // JSON NIE trafia do $_POST!
$dane = json_decode($surowe, true);           // true → tablica asocjacyjna

if (!is_array($dane) || empty($dane['tytul'])) {
    http_response_code(400);
    echo json_encode(['blad' => 'Brak pola tytul']);
    exit;
}

$zadanie = ['id' => rand(100, 999), 'tytul' => $dane['tytul'], 'wykonane' => false];
http_response_code(201);                      // utworzono
echo json_encode($zadanie, JSON_UNESCAPED_UNICODE);
wyslij-json.js
const res = await fetch('api.php', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ tytul: 'Nauka PHP + fetch' }),
});
console.log(res.status, await res.json());   // 201 { id: 537, tytul: '…', wykonane: false }

Trzy rzeczy, o których trzeba pamiętać po stronie PHP:

  • header('Content-Type: application/json') przed jakimkolwiek echo – inaczej nagłówek się nie wyśle,
  • żadnych echo "Zapisano!" ani ostrzeżeń PHP w odpowiedzi – jeden śmieciowy znak i response.json() rzuci SyntaxError: Unexpected token,
  • pliki muszą leżeć w htdocs i być otwierane przez http://localhost/…, nie dwuklikiem (file:// nie uruchomi PHP).

Jeśli strona i PHP są w tym samym folderze htdocs, piszesz adres względny 'zapisz.php' i nie myślisz o CORS. Gdy front działa na Vite (localhost:5173), a PHP na XAMPP (localhost), to już dwa różne źródła – PHP musi dodać header('Access-Control-Allow-Origin: http://localhost:5173'), inaczej przeglądarka zablokuje odczyt odpowiedzi. Przy wysyłaniu JSON (Content-Type: application/json) przeglądarka najpierw pyta metodą OPTIONS (preflight) – PHP musi wtedy odesłać także header('Access-Control-Allow-Headers: Content-Type') i zakończyć skrypt bez dalszej pracy.

Pułapki

Ściąga

SkładniaCo robiPrzykład
fetch(url)GETawait fetch('/zadania')
fetch(url, { method, headers, body })inna metoda z ciałem{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) }
res.ok / res.statusczy 2xx / konkretny kodif (!res.ok) throw new Error(res.status)
await res.json()ciało → obiektconst dane = await res.json()
PATCH vs PUTczęść vs cały obiektPATCH /zadania/1 {"wykonane":true}
form.addEventListener('submit', fn)wysyłka także Enterem, z walidacją HTML5e.preventDefault() w środku
new FormData(form)pary name→valueObject.fromEntries(new FormData(form))
body: new FormData(form)jak klasyczny formularz ($_POST, pliki)bez Content-Type!
npx json-server db.jsonREST API z pliku na :3000./public serwowany automatycznie
file_get_contents('php://input')PHP: surowe ciało JSONjson_decode($surowe, true)
header('Content-Type: application/json')PHP: odpowiedź to JSONprzed pierwszym echo
http_response_code(201)PHP: ustaw status400 dla błędu danych

Zadania

Zadanie 1
łatwe

Tłumacz kodów statusu

Napisz funkcję opiszStatus(kod), która zwraca napis w formacie "<kod> – <opis>" dla kodów 200, 201, 204, 400, 401, 403, 404, 500. Dla innego kodu z zakresu 200–299 zwróć "<kod> – sukces", z zakresu 400–499 "<kod> – błąd klienta", 500–599 "<kod> – błąd serwera", a poza tym "<kod> – nieznany". Przetestuj na 10 wartościach, w tym 299, 418, 503, 42.

Pokaż rozwiązanie
status.js
const OPISY = {
  200: 'OK',
  201: 'utworzono',
  204: 'brak treści',
  400: 'złe żądanie',
  401: 'wymagane logowanie',
  403: 'brak uprawnień',
  404: 'nie znaleziono',
  500: 'błąd serwera',
};

function opiszStatus(kod) {
  // najpierw dokładne dopasowanie, potem kategoria po pierwszej cyfrze
  if (OPISY[kod]) return `${kod} – ${OPISY[kod]}`;
  if (kod >= 200 && kod <= 299) return `${kod} – sukces`;
  if (kod >= 400 && kod <= 499) return `${kod} – błąd klienta`;
  if (kod >= 500 && kod <= 599) return `${kod} – błąd serwera`;
  return `${kod} – nieznany`;
}

for (const kod of [200, 201, 204, 299, 400, 404, 418, 500, 503, 42]) {
  console.log(opiszStatus(kod));
}
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik
Zadanie 2
średnie

Zgłoszenie usterki bez przeładowania

Zbuduj formularz zgłoszenia usterki w pracowni: pole sala (liczba 1–30, required), opis (textarea, required, min 10 znaków), lista priorytet (niski/średni/wysoki). Po wysłaniu:

  • strona nie może się przeładować,
  • dane lecą POST jako JSON na https://jsonplaceholder.typicode.com/posts (pola: title = "Sala <nr>", body = opis, userId = 1 dla niskiego, 2 dla średniego, 3 dla wysokiego),
  • w trakcie wysyłki przycisk jest zablokowany, a w <p id="status"> jest napis Wysyłam…,
  • po sukcesie: Zgłoszenie nr <id> przyjęte. i wyczyszczony formularz; po błędzie: Błąd: <treść> na czerwono.
Pokaż rozwiązanie
Zgłoszenie usterki
const form = document.getElementById('form-usterka');
const status = document.getElementById('status');
const PRIORYTET_NA_ID = { niski: 1, 'średni': 2, wysoki: 3 };

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const dane = Object.fromEntries(new FormData(form));
  const przycisk = form.querySelector('button');

  przycisk.disabled = true;
  status.className = '';
  status.textContent = 'Wysyłam…';

  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json; charset=UTF-8' },
      body: JSON.stringify({
        title: `Sala ${dane.sala}`,
        body: dane.opis,
        userId: PRIORYTET_NA_ID[dane.priorytet],
      }),
    });
    if (!res.ok) throw new Error(`serwer odpowiedział ${res.status}`);
    const zapisane = await res.json();
    status.textContent = `Zgłoszenie nr ${zapisane.id} przyjęte.`;
    form.reset();
  } catch (err) {
    status.className = 'blad';
    status.textContent = 'Błąd: ' + err.message;
  } finally {
    przycisk.disabled = false;
  }
});
Podgląd
Zadanie 3
średnie

Lista zakupów na json-server

Stwórz folder zakupy, w nim db.json z zasobem produkty (pola: nazwa, ilosc, kupione) i trzema przykładowymi rekordami oraz folder public z index.html i app.js. Uruchom npx json-server db.json i zbuduj aplikację, która:

  1. po załadowaniu pobiera GET /produkty i rysuje listę (nazwa × ilość, checkbox „kupione”, przycisk „+1”, przycisk „Usuń”),
  2. formularz dodaje produkt (POST),
  3. checkbox zmienia pole kupione (PATCH),
  4. „+1” zwiększa ilosc o 1 (PATCH z nową wartością),
  5. „Usuń” usuwa (DELETE),
  6. na dole wyświetla Kupione: X z Y.

Sprawdź po każdej operacji, co zmieniło się w db.json.

Pokaż rozwiązanie
db.json
{
  "produkty": [
    { "id": "1", "nazwa": "Chleb", "ilosc": 1, "kupione": false },
    { "id": "2", "nazwa": "Mleko", "ilosc": 2, "kupione": true },
    { "id": "3", "nazwa": "Jabłka", "ilosc": 6, "kupione": false }
  ]
}
public/index.html
<!doctype html>
<html lang="pl">
<head><meta charset="utf-8"><title>Zakupy</title></head>
<body>
  <h1>Lista zakupów</h1>
  <form id="form-produkt">
    <input name="nazwa" placeholder="Produkt" required>
    <input name="ilosc" type="number" min="1" value="1" required>
    <button>Dodaj</button>
  </form>
  <ul id="lista"></ul>
  <p id="podsumowanie"></p>
  <p id="status"></p>
  <script src="app.js" defer></script>
</body>
</html>
public/app.js
const lista = document.getElementById('lista');
const podsumowanie = document.getElementById('podsumowanie');
const status = document.getElementById('status');
const form = document.getElementById('form-produkt');

let produkty = []; // lokalna kopia stanu – żeby „+1” znało aktualną ilość

async function zapytanie(sciezka, metoda = 'GET', dane) {
  const opcje = { method: metoda, headers: {} };
  if (dane !== undefined) {
    opcje.headers['Content-Type'] = 'application/json';
    opcje.body = JSON.stringify(dane);
  }
  const res = await fetch(sciezka, opcje);
  if (!res.ok) throw new Error(`${metoda} ${sciezka}: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

function rysuj() {
  lista.innerHTML = '';
  for (const p of produkty) {
    const li = document.createElement('li');
    li.innerHTML = `
      <label>
        <input type="checkbox" data-akcja="kupione" data-id="${p.id}" ${p.kupione ? 'checked' : ''}>
        ${p.nazwa} × ${p.ilosc}
      </label>
      <button data-akcja="plus" data-id="${p.id}">+1</button>
      <button data-akcja="usun" data-id="${p.id}">Usuń</button>`;
    lista.append(li);
  }
  const kupione = produkty.filter((p) => p.kupione).length;
  podsumowanie.textContent = `Kupione: ${kupione} z ${produkty.length}`;
}

async function odswiez() {
  try {
    produkty = await zapytanie('/produkty');
    rysuj();
    status.textContent = '';
  } catch (e) {
    status.textContent = 'Błąd: ' + e.message;
  }
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const dane = Object.fromEntries(new FormData(form));
  try {
    await zapytanie('/produkty', 'POST', {
      nazwa: dane.nazwa,
      ilosc: Number(dane.ilosc), // FormData daje napisy!
      kupione: false,
    });
    form.reset();
    odswiez();
  } catch (err) {
    status.textContent = 'Błąd: ' + err.message;
  }
});

// jeden nasłuchiwacz na listę: zmiana checkboxa
lista.addEventListener('change', async (e) => {
  if (e.target.dataset.akcja !== 'kupione') return;
  try {
    await zapytanie(`/produkty/${e.target.dataset.id}`, 'PATCH', { kupione: e.target.checked });
    odswiez();
  } catch (err) {
    status.textContent = 'Błąd: ' + err.message;
    e.target.checked = !e.target.checked; // cofnij, skoro serwer nie zapisał
  }
});

// jeden nasłuchiwacz na listę: przyciski
lista.addEventListener('click', async (e) => {
  const { akcja, id } = e.target.dataset;
  if (!akcja || akcja === 'kupione') return;
  try {
    if (akcja === 'plus') {
      const produkt = produkty.find((p) => p.id === id);
      await zapytanie(`/produkty/${id}`, 'PATCH', { ilosc: produkt.ilosc + 1 });
    } else if (akcja === 'usun') {
      await zapytanie(`/produkty/${id}`, 'DELETE');
    }
    odswiez();
  } catch (err) {
    status.textContent = 'Błąd: ' + err.message;
  }
});

odswiez();
Zadanie 4
trudne

Licznik głosów w PHP + fetch

W htdocs/glosowanie/ zbuduj ankietę „Który język wolisz?” z trzema przyciskami (JavaScript, PHP, Python) i wynikami pod spodem. Po stronie serwera napisz glosy.php, który:

  • przy GET odsyła JSON {"JavaScript": 3, "PHP": 1, "Python": 2} (dane trzymaj w pliku glosy.json obok skryptu, czytaj file_get_contents, zapisuj file_put_contents),
  • przy POST z ciałem JSON {"jezyk": "PHP"} zwiększa licznik i odsyła zaktualizowany obiekt ze statusem 200,
  • dla nieznanego języka odsyła 400 z {"blad": "Nieznany język"}.

Po stronie klienta: po załadowaniu GET, po kliknięciu POST, wyniki rysowane jako lista z procentami (zaokrąglonymi do całości), przyciski zablokowane podczas żądania, komunikat błędu z blad z odpowiedzi serwera.

Pokaż rozwiązanie
glosy.php
<?php
header('Content-Type: application/json; charset=utf-8');

$plik = __DIR__ . '/glosy.json';
$dozwolone = ['JavaScript', 'PHP', 'Python'];

// wczytaj stan (albo zera, gdy pliku jeszcze nie ma)
$glosy = file_exists($plik) ? json_decode(file_get_contents($plik), true) : [];
foreach ($dozwolone as $j) {
    $glosy[$j] = $glosy[$j] ?? 0;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $dane = json_decode(file_get_contents('php://input'), true);
    $jezyk = $dane['jezyk'] ?? '';

    if (!in_array($jezyk, $dozwolone, true)) {
        http_response_code(400);
        echo json_encode(['blad' => 'Nieznany język'], JSON_UNESCAPED_UNICODE);
        exit;
    }

    $glosy[$jezyk]++;
    file_put_contents($plik, json_encode($glosy));
}

// GET oraz udany POST kończą się tak samo: aktualny stan
echo json_encode($glosy);
index.html
<!doctype html>
<html lang="pl">
<head><meta charset="utf-8"><title>Głosowanie</title></head>
<body>
  <h1>Który język wolisz?</h1>
  <p id="przyciski">
    <button data-jezyk="JavaScript">JavaScript</button>
    <button data-jezyk="PHP">PHP</button>
    <button data-jezyk="Python">Python</button>
  </p>
  <ul id="wyniki"></ul>
  <p id="status"></p>
  <script src="app.js" defer></script>
</body>
</html>
app.js
const przyciski = document.getElementById('przyciski');
const wyniki = document.getElementById('wyniki');
const status = document.getElementById('status');

function rysuj(glosy) {
  const suma = Object.values(glosy).reduce((a, b) => a + b, 0);
  wyniki.innerHTML = '';
  for (const [jezyk, liczba] of Object.entries(glosy)) {
    const procent = suma === 0 ? 0 : Math.round((liczba / suma) * 100);
    const li = document.createElement('li');
    li.textContent = `${jezyk}: ${liczba} (${procent}%)`;
    wyniki.append(li);
  }
}

async function zapytanie(opcje) {
  const res = await fetch('glosy.php', opcje);
  const dane = await res.json();                 // PHP zawsze odsyła JSON – także przy 400
  if (!res.ok) throw new Error(dane.blad ?? `status ${res.status}`);
  return dane;
}

function blokuj(stan) {
  for (const b of przyciski.querySelectorAll('button')) b.disabled = stan;
}

przyciski.addEventListener('click', async (e) => {
  const jezyk = e.target.dataset.jezyk;
  if (!jezyk) return;
  blokuj(true);
  status.textContent = 'Zapisuję głos…';
  try {
    rysuj(await zapytanie({
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jezyk }),
    }));
    status.textContent = `Dziękujemy za głos na ${jezyk}.`;
  } catch (err) {
    status.textContent = 'Błąd: ' + err.message;
  } finally {
    blokuj(false);
  }
});

// start: pobierz aktualne wyniki
zapytanie().then(rysuj).catch((err) => (status.textContent = 'Błąd: ' + err.message));

Uwaga do testów: zmień w app.js jezyk na 'Rust' (albo wyślij z konsoli fetch('glosy.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"jezyk":"Rust"}' })) i sprawdź, że dostajesz 400 z komunikatem Nieznany język.

Test powtórkowy

Test powtórkowy

Dziewięć pytań o REST, fetch i formularze. Jedna poprawna odpowiedź.

0/0poprawnych
Sprawdź się

Które żądanie w stylu REST tworzy nowe zadanie?

Sprawdź się

Serwer odpowiedział statusem 201. Co to znaczy?

Sprawdź się

Co zwróci res.ok, gdy serwer odpowie statusem 500?

Sprawdź się

Który fragment poprawnie wysyła obiekt dane jako JSON?

Sprawdź się

Które zdarzenie i metoda zapobiegają przeładowaniu strony po wysłaniu formularza?

Sprawdź się

Co zawiera zmienna dane?

// <form><input name="imie" value="Ola"><input name="wiek" value="17"></form>
const dane = Object.fromEntries(new FormData(form));
Sprawdź się

Wysyłasz plik przez body: new FormData(form). Który nagłówek Content-Type ustawiasz ręcznie?

Sprawdź się

Jak skrypt PHP odczytuje ciało żądania wysłane jako JSON?

Sprawdź się

Które polecenie uruchamia lokalne REST API z pliku db.json w aktualnej wersji json-server?