Заняття 05 · місяць 2

Форма, кнопка, запис у базі

90 хвилин

Мета заняття: людина заповнює форму на твоїй сторінці — дані зберігаються в базі, і новий запис одразу видно в списку.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін.

Що ти зробиш сьогодні

Зараз твій сервіс уміє лише показувати те, що ти сам додав у базу з термінала. Додавав ти командою INSERT — це команда мови SQL, якою на занятті 04 ти звертався до бази даних: SELECT читає рядки з таблиці, INSERT додає новий рядок, DELETE видаляє. Сьогодні на сторінці з’явиться форма з полями й кнопкою: людина, яка не має доступу до сервера й не знає SQL, заповнює її, натискає кнопку — і запис потрапляє в базу.

Крім кнопки «додати» буде кнопка «видалити» біля кожного запису. І окремо ти зробиш так, щоб на зіпсовані дані сервіс відповідав зрозумілим текстом і не припиняв роботу: порожня назва має отримати відповідь «дані не пройшли перевірку», а спроба видалити запис, якого вже немає, — відповідь «такого немає».

Теорія

1. Чотири різні дії за однією адресою

Коли браузер звертається до сервера, він надсилає не тільки адресу, а ще й одне слово перед нею — метод HTTP. Метод каже, що саме треба зробити з тим, що лежить за цією адресою.

HTTP — це набір правил, за якими браузер і сервер обмінюються повідомленнями. Коли ти відкриваєш сторінку, браузер надсилає повідомлення на кшталт «GET /u/bohdan.g9/» і отримує у відповідь текст сторінки. Усе, що ти робив на заняттях 02 і 04, було методом GET: відкрив адресу в браузері — браузер зробив GET.

GET
«Покажи мені те, що там є». Нічого не змінює. Саме цей метод браузер надсилає, коли ти вводиш адресу в рядок або клацаєш посилання.
POST
«Додай ось ці дані». Разом із методом сервер отримує вміст — наприклад, назву нової звички. Це те, що робить кнопка «надіслати» під формою.
DELETE
«Видали те, що за цією адресою». Даних із собою не несе — все, що потрібно, є в самій адресі: /api/habits/7 означає «запис із номером 7».
API
Набір адрес, за якими дані забирає інша програма, а не людина. У твоєму сервісі це все, що починається з /api/. Термін уже був на занятті 02, нагадуємо, бо сьогодні він трапиться кілька разів.
Ендпоінт
Окрема адреса всередині API разом із методом. Пара «метод + адреса», яку вміє обробляти твій сервіс: GET /api/habits і POST /api/habits — це два різні ендпоінти, хоча адреса в них однакова.

Адреса може повторюватися, а пара «метод + адреса» — ні. У коді FastAPI це видно одразу: рядок над функцією називає і метод, і адресу.

@app.get("/api/habits")      # віддати список
@app.post("/api/habits")     # додати запис
@app.delete("/api/habits/{habit_id}")   # видалити запис за номером

Чому зміни не роблять через GET

Технічно ніщо не заважає написати ендпоінт GET /api/habits/7/delete і видаляти запис при відкритті цієї адреси. Так робити не можна, і причина практична.

GET вважається безпечним: усі, хто працює з вебом, виходять із того, що відкрити адресу методом GET можна скільки завгодно разів без наслідків. Тому браузер може завантажити посилання наперед, поки ти ще тільки навів на нього курсор. Месенджер, у який ти кинув посилання, відкриває його сам, щоб показати попередній перегляд. Антивірус і поштові фільтри перевіряють посилання так само — заходять і дивляться. Якщо за GET-адресою стоїть видалення, усі ці програми почнуть видаляти твої записи, і жодна з них не зробить нічого поганого зі свого боку.

Питання на розуміння

Оля зробила біля кожного запису звичайне посилання на адресу /api/habits/3/delete, і воно справді видаляло запис. Через кілька днів вона скинула посилання на свій сайт у групу в месенджері, а наступного ранку побачила, що частина записів зникла, хоча вона нічого не натискала. Що сталося?

2. JSON — формат, у якому передають дані

Форма має надіслати на сервер назву звички й кількість днів. Треба якось запакувати ці два значення в один текст, який сервер зможе розібрати назад. Для цього використовують JSON — текстовий формат запису даних, який розуміють усі мови програмування.

{"title": "Читати 20 хвилин", "days": 7}

Виглядає майже як словник Python, і читається так само: ключ, двокрапка, значення. Правил небагато:

Твій ендпоінт GET /api/habits уже віддає JSON — саме тому в браузері він показує текст у фігурних і квадратних дужках, а не звичну сторінку. Сьогодні той самий формат працюватиме у зворотному напрямку: сторінка надсилатиме JSON серверу.

3. Код відповіді: що сервер каже про результат

На кожен запит сервер відповідає трицифровим числом — кодом стану. Це коротка відповідь на питання «як усе минуло», і вона надходить раніше за самі дані. Ти вже бачив код 200 на занятті 02, коли перевіряв, що /api/ping працює.

Перша цифра ділить коди на групи, і в нашій роботі важливі три:

