open atlas
← Все проекты

backend · starter · 4d

Мини-CRUD API

Собери свой первый настоящий бэкенд: крошечный HTTP API, который создаёт, читает, обновляет и удаляет заметки — на SQLite, чтобы данные пережили перезапуск. Ты пройдёшь путь от сервера в одну строку с «hello» до небольшого сервиса, который проверяет ввод и хранит строки, — честно, шаг за шагом.

Почти любая бэкенд-задача, какой бы навороченной она ни была, — это CRUD в костюме. Собрав его руками — сервер, маршрутизация, валидация, настоящая база, — ты разберёшься в деталях, которые фреймворки потом прячут. Не поддавайся искушению поставить большой фреймворк в первый же день. Заставь работать голый цикл, посмотри, как плохой запрос отскакивает от твоей валидации, и перезапусти сервер, чтобы увидеть, что строки на месте. Именно в этот момент, когда данные никуда не делись, бэкенд перестаёт быть магией.

Результат

Работающий Node-сервис с GET/POST/PUT/DELETE для ресурса «notes», сохраняющий данные в файл SQLite, отклоняющий некорректный ввод понятными ошибками 4xx и возвращающий JSON так, как это делают настоящие API.

Этапы

0/5 · 0%
  1. 01Сервер, который отвечает

    Запусти самый маленький возможный HTTP-сервер на Node и заставь его отвечать на запрос. Запусти его, открой URL в браузере или через curl и увидь, как твои собственные слова возвращаются обратно. В этом вся суть бэкенда в миниатюре: процесс, который слушает и отвечает. Пока не добавляй фреймворк — заставь работать голый цикл, чтобы понять, что каждый фреймворк от тебя прячет.

    Критерии готовности
    • Запуск файла поднимает сервер на порту и печатает, какой порт он слушает.
    • Запрос GET / возвращает 200 с текстом или JSON, который ты написал.
  2. 02Маршрутизация по методу и пути

    Один эндпоинт — это ещё не API. Ветвись по методу и URL запроса так, чтобы GET /notes возвращал список заметок, а POST /notes обрабатывался иначе. Пока держи заметки в обычном массиве в памяти — без базы данных. Главная мысль: маршрутизация — это всего лишь «посмотри на метод и путь, реши, что делать», а неизвестный маршрут должен отвечать 404, а не падать.

    Критерии готовности
    • GET /notes возвращает текущий список в виде JSON; POST /notes добавляет элемент в массив в памяти.
    • Неизвестный путь (например, GET /nope) возвращает 404, а не зависает и не падает с ошибкой.
  3. 03Прочитай и проверь тело

    POST несёт тело, а тело приходит кусками, которые нужно собрать и распарсить. Прочитай тело запроса, сделай JSON.parse и проверь его: у заметки должен быть непустой title. Если его нет — отвечай 400 с короткой подсказкой и никогда не пускай плохие данные в список. Это твоё первое знакомство с правилом, которым живёт любой бэкенд: не доверяй ничему, что прислал клиент.

    Критерии готовности
    • POST /notes с корректным JSON-телом создаёт заметку и возвращает 201 с созданной заметкой.
    • POST /notes с отсутствующим или пустым title возвращает 400 и ничего не добавляет.
  4. 04Заставь данные пережить перезапуск

    Массив в памяти забывает всё, когда процесс останавливается. Замени его файлом SQLite: создай таблицу «notes» с id и title и перепиши обработчики на INSERT, SELECT, UPDATE и DELETE. SQLite — это один файл, ему не нужен сервер, поэтому он идеален для знакомства с настоящим хранением. Останови и перезапусти сервер — твои заметки должны остаться на месте.

    Критерии готовности
    • Заметки хранятся в файле SQLite; после перезапуска сервера GET /notes по-прежнему их возвращает.
    • У каждой заметки есть настоящий id, сгенерированный базой и используемый в URL для операций над одной заметкой.
  5. 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
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: 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 точно говорил, что не так.
  • Напиши несколько автоматических тестов, которые поднимают сервер, дёргают каждый эндпоинт и проверяют статус-код и тело, — твою первую настоящую страховочную сетку.

Навыки

starting a Node HTTP serverrouting by method and pathreading and validating a JSON bodyCRUD against SQLitereturning correct status codes

Рекомендуемый стек

nodesqlite (node:sqlite or better-sqlite3)