Мета заняття: людина заповнює форму на твоїй сторінці — дані зберігаються в базі, і новий запис одразу видно в списку.
Замість <логін> підставляєш свій
логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться.
Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні.
У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін.
Зараз твій сервіс уміє лише показувати те, що ти сам додав у базу з термінала. Додавав
ти командою INSERT — це команда мови SQL, якою на занятті 04
ти звертався до бази даних: SELECT читає рядки з таблиці, INSERT
додає новий рядок, DELETE видаляє. Сьогодні на сторінці з’явиться форма з
полями й кнопкою: людина, яка не має доступу до сервера й не знає SQL, заповнює її,
натискає кнопку — і запис потрапляє в базу.
Крім кнопки «додати» буде кнопка «видалити» біля кожного запису. І окремо ти зробиш так, щоб на зіпсовані дані сервіс відповідав зрозумілим текстом і не припиняв роботу: порожня назва має отримати відповідь «дані не пройшли перевірку», а спроба видалити запис, якого вже немає, — відповідь «такого немає».
Коли браузер звертається до сервера, він надсилає не тільки адресу, а ще й одне слово перед нею — метод HTTP. Метод каже, що саме треба зробити з тим, що лежить за цією адресою.
HTTP — це набір правил, за якими браузер і сервер обмінюються повідомленнями. Коли ти відкриваєш сторінку, браузер надсилає повідомлення на кшталт «GET /u/bohdan.g9/» і отримує у відповідь текст сторінки. Усе, що ти робив на заняттях 02 і 04, було методом GET: відкрив адресу в браузері — браузер зробив GET.
/api/habits/7 означає «запис із номером 7»./api/. Термін уже був на занятті 02, нагадуємо, бо сьогодні
він трапиться кілька разів.GET /api/habits і POST /api/habits — це два різні
ендпоінти, хоча адреса в них однакова.Адреса може повторюватися, а пара «метод + адреса» — ні. У коді FastAPI це видно одразу: рядок над функцією називає і метод, і адресу.
@app.get("/api/habits") # віддати список
@app.post("/api/habits") # додати запис
@app.delete("/api/habits/{habit_id}") # видалити запис за номеромТехнічно ніщо не заважає написати ендпоінт GET /api/habits/7/delete і видаляти
запис при відкритті цієї адреси. Так робити не можна, і причина практична.
GET вважається безпечним: усі, хто працює з вебом, виходять із того, що відкрити адресу методом GET можна скільки завгодно разів без наслідків. Тому браузер може завантажити посилання наперед, поки ти ще тільки навів на нього курсор. Месенджер, у який ти кинув посилання, відкриває його сам, щоб показати попередній перегляд. Антивірус і поштові фільтри перевіряють посилання так само — заходять і дивляться. Якщо за GET-адресою стоїть видалення, усі ці програми почнуть видаляти твої записи, і жодна з них не зробить нічого поганого зі свого боку.
Оля зробила біля кожного запису звичайне посилання на адресу
/api/habits/3/delete, і воно справді видаляло запис. Через кілька днів вона
скинула посилання на свій сайт у групу в месенджері, а наступного ранку побачила, що частина
записів зникла, хоча вона нічого не натискала. Що сталося?
Форма має надіслати на сервер назву звички й кількість днів. Треба якось запакувати ці два значення в один текст, який сервер зможе розібрати назад. Для цього використовують JSON — текстовий формат запису даних, який розуміють усі мови програмування.
{"title": "Читати 20 хвилин", "days": 7}Виглядає майже як словник Python, і читається так само: ключ, двокрапка, значення. Правил небагато:
"days": 7 і
"days": "7" — це різні речі: число й текст.{"tags": ["книги", "вечір"]}.true і false, не
True.Твій ендпоінт GET /api/habits уже віддає JSON — саме тому в браузері він
показує текст у фігурних і квадратних дужках, а не звичну сторінку. Сьогодні той самий формат
працюватиме у зворотному напрямку: сторінка надсилатиме JSON серверу.
На кожен запит сервер відповідає трицифровим числом — кодом стану. Це
коротка відповідь на питання «як усе минуло», і вона надходить раніше за самі дані. Ти вже
бачив код 200 на занятті 02, коли перевіряв, що /api/ping працює.
Перша цифра ділить коди на групи, і в нашій роботі важливі три:
| Код | Що означає | Приклад |
|---|---|---|
| 200 | Усе гаразд, ось дані | Сторінка відкрилася; список звичок віддано |
| 404 | Такого немає | Просять запис із номером 999, а такого рядка в таблиці немає |
| 422 | Дані не пройшли перевірку | Надіслали порожню назву або текст там, де очікується число |
| 500 | Помилка на боці сервера | У коді ендпоінта сталася помилка, і функція не змогла нічого повернути |
Різниця між групою 4xx і групою 5xx принципова, і сьогодні вона тобі знадобиться в обох критеріях перевірки.
Практичний висновок такий. Порожнє поле форми — звичайна ситуація, яку код має передбачити й відповісти на неї кодом 422. Якщо у відповідь приходить 500, це означає, що порожнє поле в коді не передбачене й функція на ньому зупинилася з помилкою. Тому критерій заняття сформульований саме так: порожня назва має давати 422.
Сусід перевіряє твій сервіс. Він надсилає форму, залишивши поле назви порожнім, і бачить у відповідь код 500. Що це означає?
Валідація — перевірка даних перед тим, як їх використати: чи є обов’язкові поля, чи не порожні вони, чи справді число там, де має бути число.
Перевірити можна у двох місцях, і вони роблять різну роботу.
required, maxlength, type="number".
Браузер сам не дає надіслати форму й підсвічує поле. Це зручність для людини: помилку видно
одразу, без звернення до сервера.Обійти перевірку у формі просто, і для цього не треба нічого вміти. Запит до твого сервісу
можна надіслати командою curl прямо з термінала, узагалі не відкриваючи сторінку.
curl — програма командного рядка, яка надсилає запит на вказану адресу й друкує
відповідь текстом; ти вже користувався нею на заняттях 01 і 02, сьогодні вона знадобиться
знову. Або можна відкрити інструменти розробника — вбудовану в браузер панель,
яка відкривається клавішею F12 і показує код сторінки, помилки й усі запити до сервера, — і
прибрати атрибут required двома клацаннями. Тому все, що написано у формі, для
сервера є побажанням, а не гарантією.
Писати перевірки вручну в кожному ендпоінті довго й легко щось забути. 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 точно число.
min_length=1 його пропустить.
Тому в самому ендпоінті ми додатково прибираємо пробіли по краях методом
.strip() і перевіряємо, чи щось лишилося.
Максим поставив у полі назви required і maxlength="200"
і каже, що тепер порожня назва в базу не потрапить ніколи, а перевірку на сервері писати
зайве. Він має рацію?
Ситуація трапляється щодня. Людина натиснула «видалити», нічого не відбулося за півсекунди, вона натиснула ще раз. Або відкрила сайт у двох вкладках, видалила запис в одній, а в другій список ще старий і кнопка на місці.
Перший запит видалив рядок і відповів 200. Другий приходить до сервера, коли рядка вже немає. Правильна відповідь тут — 404, «такого немає». Це відповідь із групи 4xx: запит опрацьовано, у таблиці шукали, потрібного рядка не знайшли, і код 404 повідомляє саме про це.
Щоб так вийшло, ендпоінт спочатку перевіряє, чи є такий рядок, і лише потім видаляє.
Якщо написати одразу DELETE FROM habit WHERE id = ? без перевірки, помилки не
буде: SQLite видалить нуль рядків і поверне звичайний результат. Сервіс відповість 200,
хоча насправді нічого не сталося, і сторінка покаже людині, що все гаразд.
Тарас написав видалення без попереднього SELECT — одразу
DELETE FROM habit WHERE id = ?. Через тиждень до нього приходить однокласниця:
вона видалила запис №4, а наступного дня знову бачить його в списку й каже, що сервіс
«видаляє через раз». Насправді запис №4 вона видалила один раз, а вдруге натиснула кнопку в
старій вкладці, яку не оновлювала. Що показала їй сторінка вдруге і чому Тарас цього не
помітив у себе?
Звичайна форма в 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 його пропускає. Саме тому
порожню назву доводиться відсіювати в коді, а не сподіватися на базу.
ssh bohdan.g9@91.219.61.4
curl -s http://127.0.0.1/u/bohdan.g9/api/habitsКлюч -s прибирає смужку прогресу, щоб вона не заважала читати відповідь. Ти
маєш побачити список записів у квадратних дужках — той самий, що віддає ендпоінт із заняття
04. Якщо замість списку порожні дужки [], база порожня: додай кілька рядків
командою INSERT, як на минулому занятті.
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/habitsnano ~/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.
Прокрути 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)class NewHabit опиниться нижче за @app.post, під
час запуску буде NameError: name 'NewHabit' is not defined і служба не
підніметься.
Верхні межі тут не для краси. max_length=200 зупинить запит із текстом на
п’ять тисяч символів, який інакше повністю запишеться в базу. le=365 зупинить
число на кшталт мільйона. Підбирай межі під свої дані: для поля «вік» розумно
ge=1, le=120, для короткої нотатки — max_length=500.
@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]Розбір по частинах:
item: NewHabit — саме через цей запис FastAPI розуміє, що в тілі запиту
очікується JSON із полями моделі. Тіло запиту — це дані, які запит несе із собою
окремо від адреси; у GET його зазвичай немає, а POST саме для того й потрібен, щоб щось у
тілі передати. Якщо JSON у тілі не підходить під опис моделі, функція не запуститься, а у
відповідь піде 422..strip() прибирає пробіли на початку й у кінці. Далі перевірка на випадок,
коли після цього не лишилося нічого. raise HTTPException зупиняє функцію й
віддає вказаний код.(title, item.days). Від списку він відрізняється тим, що його не можна
змінювати після створення; для передавання параметрів у SQL це якраз доречно. Кортеж з
одного значення пишеться з комою в кінці: (new_id,) — без коми Python вважав
би це просто дужками навколо числа.+, ані через
f-рядок (це рядок із літерою f перед лапками, у який Python підставляє
значення змінних з фігурних дужок: f"...WHERE id = {habit_id}"). Чому саме так
не можна, розберемо на занятті 08.execute повертає номер щойно доданого рядка. Ми одразу читаємо цей рядок
із бази й повертаємо його цілком — сторінці потрібен id, щоб показати біля
запису кнопку «видалити».Успішна відповідь тут матиме код 200. У багатьох сервісів на створення прийнято відповідати кодом 201 «створено»; для нашої перевірки підходить будь-який із них.
@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 має бути однаковий скрізь. Код відповіді при цьому саме той, який потрібен.
Автоматична перевірка не вгадує назви твоїх ендпоінтів — вона читає їх із
project.yaml. Поки поля порожні, критерії не зарахуються, навіть якщо код
ідеальний.
nano ~/app/project.yamlendpoints:
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 читають маніфест одразу при старті, ще
до того, як програма почне приймати запити.
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 чи поставили модель нижче за функцію).
Спочатку нормальний запит. Ключ -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 означає «цей самий комп’ютер» і працює тільки в терміналі на
сервері. З дому ті самі команди виконуються з адресою http://91.219.61.4/….
Шлях після адреси однаковий.
Сторінка лежить у файлі ~/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: запит надсилатиме код, а не сама форма.
Цей блок додається в кінці сторінки, перед закривальним тегом
</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 спрацює, коли сервер узагалі не
відповів: без нього сторінка мовчки перестала б працювати. |
На занятті тебе спитають, що робить блок цілком — не окремий рядок. Ось із чого складати відповідь:
loadList питає в сервера список записів методом GET, очищає список на
сторінці й будує його заново — по одному рядку <li> на запис, з кнопкою
«видалити» біля кожного.submit спрацьовує при натисканні кнопки в формі. Перший рядок
зупиняє стандартне перезавантаження сторінки. Далі значення полів перетворюються на JSON
(JSON.stringify) і надсилаються методом POST.Number(fDays.value) перетворює значення поля на число. Поле вводу завжди
віддає текст, навіть якщо в ньому type="number".res.ok, форма очищається й список перечитується — тому новий запис
з’являється без перезавантаження. Якщо прийшов 422, показуємо текст про перевірку полів.
Якщо сервер не відповів узагалі, спрацьовує catch і показує окреме
повідомлення.removeItem надсилає DELETE на адресу з номером запису й теж перечитує
список. Код 404 тут теж вважається нормальним: запису вже немає, а список однаково варто
оновити.loadList() виконується один раз при відкритті сторінки,
щоб список був заповнений одразу.Про адресу 'api/habits' без косої риски спереду: вона рахується від адреси
сторінки. Сторінка відкрита за адресою /u/bohdan.g9/, тому запит піде на
/u/bohdan.g9/api/habits — саме туди, куди треба. Якщо написати
'/api/habits' з косою рискою, запит піде в корінь сервера, де твого сервісу
немає, і ти отримаєш 404.
Відкрий http://91.219.61.4/u/bohdan.g9/ і зроби три дії підряд:
Перевір базу з термінала, щоб не покладатися лише на вигляд сторінки. 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 показує кожен запит із його кодом відповіді.
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:
{"title":"Біг","days":"багато"};{"title":"Біг"}.Останній випадок особливий: поле без значення за замовчуванням у моделі вважається обов’язковим, тому FastAPI сам відповість 422. Перевір, що це справді так, а не припускай.
Це стандартна поведінка браузера для форм, і вона спрацьовує сама. Перший рядок обробника має бути таким:
form.addEventListener('submit', async function (event) {
event.preventDefault();Друга можлива причина — код не знайшов форму, тому обробник до неї не додався. Це буває, коли
id у тегу <form> написаний інакше, ніж у
getElementById('add-form'). Відкрий F12, вкладку Console: там буде рядок
Cannot read properties of null із номером рядка. Третя причина — блок
<script> стоїть вище за форму й виконався раніше, ніж вона з’явилася;
переміщуй його в самий кінець, перед </body>.
Назви полів у 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"]: проблема не в назві, а в днях.
"7" рядком, Pydantic сам перетворить такий рядок на число 7 і
відповість 200. Number() тут потрібен для порядку в даних, а не для того, щоб
уникнути 422.
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, символ у
символ.
Порівняй адресу з тим, що бачиш у вкладці 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.
systemctl --user status club-api показує failed, сторінка
/api/ping не відповідає. Причина завжди в останніх рядках журналу:
journalctl --user -u club-api -n 30IndentationError — збилися відступи. Python вимагає, щоб у межах одного
блоку відступ був однаковий, і забороняє змішувати пробіли з табуляцією. Скільки саме
пробілів, мова не диктує; загальноприйнято чотири, і в заготовці зроблено саме так — тримайся
цього, щоб файл лишався однорідним.NameError: name 'HTTPException' is not defined — забув дописати імпорт із
кроку 2 практики.NameError: name 'NewHabit' is not defined — клас моделі стоїть у файлі нижче
за функцію, яка на нього посилається. Переміщуй class NewHabit вище за
@app.post.yaml.scanner.ScannerError — зіпсований project.yaml: табуляція
замість пробілів або пропущений пробіл після двокрапки.Виправ файл і повтори systemctl --user restart club-api. Якщо не бачиш
помилки очима, поверни останню робочу версію: git diff main.py покаже, що ти
змінив, а git restore main.py відкотить файл до останнього коміта.