КодЩо означаєПриклад
200Усе гаразд, ось дані Сторінка відкрилася; список звичок віддано
404Такого немає Просять запис із номером 999, а такого рядка в таблиці немає
422Дані не пройшли перевірку Надіслали порожню назву або текст там, де очікується число
500Помилка на боці сервера У коді ендпоінта сталася помилка, і функція не змогла нічого повернути

Різниця між групою 4xx і групою 5xx принципова, і сьогодні вона тобі знадобиться в обох критеріях перевірки.

Практичний висновок такий. Порожнє поле форми — звичайна ситуація, яку код має передбачити й відповісти на неї кодом 422. Якщо у відповідь приходить 500, це означає, що порожнє поле в коді не передбачене й функція на ньому зупинилася з помилкою. Тому критерій заняття сформульований саме так: порожня назва має давати 422.

Питання на розуміння

Сусід перевіряє твій сервіс. Він надсилає форму, залишивши поле назви порожнім, і бачить у відповідь код 500. Що це означає?

4. Валідація: чому перевірки у браузері недостатньо

Валідація — перевірка даних перед тим, як їх використати: чи є обов’язкові поля, чи не порожні вони, чи справді число там, де має бути число.

Перевірити можна у двох місцях, і вони роблять різну роботу.

Перевірка у формі, у браузері
Атрибути required, maxlength, type="number". Браузер сам не дає надіслати форму й підсвічує поле. Це зручність для людини: помилку видно одразу, без звернення до сервера.
Перевірка на сервері
Код усередині ендпоінта, який дивиться на те, що реально прийшло. Це єдина перевірка, яку не можна обійти.

Обійти перевірку у формі просто, і для цього не треба нічого вміти. Запит до твого сервісу можна надіслати командою curl прямо з термінала, узагалі не відкриваючи сторінку. curl — програма командного рядка, яка надсилає запит на вказану адресу й друкує відповідь текстом; ти вже користувався нею на заняттях 01 і 02, сьогодні вона знадобиться знову. Або можна відкрити інструменти розробника — вбудовану в браузер панель, яка відкривається клавішею F12 і показує код сторінки, помилки й усі запити до сервера, — і прибрати атрибут required двома клацаннями. Тому все, що написано у формі, для сервера є побажанням, а не гарантією.

Модель Pydantic

Писати перевірки вручну в кожному ендпоінті довго й легко щось забути. FastAPI вміє перевіряти вхідні дані сам, якщо ти опишеш, які поля очікуєш. Такий опис називається моделлю Pydantic. Pydantic — бібліотека, яка цим займається; вона вже встановлена у спільному віртуальному оточенні групи /opt/club/venv — це тека з бібліотеками Python, одна на всіх, про неї йшлося на занятті 02. Установлювати нічого не треба.

from pydantic import BaseModel, Field

