backend · starter · 4d
Мини-CRUD API
Собери свой первый настоящий бэкенд: крошечный HTTP API, который создаёт, читает, обновляет и удаляет заметки — на SQLite, чтобы данные пережили перезапуск. Ты пройдёшь путь от сервера в одну строку с «hello» до небольшого сервиса, который проверяет ввод и хранит строки, — честно, шаг за шагом.
Результат
Работающий Node-сервис с GET/POST/PUT/DELETE для ресурса «notes», сохраняющий данные в файл SQLite, отклоняющий некорректный ввод понятными ошибками 4xx и возвращающий JSON так, как это делают настоящие API.
Этапы
0/5 · 0%- 01Сервер, который отвечает
Запусти самый маленький возможный HTTP-сервер на Node и заставь его отвечать на запрос. Запусти его, открой URL в браузере или через curl и увидь, как твои собственные слова возвращаются обратно. В этом вся суть бэкенда в миниатюре: процесс, который слушает и отвечает. Пока не добавляй фреймворк — заставь работать голый цикл, чтобы понять, что каждый фреймворк от тебя прячет.
Критерии готовности- Запуск файла поднимает сервер на порту и печатает, какой порт он слушает.
- Запрос GET / возвращает 200 с текстом или JSON, который ты написал.
- 02Маршрутизация по методу и пути
Один эндпоинт — это ещё не API. Ветвись по методу и URL запроса так, чтобы GET /notes возвращал список заметок, а POST /notes обрабатывался иначе. Пока держи заметки в обычном массиве в памяти — без базы данных. Главная мысль: маршрутизация — это всего лишь «посмотри на метод и путь, реши, что делать», а неизвестный маршрут должен отвечать 404, а не падать.
Критерии готовности- GET /notes возвращает текущий список в виде JSON; POST /notes добавляет элемент в массив в памяти.
- Неизвестный путь (например, GET /nope) возвращает 404, а не зависает и не падает с ошибкой.
- 03Прочитай и проверь тело
POST несёт тело, а тело приходит кусками, которые нужно собрать и распарсить. Прочитай тело запроса, сделай JSON.parse и проверь его: у заметки должен быть непустой title. Если его нет — отвечай 400 с короткой подсказкой и никогда не пускай плохие данные в список. Это твоё первое знакомство с правилом, которым живёт любой бэкенд: не доверяй ничему, что прислал клиент.
Критерии готовности- POST /notes с корректным JSON-телом создаёт заметку и возвращает 201 с созданной заметкой.
- POST /notes с отсутствующим или пустым title возвращает 400 и ничего не добавляет.
- 04Заставь данные пережить перезапуск
Массив в памяти забывает всё, когда процесс останавливается. Замени его файлом SQLite: создай таблицу «notes» с id и title и перепиши обработчики на INSERT, SELECT, UPDATE и DELETE. SQLite — это один файл, ему не нужен сервер, поэтому он идеален для знакомства с настоящим хранением. Останови и перезапусти сервер — твои заметки должны остаться на месте.
Критерии готовности- Заметки хранятся в файле SQLite; после перезапуска сервера GET /notes по-прежнему их возвращает.
- У каждой заметки есть настоящий id, сгенерированный базой и используемый в URL для операций над одной заметкой.
- 05Замкни круг CRUD
Доведи до конца все четыре глагола, чтобы ресурс вёл себя так, как ждут клиенты. GET /notes/:id возвращает одну заметку (или 404), PUT /notes/:id обновляет её, а DELETE /notes/:id удаляет её (или 404, если её не было). Будь честен со статус-кодами — 200 на удачное чтение, 404, когда id нет, — ведь правильные коды это то, как API без слов говорит с остальным миром.
Критерии готовности- Все четыре операции работают по реальному id: прочитать одну, обновить, удалить, получить список.
- Операция над несуществующим id возвращает 404, а не 500 и не тихий «успех».
Стартер
- README.md
- src/api.ts
- test/api.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Корректность CRUD-маршрутов и статус-кодов | Все пять форм маршрутов отвечают без падений; 201 при создании и 200 при чтении в целом верны, хотя 404 и 500 для несуществующих id могут путаться. | Каждый маршрут возвращает семантически правильный статус: 201 с созданным телом, 200 с обновлённым телом на PUT, 204 или 200 на DELETE, и 404 всякий раз, когда id отсутствует — никаких случайных 500. | Статус-коды верны И логика явная: 201 сигнализирует о создании нового ресурса (сюда относится заголовок Location), PUT идемпотентен — повторные вызовы возвращают тот же ответ 200, DELETE несуществующего id тоже идемпотентен — второй delete — это 404, а не тихий 200, который лжёт о произошедшем. |
| Валидация и пути ошибок (400 vs 404) | Отсутствующее name на POST возвращает что-то, отличное от 201; разница между 400 (плохой ввод) и 404 (ресурс не найден) есть, но не всегда последовательна. | 400 срабатывает строго для некорректного или отсутствующего обязательного ввода (нет name на POST) и никогда для случая ресурс-не-найден; 404 срабатывает строго когда id не существует в любом маршруте, который ссылается на него. Два класса ошибок чётко разделены. | Тело ответа 400 называет проблемное поле и нарушенное ограничение (не generic 'bad request'), чтобы клиент мог показать содержательную ошибку формы без парсинга сообщения. PUT несуществующего id — это 404, не 400: тело было валидным, адрес неверным. Ты можешь объяснить, почему смешение этих двух кодов ломает типизированные API-клиенты, которые ветвятся по статусу, чтобы решить — повторять запрос или показывать UI валидации. |
| Stateless-природа и что меняется с реальной базой данных | Понимает, что карта в памяти теряется при перезапуске процесса и что база данных решила бы это. | Может перечислить конкретные изменения: генерация id переходит от внутрипроцессного счётчика к последовательности базы или UUID, каждый обработчик становится async, ошибки уровня БД (нарушения ограничений, обрывы соединения) требуют собственных веток обработки ошибок, отдельных от логики 400/404. | Опознаёт опасность конкурентного доступа, которую карта в памяти молча избегает: реальная база с параллельными записями требует транзакции вокруг read-then-write в PUT, чтобы предотвратить потерянное обновление, а DELETE должен быть внутри той же транзакции, что и проверка существования, во избежание TOCTOU-гонки. Версия в памяти корректна только потому, что однопоточна; та же структура кода ломается под пулом соединений. Ты можешь назвать, какой уровень изоляции SQL закрывает гонку и какова стоимость этого в производительности. |
Эталонный разбор (спойлер)
Почему 404, а не 400 для отсутствующего id: 400 означает, что сам запрос некорректен — клиент прислал плохие данные. 404 означает, что запрос был валидным, но адресуемый ресурс не существует. Их путаница ломает любой клиент, который ветвится по статусу, чтобы решить — показывать ошибку валидации (400) или страницу «не найдено» (404). Та же логика применима к PUT и DELETE несуществующего id: тело или метод может быть совершенно валидным; id просто не соответствует никакому ресурсу.
Почему PUT и DELETE идемпотентны: вызов PUT /items/5 с одним и тем же телом десять раз должен давать тот же результат, что и вызов один раз — элемент находится в обновлённом состоянии. Это контракт RFC 9110, позволяющий клиенту безопасно повторить запрос с истёкшим таймаутом, не проверяя, дошёл ли первый. DELETE идемпотентен по тому же контракту, но статус при втором вызове — 404, а не 200: операция прошла успешно (delete-if-exists детерминирован), но подтверждать больше нечего. Различие между идемпотентностью и статус-кодом, который она производит, — распространённый вопрос на senior-собеседовании.
Почему счётчик в памяти делает тесты детерминированными: использование Date.now() или Math.random() для генерации id делает вывод теста зависимым от системного времени или random-seed, так что два запуска одного теста могут проверять разные id. Инкрементирующий счётчик, привязанный к store, выдаёт '1', '2', '3' в порядке вставки, делая каждую проверку предсказуемой. Когда ты заменишь store на реальную базу, генерация id перейдёт к последовательности БД или UUID — оба также детерминированы в рамках транзакции, хотя UUID не упорядочены.
Что меняется, когда карта в памяти становится реальной базой данных: каждый обработчик должен стать async, генерация id выходит из процесса, а параллельные записи обнажают опасность потерянного обновления в read-then-write PUT, которую однопоточная версия в памяти молча избегает. Решение — транзакция вокруг чтения и обновления с изоляцией не ниже READ COMMITTED. DELETE имеет ту же TOCTOU-гонку: check-then-delete должен быть атомарным, а не двумя отдельными операторами. Пул соединений умножает этот риск, потому что N параллельных запросов делят M соединений, каждое из которых несёт своё состояние транзакции.
Роль абстракции Store: инкапсуляция всего состояния за createStore() и handle() делает реализацию заменяемой без изменения тестов. Приёмочный набор импортирует один и тот же интерфейс, неважно — Map, SQLite или Postgres в основе: меняется только фабрика createStore(). Это паттерн репозитория в миниатюре, и именно поэтому реальные фреймворки разделяют ORM-модель и обработчик маршрута: ты хочешь тестировать логику маршрутизации без запуска базы данных.
Сделай по-сеньорски
- Добавь валидацию ввода через библиотеку схем (например, zod), чтобы каждое поле проверялось в одном месте, а ответ 400 точно говорил, что не так.
- Напиши несколько автоматических тестов, которые поднимают сервер, дёргают каждый эндпоинт и проверяют статус-код и тело, — твою первую настоящую страховочную сетку.