class NewHabit(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    days: int = Field(ge=1, le=365)

Читається це так: очікуємо два поля. title — текст довжиною від 1 до 200 символів. days — ціле число від 1 до 365 (ge — «не менше», le — «не більше»).

Далі ти пишеш у функції ендпоінта item: NewHabit, і FastAPI бере перевірку на себе. Якщо дані не підходять під опис, функція навіть не запускається — у відповідь іде код 422 і текст із переліком полів, які не пройшли. Якщо підходять, у функцію приходить готовий об’єкт, у якого item.title точно рядок, а item.days точно число.

Чого модель Pydantic не перевіряє Рядок із трьох пробілів має довжину 3, тому min_length=1 його пропустить. Тому в самому ендпоінті ми додатково прибираємо пробіли по краях методом .strip() і перевіряємо, чи щось лишилося.
Питання на розуміння

Максим поставив у полі назви required і maxlength="200" і каже, що тепер порожня назва в базу не потрапить ніколи, а перевірку на сервері писати зайве. Він має рацію?

5. Видалення того, чого вже немає

Ситуація трапляється щодня. Людина натиснула «видалити», нічого не відбулося за півсекунди, вона натиснула ще раз. Або відкрила сайт у двох вкладках, видалила запис в одній, а в другій список ще старий і кнопка на місці.

Перший запит видалив рядок і відповів 200. Другий приходить до сервера, коли рядка вже немає. Правильна відповідь тут — 404, «такого немає». Це відповідь із групи 4xx: запит опрацьовано, у таблиці шукали, потрібного рядка не знайшли, і код 404 повідомляє саме про це.

Щоб так вийшло, ендпоінт спочатку перевіряє, чи є такий рядок, і лише потім видаляє. Якщо написати одразу DELETE FROM habit WHERE id = ? без перевірки, помилки не буде: SQLite видалить нуль рядків і поверне звичайний результат. Сервіс відповість 200, хоча насправді нічого не сталося, і сторінка покаже людині, що все гаразд.

Питання на розуміння

Тарас написав видалення без попереднього SELECT — одразу DELETE FROM habit WHERE id = ?. Через тиждень до нього приходить однокласниця: вона видалила запис №4, а наступного дня знову бачить його в списку й каже, що сервіс «видаляє через раз». Насправді запис №4 вона видалила один раз, а вдруге натиснула кнопку в старій вкладці, яку не оновлювала. Що показала їй сторінка вдруге і чому Тарас цього не помітив у себе?

6. fetch: сторінка звертається до сервера сама

Звичайна форма в HTML працює так: натиснув кнопку — браузер звернувся до сервера й повністю перезавантажив сторінку. Це помітно: сторінка на мить стає порожньою, потім показується наново, і прокрутка повертається на початок.

fetch — команда JavaScript, яка надсилає запит на сервер із самої сторінки, не перезавантажуючи її. Відповідь приходить у вигляді даних, і код на сторінці сам вирішує, що з ними зробити: дописати рядок у список, показати повідомлення, очистити форму.

JavaScript — мова, якою пишуть код, що виконується в браузері, а не на сервері. Ми її не вивчаємо. Сьогодні блок із fetch ти отримаєш готовим і підставиш у нього назви своїх полів.

форма на сторінці --fetch POST--> ендпоінт FastAPI --INSERT--> таблиця в базі
       ^                                                              |
       +----------- список оновився (fetch GET) <---------------------+

Ще одна деталь, через яку найчастіше плутаються. У браузера є власна, стара поведінка: натиснули кнопку в формі — перезавантажуємо сторінку. Вона спрацьовує сама, поза твоїм кодом. Щоб її зупинити, у першому ж рядку обробника пишуть event.preventDefault() — дослівно «скасувати дію за замовчуванням». Без цього рядка сторінка перезавантажиться, і твій fetch не встигне нічого зробити.

Питання на розуміння

Дарина натискає «Додати» — сторінка блимає й перезавантажується, поля порожні, нового запису в списку немає. У журналі служби жодного запиту POST не видно. Що найімовірніше пропущено?

Практика

У прикладах — таблиця habit з полями id, title, days. Це та сама таблиця зі зразка заняття 04. Скрізь підставляй назву своєї таблиці й своїх полів — вони записані у project.yaml у розділі database. project.yaml — це маніфест проєкту, який ти заповнював на занятті 01: короткий файл із назвами, які програма перевірки не має вгадувати, а читає звідти. Нагадування, як виглядає таблиця зразка:

CREATE TABLE habit (
  id    INTEGER PRIMARY KEY,
  title TEXT NOT NULL,
  days  INTEGER NOT NULL
);

NOT NULL у цьому описі означає «у стовпці має бути значення»: додати рядок, не вказавши title, база не дасть. Зверни увагу на межу цієї перевірки — порожній рядок '' для бази теж значення, і NOT NULL його пропускає. Саме тому порожню назву доводиться відсіювати в коді, а не сподіватися на базу.

1

Зайти на сервер і подивитися, що є зараз

ssh bohdan.g9@91.219.61.4
curl -s http://127.0.0.1/u/bohdan.g9/api/habits

Ключ -s прибирає смужку прогресу, щоб вона не заважала читати відповідь. Ти маєш побачити список записів у квадратних дужках — той самий, що віддає ендпоінт із заняття 04. Якщо замість списку порожні дужки [], база порожня: додай кілька рядків командою INSERT, як на минулому занятті.

Чому адреса не така, як на занятті 04 На занятті 04 ти звертався прямо до своєї програми: curl -s http://127.0.0.1:$PORT/api/habits, де $PORT — особистий номер порту твоєї служби. Тут інший шлях: запит іде на порт 80, де стоїть nginx, а вже він передає його твоїй програмі. Різниця в тому, що через nginx адреса має префікс /u/логін/, а напряму — не має. Сьогодні беремо саме шлях через nginx: сторінка в браузері звертатиметься до сервісу так само, і команда в терміналі перевіряє рівно те, що робитиме сторінка. Якщо ця команда нічого не повертає, спробуй спосіб із заняття 04 — тоді буде видно, чи проблема у твоїй програмі, чи в nginx:
PORT=$(grep -oP '(?<=--port )\d+' ~/.config/systemd/user/club-api.service)
curl -s http://127.0.0.1:$PORT/api/habits
2

Дописати імпорти у main.py

nano ~/app/main.py

У верхній частині файлу вже є п’ять рядків з import. Зараз вони виглядають так:

from pathlib import Path

import yaml
from fastapi import FastAPI
from fastapi.responses import JSONResponse

from db import query  # готова заготовка, не редагуємо

Змінити треба два рядки й додати один. Решту не чіпай: Path, yaml і JSONResponse потрібні заготовці, і без них служба не запуститься. Має вийти так:

from pathlib import Path

import yaml
from fastapi import FastAPI, HTTPException   # ← дописав HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field        # ← цілком новий рядок

from db import query, execute  # ← дописав execute; сам db.py не редагуємо

Позначки після знака # — це коментарі: Python їх не виконує, тож блок можна скопіювати цілком. Свої пояснення після вставляння можеш стерти.

Що додалося. HTTPException — спосіб перервати роботу функції й відповісти потрібним кодом; ним ми віддамо 404. BaseModel і Field потрібні для моделі. execute — функція з db.py, яка виконує INSERT і DELETE та повертає номер щойно доданого рядка; query лишається для SELECT. Нічого встановлювати не треба, усі бібліотеки вже є у спільному оточенні /opt/club/venv.

3

Описати модель вхідних даних

Прокрути main.py нижче ендпоінта /api/about. Там є закоментований блок із заголовком ЗАНЯТТЯ 05. Додавання й видалення запису. — кілька рядків, які починаються зі знака #. Це начерк заготовки: у ньому інші назви (NewItem, create_item) і він неповний. Найпростіше — видалити ці рядки цілком і написати на їхньому місці свій код із кроків 3, 4 і 5. Рядок # from pydantic import BaseModel, Field звідти теж видали: цей імпорт ти вже дописав угорі файлу на кроці 2, двічі він не потрібен.

Назви полів у моделі мають збігатися з назвами стовпців твоєї таблиці — так менше плутанини.

class NewHabit(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    days: int = Field(ge=1, le=365)
Порядок у файлі має значення Клас моделі має стояти вище за функцію, яка на нього посилається. Python читає файл згори вниз: якщо class NewHabit опиниться нижче за @app.post, під час запуску буде NameError: name 'NewHabit' is not defined і служба не підніметься.

Верхні межі тут не для краси. max_length=200 зупинить запит із текстом на п’ять тисяч символів, який інакше повністю запишеться в базу. le=365 зупинить число на кшталт мільйона. Підбирай межі під свої дані: для поля «вік» розумно ge=1, le=120, для короткої нотатки — max_length=500.

4

Написати ендпоінт POST

@app.post("/api/habits")
def create_habit(item: NewHabit):
    """Додає запис і повертає його разом із новим id."""
    title = item.title.strip()
    if not title:
        raise HTTPException(status_code=422, detail="Назва не може бути порожня")

    new_id = execute(
        "INSERT INTO habit (title, days) VALUES (?, ?)",
        (title, item.days),
    )
    rows = query("SELECT * FROM habit WHERE id = ?", (new_id,))
    return rows[0]

Розбір по частинах:

Успішна відповідь тут матиме код 200. У багатьох сервісів на створення прийнято відповідати кодом 201 «створено»; для нашої перевірки підходить будь-який із них.

5

Написати ендпоінт DELETE

@app.delete("/api/habits/{habit_id}")
def delete_habit(habit_id: int):
    """Видаляє запис. Якщо такого немає — 404."""
    rows = query("SELECT id FROM habit WHERE id = ?", (habit_id,))
    if not rows:
        raise HTTPException(status_code=404, detail="Такого запису немає")

    execute("DELETE FROM habit WHERE id = ?", (habit_id,))
    return {"deleted": habit_id}

Фігурні дужки в адресі — це змінна частина. Запит на /api/habits/7 призведе до того, що в аргумент habit_id прийде число 7. Позначка : int важлива: якщо хтось звернеться на /api/habits/abc, FastAPI сам відповість 422, бо abc не число, і твій код навіть не запуститься.

Спочатку SELECT, і тільки якщо рядок знайшовся — DELETE. Без цієї перевірки видалення неіснуючого номера пройшло б «успішно», бо SQLite вважає видалення нуля рядків нормальним результатом.

Чому у відповіді не буде твого тексту Внизу main.py у заготовці є оброблювач помилки 404, який повертає всім однаковий вигляд: {"error": "not_found"}. Тому текст із detail="Такого запису немає" назовні не потрапить — вийде саме {"error": "not_found"} з кодом 404. Це нормально й задумано: формат помилки в API має бути однаковий скрізь. Код відповіді при цьому саме той, який потрібен.
6

Записати нові адреси у project.yaml

Автоматична перевірка не вгадує назви твоїх ендпоінтів — вона читає їх із project.yaml. Поки поля порожні, критерії не зарахуються, навіть якщо код ідеальний.

nano ~/app/project.yaml
endpoints:
  ping: "GET /api/ping"
  about: "GET /api/about"
  list: "GET /api/habits"
  create: "POST /api/habits"
  delete: "DELETE /api/habits/{habit_id}"
  login: ""

У рядку delete пиши в фігурних дужках те саме ім’я, що й у декораторі @app.delete з кроку 5. У прикладі це {habit_id}; якщо в тебе аргумент функції називається note_id, то й у маніфесті має стояти {note_id}. Одне ім’я в трьох місцях: у декораторі, в аргументі функції та в маніфесті.

Формат файлу — YAML: у кожному рядку ключ, двокрапка й значення, а вкладеність показують відступом. Відступи робляться тільки пробілами — табуляція ламає файл. Скільки саме пробілів, формат не диктує, важлива однаковість; у нашому курсі домовилися про два, як у решті цього файлу. Зіпсований project.yaml не дасть службі запуститися взагалі: і db.py, і main.py читають маніфест одразу при старті, ще до того, як програма почне приймати запити.

7

Перезапустити службу й перевірити, що вона піднялася

systemctl --user restart club-api
systemctl --user status club-api

Нагадування з заняття 02. Служба — програма, яку система запускає сама й тримає запущеною постійно; твоя зветься club-api, і це саме та програма, що відповідає на запити до /api/. Керує службами systemctl. Ключ --user означає «моя власна служба, а не загальносерверна» — без нього команда шукала б службу адміністратора й не знайшла б твоєї.

Служба тримає в пам’яті ту версію коду, з якою її запустили, тому без перезапуску правки ні на що не вплинуть. У виводі status шукай слово active (running). Якщо стоїть failed, дивись останні рядки журналу:

journalctl --user -u club-api -n 30

Журнал — запис усього, що служба надрукувала під час роботи, разом з усіма помилками; читає його команда journalctl. Ключ -u club-api каже «тільки записи цієї служби», -n 30 — «останні 30 рядків». В останніх рядках буде назва файлу, номер рядка й текст помилки Python. Найчастіше це IndentationError (збилися відступи) або NameError (забули імпорт із кроку 2 чи поставили модель нижче за функцію).

8

Перевірити ендпоінти командою curl

Спочатку нормальний запит. Ключ -i просить надрукувати ще й службові рядки відповіді — у першому з них видно код. -X POST задає метод, -H "Content-Type: application/json" повідомляє серверу, що в тілі запиту JSON, -d — саме тіло.

curl -i -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"Читати 20 хвилин","days":7}'

Очікуємо перший рядок HTTP/1.1 200 OK і нижче JSON із новим записом, де вже є id. Тепер обов’язкова вправа заняття — свідомо погані дані:

curl -i -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"","days":7}'

Має бути HTTP/1.1 422 Unprocessable Entity. Якщо бачиш 500 Internal Server Error — критерій про порожню назву не зарахується (усі три критерії заняття перелічені в таблиці нижче); читай журнал і дивись, на чому саме зупинилася функція. Ще два запити, які варто зробити зараз:

curl -i -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"Пробіли","days":"сім"}'

curl -i -X DELETE http://127.0.0.1/u/bohdan.g9/api/habits/999999

Перший має дати 422 (текст замість числа), другий — 404. Зверни увагу: у першому випадку твій код узагалі не запускався, усе зробила модель.

Адреса 127.0.0.1 і адреса 91.219.61.4 127.0.0.1 означає «цей самий комп’ютер» і працює тільки в терміналі на сервері. З дому ті самі команди виконуються з адресою http://91.219.61.4/…. Шлях після адреси однаковий.
9

Додати форму на сторінку

Сторінка лежить у файлі ~/www/index.html. Редагувати її можна двома способами: nano ~/www/index.html прямо на сервері або через FTP у звичному редакторі на своєму комп’ютері. Налаштування ті самі, що на занятті 01: адреса 91.219.61.4, порт 21, шифрування «явний FTP через TLS», логін і пароль твої звичайні. Коротко про ці слова. FTP — правила передавання файлів між твоїм комп’ютером і сервером; програма на кшталт FileZilla показує теки на сервері, і файли перетягуються мишею. Порт — номер, за яким на одній машині розрізняють різні служби: 80 — веб, 22 — SSH, 21 — FTP. TLS — шифрування з’єднання, те саме, яким користується SSH: без нього логін, пароль і вміст файлів ішли б мережею відкритим текстом.

Всередину <body> додай розмітку форми й місце для списку. Атрибут for у <label> має збігатися з id поля — тоді клацання по підпису ставить курсор у поле.

<h2>Додати звичку</h2>
<form id="add-form">
  <label for="f-title">Назва звички</label>
  <input id="f-title" type="text" required maxlength="200">

  <label for="f-days">Скільки днів поспіль</label>
  <input id="f-days" type="number" min="1" max="365" value="1" required>

  <button type="submit">Додати</button>
</form>
<p id="form-msg"></p>

<h2>Мої звички</h2>
<ul id="list"></ul>

У тега <form> немає атрибутів action і method: запит надсилатиме код, а не сама форма.

10

Підставити готовий блок fetch

Цей блок додається в кінці сторінки, перед закривальним тегом </body>. Стрілкою позначені всі місця, які залежать від твого проєкту: адреса ендпоінта, назви твоїх стовпців і назви id з розмітки кроку 9. Якщо ти взяв розмітку кроку 9 без змін, рядки з getElementById міняти не треба — імена add-form, form-msg, list, f-title, f-days уже збігаються.

<script>
const API = 'api/habits';                            // ← адреса твого ендпоінта

const form   = document.getElementById('add-form');  // ← id форми з кроку 9
const msg    = document.getElementById('form-msg');  // ← id абзацу для повідомлень
const list   = document.getElementById('list');      // ← id списку
const fTitle = document.getElementById('f-title');   // ← id поля назви
const fDays  = document.getElementById('f-days');    // ← id поля числа

async function loadList() {
  let items;
  try {
    const res = await fetch(API);
    if (!res.ok) { msg.textContent = 'Не вдалося прочитати список: ' + res.status; return; }
    items = await res.json();
  } catch (e) {
    msg.textContent = 'Сервер не відповідає. Перевір, чи запущена служба.';
    return;
  }
  list.innerHTML = '';
  for (const it of items) {
    const li = document.createElement('li');
    li.textContent = it.title + ' — ' + it.days + ' дн.';   // ← твої стовпці
    const del = document.createElement('button');
    del.type = 'button';
    del.textContent = 'видалити';
    del.addEventListener('click', function () { removeItem(it.id); });
    li.appendChild(del);
    list.appendChild(li);
  }
}

form.addEventListener('submit', async function (event) {
  event.preventDefault();
  msg.textContent = '';
  let res;
  try {
    res = await fetch(API, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: fTitle.value,          // ← твій стовпець
        days: Number(fDays.value)     // ← твій стовпець
      })
    });
  } catch (e) {
    msg.textContent = 'Сервер не відповідає. Перевір, чи запущена служба.';
    return;
  }
  if (res.ok) {
    form.reset();
    await loadList();
  } else if (res.status === 422) {
    msg.textContent = 'Дані не пройшли перевірку. Назва не може бути порожня, ' +
                      'а днів має бути цілим числом від 1 до 365.';
  } else {
    msg.textContent = 'Сервер відповів помилкою ' + res.status;
  }
});

async function removeItem(id) {
  let res;
  try {
    res = await fetch(API + '/' + id, { method: 'DELETE' });
  } catch (e) {
    msg.textContent = 'Сервер не відповідає. Перевір, чи запущена служба.';
    return;
  }
  if (res.ok || res.status === 404) {
    await loadList();
  } else {
    msg.textContent = 'Не вдалося видалити: ' + res.status;
  }
}

loadList();
</script>

Стрілки стоять після знака // — це коментарі JavaScript, код їх не виконує. Блок можна копіювати цілком разом із ними.

Що означає кожна команда в цьому блоці

Мову JavaScript ми не вивчаємо, але пояснити чужий код своїми словами ти маєш уміти. Ось усі команди, які тут трапляються:

КомандаЩо робить
getElementById('x')Знаходить на сторінці елемент, у якого id="x". Якщо такого елемента немає, повертає null, і наступне звертання до нього дає помилку.
addEventListenerКаже: коли з елементом станеться така подія ('submit' — надсилання форми, 'click' — натискання) — виклич оцю функцію.
async / awaitВідповідь від сервера приходить не миттєво. await означає «зупинися тут, дочекайся відповіді й тільки потім виконуй наступний рядок». Слово async перед функцією — дозвіл використовувати в ній await. Без await наступні рядки виконалися б, поки відповіді ще немає, і res.status був би невідомий.
JSON.stringify(…)Перетворює об’єкт JavaScript на текст JSON — рівно той формат із розділу 2 теорії, який чекає сервер.
res.json()Зворотна дія: читає текст відповіді сервера й перетворює JSON назад на дані, з якими можна працювати в коді.
res.okКоротка перевірка «код відповіді успішний», тобто від 200 до 299. Для 404 і 422 res.ok буде false.
createElement('li')Створює новий елемент, поки що ні до чого не приєднаний — на сторінці його не видно.
appendChild(el)Вставляє створений елемент усередину іншого — аж після цього він з’являється на сторінці.
list.innerHTML = ''Прибирає весь вміст елемента. Тут — очищає список перед тим, як заповнити його заново.
form.reset()Повертає поля форми до початкових значень, щоб після додавання не доводилося стирати текст руками.
try / catch«Спробуй виконати це; якщо станеться помилка — не зупиняйся, а виконай оце». Тут catch спрацює, коли сервер узагалі не відповів: без нього сторінка мовчки перестала б працювати.

Поясни цей блок

На занятті тебе спитають, що робить блок цілком — не окремий рядок. Ось із чого складати відповідь:

Про адресу 'api/habits' без косої риски спереду: вона рахується від адреси сторінки. Сторінка відкрита за адресою /u/bohdan.g9/, тому запит піде на /u/bohdan.g9/api/habits — саме туди, куди треба. Якщо написати '/api/habits' з косою рискою, запит піде в корінь сервера, де твого сервісу немає, і ти отримаєш 404.

11

Перевірити в браузері

Відкрий http://91.219.61.4/u/bohdan.g9/ і зроби три дії підряд:

  1. Заповни форму й натисни «Додати». Новий рядок має з’явитися в списку, а сторінка — не перезавантажитися.
  2. Онови сторінку клавішею F5. Рядок має лишитися на місці: якщо він зник, запис не дійшов до бази й жив тільки на екрані.
  3. Натисни «видалити» біля будь-якого запису — рядок має зникнути зі списку й з бази.

Перевір базу з термінала, щоб не покладатися лише на вигляд сторінки. sqlite3 — окрема програма командного рядка, яка відкриває файл бази й виконує SQL, який ти їй передаси. Це не сама база: база — це файл ~/data/app.db, а sqlite3 лише спосіб зазирнути в нього повз твою програму.

sqlite3 ~/data/app.db "SELECT id, title FROM habit ORDER BY id DESC LIMIT 5;"

Якщо у відповідь бачиш sqlite3: command not found, ця програма на сервері не встановлена. Установлювати її самому не треба — той самий результат дає Python зі спільного оточення, у якому робота з SQLite вбудована:

/opt/club/venv/bin/python -c "import sqlite3, pathlib; \
print(sqlite3.connect(pathlib.Path.home()/'data/app.db') \
.execute('SELECT id, title FROM habit ORDER BY id DESC LIMIT 5').fetchall())"

Якщо на сторінці порожньо, а помилок не видно, відкрий інструменти розробника клавішею F12 і подивись вкладку Console: там будуть повідомлення про помилки в коді сторінки. Вкладка Network показує кожен запит із його кодом відповіді.

12

Зберегти зміни в git

cd ~/app
git add main.py project.yaml
git commit -m "додав POST і DELETE з перевіркою вхідних даних"
git push origin master

Нагадування з заняття 03: коміт — збережений стан файлів на певний момент, до якого потім можна повернутися; репозиторій — тека, за якою git стежить і в якій зберігає всю історію комітів. Тека ~/app — репозиторій, git add відбирає файли для наступного коміта, git commit зберігає його, git push надсилає збережене у сховище на сервері.

Файл ~/www/index.html лежить поза текою ~/app, тому в цей репозиторій він не потрапляє. Якщо ти вже підключав ~/www до git на минулих заняттях, зроби окремий коміт і там.

Якщо у відповідь на push ти бачиш src refspec master does not match any, гілка в тебе називається інакше. Подивись, як саме, і підстав цю назву замість master:

git branch --show-current

Що перевіряє автоматика

Три критерії заняття 05, усі три перевіряє програма. Перевірка запускається за розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Адреси ендпоінтів програма бере з project.yaml, а назву головної таблиці — з розділу database.

КодКритерійЩо це означаєНе зараховано — що робити
L05.1 форма додає запис у базу і в список Програма відкриває твою сторінку в браузері без участі людини, заповнює форму, натискає кнопку й перевіряє дві речі: у головній таблиці побільшало на один рядок, і цей рядок видно на сторінці без перезавантаження. Пройди той самий шлях руками: заповни форму, натисни кнопку, онови сторінку клавішею F5. Якщо рядок зникає після оновлення — запис у базу не потрапив, дивись вкладку Network у F12: там видно код відповіді на POST. Якщо рядок узагалі не з’являється, перевір, чи є в обробнику event.preventDefault(), і чи заповнене поле create у project.yaml. Обов’язково перевір, що всі <input> мають id, а адреса в API написана без косої риски спереду.
L05.2 порожня назва дає 422, а не 500 Програма надсилає запит POST із порожнім значенням обов’язкового текстового поля й дивиться на код відповіді. Має бути 422 — «дані не пройшли перевірку». 500 означає, що код ендпоінта зупинився з помилкою, і критерій не зараховується. Повтори той самий запит командою curl із кроку 8 практики. Якщо відповідь 500, читай journalctl --user -u club-api -n 30 — там буде рядок, на якому все зупинилося. Найчастіші причини: забутий імпорт HTTPException (у журналі NameError); написано HTTPException(...) без слова raise, тому функція йде далі й падає нижче; у функції звертання до поля, якого в моделі немає, наприклад item.name замість item.title (у журналі AttributeError). Окремий випадок — відповідь не 500, а 200: тоді в моделі немає Field(min_length=1) і в ендпоінті немає перевірки після .strip(), тож порожня назва записується в базу без перешкод. Критерій не зарахується й тут: чекер чекає саме 422.
L05.3 DELETE неіснуючого запису дає 404 Програма надсилає DELETE на адресу із завідомо неіснуючим номером (наприклад, 999999) і чекає код 404. Відповідь 200 теж не зараховується: вона означає, що сервіс звітує про видалення, якого не було. Перевір самостійно: curl -i -X DELETE http://127.0.0.1/u/bohdan.g9/api/habits/999999. Якщо прийшло 200 — в ендпоінті немає перевірки SELECT перед видаленням, додай її з кроку 5. Якщо прийшло 405 «метод не дозволено» — ендпоінт написаний як @app.get, а має бути @app.delete. Якщо 422 — в адресі декоратора не вказано {habit_id} або аргумент функції названий інакше, ніж змінна у фігурних дужках: ці два імені мають збігатися символ у символ.

Домашнє завдання

Основне. Дай сервіс тій самій людині, яку ти назвав на першому занятті в розділі «для кого». Якщо саме цього тижня вона недоступна, підійде інший член родини. Умова одна: не підказувати й не пояснювати. Дай адресу http://91.219.61.4/u/bohdan.g9/ і мовчи.

Записуй дослівно, що людина каже вголос і на чому зупиняється. Дослівно — це «а куди тут тиснути?», а не «користувач не зрозумів інтерфейс». Твої висновки на цьому етапі не потрібні, потрібні її слова. Принеси цей список на наступне заняття — з нього почнеться робота на занятті 06.

Записуй і те, що людина зробила не так, як ти очікував: заповнила поле днів словом, натиснула «додати» двічі, спробувала видалити чужий запис. Кожен такий випадок — це запит, який твій сервіс уже отримав, і варто подивитися, як він на нього відповів:

journalctl --user -u club-api -n 50

Друге. Доведи до кінця решту випадків із перевіркою вхідних даних, якщо не встиг на занятті. Перевір командою curl і переконайся, що жодна відповідь не має коду 500:

Останній випадок особливий: поле без значення за замовчуванням у моделі вважається обов’язковим, тому FastAPI сам відповість 422. Перевір, що це справді так, а не припускай.

Якщо щось не працює

Сторінка перезавантажується при натисканні кнопки, запис не додається

Це стандартна поведінка браузера для форм, і вона спрацьовує сама. Перший рядок обробника має бути таким:

form.addEventListener('submit', async function (event) {
  event.preventDefault();

Друга можлива причина — код не знайшов форму, тому обробник до неї не додався. Це буває, коли id у тегу <form> написаний інакше, ніж у getElementById('add-form'). Відкрий F12, вкладку Console: там буде рядок Cannot read properties of null із номером рядка. Третя причина — блок <script> стоїть вище за форму й виконався раніше, ніж вона з’явилася; переміщуй його в самий кінець, перед </body>.

У відповідь на форму приходить 422, хоча всі поля заповнені

Назви полів у JSON не збігаються з назвами в моделі Pydantic. Модель чекає рівно ті імена, які в ній написані: title і days. Якщо в блоці fetch відправляється {"name": ..., "count": ...}, для моделі обидва потрібні поля відсутні.

Подивись, яке саме поле не пройшло перевірку — у відповіді 422 FastAPI їх перелічує:

curl -s -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
  -H "Content-Type: application/json" \
  -d '{"title":"Тест","days":3}'

У тексті відповіді шукай "loc" — там указано, у якому полі проблема, і "msg" — у чому вона.

Друга поширена причина: у полі числа порожньо або там не число. Значення з поля вводу завжди приходить рядком, тому в блоці стоїть Number(fDays.value). Якщо поле порожнє, Number('') дає 0, а модель чекає число не менше 1 — і це 422. Якщо в полі текст, Number('три') дає NaN, яке JSON.stringify записує як null, — теж 422. У відповіді це видно за рядком "loc": ["body", "days"]: проблема не в назві, а в днях.

Сам Number() тут ні до чого Може здатися, що причина в цій обгортці. Це не так: якщо її прибрати й надіслати "7" рядком, Pydantic сам перетворить такий рядок на число 7 і відповість 200. Number() тут потрібен для порядку в даних, а не для того, щоб уникнути 422.

Відповідь 500, у журналі — «no such table» або «no such column»

journalctl --user -u club-api -n 30

Такий рядок означає, що в SQL написана назва, якої в базі немає. Перевір, як таблиця й стовпці називаються насправді:

sqlite3 ~/data/app.db ".schema"

Якщо sqlite3 не встановлений, той самий опис таблиць покаже Python зі спільного оточення:

/opt/club/venv/bin/python -c "import sqlite3, pathlib; \
print('\n'.join(r[0] for r in sqlite3.connect(pathlib.Path.home()/'data/app.db') \
.execute('SELECT sql FROM sqlite_master WHERE sql IS NOT NULL')))"

Найчастіше причина — назва зі зразка (habit, title, days) лишилася в новому коді, а твоя таблиця називається інакше. Регістр тут ні до чого: назви таблиць і стовпців SQLite порівнює без урахування великих і малих літер, тому SELECT Title знайде стовпець title. Натомість варто перевірити написання: одна зайва літера (habits замість habit) або кирилична літера серед латинських — с замість c — дають рівно таке саме повідомлення. Порівняй назву в коді з тим, що надрукувала .schema, символ у символ.

DELETE із браузера відповідає 404, хоча запис існує

Порівняй адресу з тим, що бачиш у вкладці Network (F12). Типові розбіжності: у removeItem замість номера підставляється undefined — це означає, що в записі немає поля id, бо ендпоінт POST повертає не весь рядок, а щось інше; або в адресі з’явилася подвійна коса риска api/habits//7, бо константа API написана з рискою в кінці.

Перевір ендпоінт напряму, без сторінки — підстав номер запису, який точно є в базі:

curl -i -X DELETE http://127.0.0.1/u/bohdan.g9/api/habits/3

Якщо тут 200, а зі сторінки 404, проблема в адресі, яку будує код сторінки. Якщо і тут 404, перевір, які номери взагалі є в таблиці:

sqlite3 ~/data/app.db "SELECT id FROM habit;"

Якщо sqlite3 не встановлений, той самий список віддасть сам сервіс: curl -s http://127.0.0.1/u/bohdan.g9/api/habits.

Служба не піднімається після правки main.py

systemctl --user status club-api показує failed, сторінка /api/ping не відповідає. Причина завжди в останніх рядках журналу:

journalctl --user -u club-api -n 30

Виправ файл і повтори systemctl --user restart club-api. Якщо не бачиш помилки очима, поверни останню робочу версію: git diff main.py покаже, що ти змінив, а git restore main.py відкотить файл до останнього коміта.