From b0753f0c6046ff10b08c27ea498867965a9f0dcf Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Fri, 14 Aug 2026 18:24:04 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(tasks):=20FEEDBACK-371=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=81=D0=BB=D1=83=D0=B6=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20/tasks?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Статичный мок prism отдаёт пример из спецификации дословно, поэтому не применял skip и limit, а на любой /tasks/{id} отвечал одной и той же задачей. Уроки приходилось подгонять под это: в 250-example оговаривать, что параметры не работают, а в 400-kinds прибивать сравнение REST и RPC к задаче 1. Теперь /tasks обслуживает приложение. Операции вынесены в tasks-store.js, и его же использует JSON-RPC, то есть два стиля делят не только текст задач, но и логику: задача с одним номером в REST и в RPC одна и та же по построению. Коды ответов, на которых стоят самостоятельные, воспроизведены руками, потому что раньше их давал prism из спецификации: 200, 404 на несуществующую задачу, 422 на негодное тело, 405 на неподходящий метод, 204 на удаление. 405 требует отдельных маршрутов: без них fastify отвечает 404 на существующий адрес с чужим методом. Набор данных остался неизменяемым. POST и DELETE отвечают 201 и 204, но список не трогают: сервер учебный, запросы идут одновременно от множества студентов, и мутации сделали бы уроки невоспроизводимыми. В Caddyfile маршруты заданы точно, /http-api/tasks и /http-api/tasks/*, без tasks*, чтобы не перехватывать посторонние адреса. Префикс не срезается, поэтому приложение регистрирует полные пути. Спецификация приведена к поведению: skip и limit объявлены uint16, как у остальных коллекций, и добавлен 404 у get, update и delete. По спецификации читают документацию курса, расходиться с ответами ей нельзя. Co-Authored-By: Claude Opus 5 (1M context) --- Caddyfile | 12 +++ custom-server/src/index.js | 5 ++ custom-server/src/rpc.js | 46 +++++----- custom-server/src/tasks-rest.js | 95 +++++++++++++++++++++ custom-server/src/tasks-store.js | 80 +++++++++++++++++ typespec/http-api/services/tasksService.tsp | 13 +-- 6 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 custom-server/src/tasks-rest.js create mode 100644 custom-server/src/tasks-store.js diff --git a/Caddyfile b/Caddyfile index 994aa8c..989d235 100644 --- a/Caddyfile +++ b/Caddyfile @@ -9,6 +9,18 @@ handle /http-api/rpc { reverse_proxy localhost:4010 } +# /tasks обслуживает приложение, а не мок prism: мок отдавал пример дословно и +# поэтому не применял skip и limit и не отбирал записи по пути. Блоки заданы +# точно, без tasks*, чтобы не перехватывать посторонние адреса. Префикс здесь не +# срезается, поэтому приложение регистрирует полные пути. +handle /http-api/tasks { + reverse_proxy localhost:4010 +} + +handle /http-api/tasks/* { + reverse_proxy localhost:4010 +} + handle /http-api-openapi* { reverse_proxy localhost:4010 } diff --git a/custom-server/src/index.js b/custom-server/src/index.js index 50effd7..4b77ffa 100644 --- a/custom-server/src/index.js +++ b/custom-server/src/index.js @@ -8,6 +8,7 @@ import fastifyCookie from '@fastify/cookie'; import appConfig from '../../app.config.json' with {type: 'json'} import setUpRpc from './rpc.js'; +import setUpTasks from './tasks-rest.js'; const { dirname } = import.meta; @@ -117,6 +118,10 @@ export default async (app, _options) => { setUpRpc(app); + // REST-маршруты /http-api/tasks обслуживаются здесь, а не моком prism: см. + // шапку tasks-rest.js. + setUpTasks(app); + app.get('/postman/cookie', (req, res) => { res.setCookie('myCookie', 'cookieValue', { path: '/', diff --git a/custom-server/src/rpc.js b/custom-server/src/rpc.js index 0a76655..6e106a8 100644 --- a/custom-server/src/rpc.js +++ b/custom-server/src/rpc.js @@ -1,8 +1,14 @@ // JSON-RPC 2.0 endpoint for the http-api course. -// Shows the RPC style next to the REST routes served by the prism mock: -// one endpoint, always POST, errors live in the body and the status is always 200. +// Shows the RPC style next to the REST routes in tasks-rest.js: one endpoint, +// always POST, errors live in the body and the status is always 200. +// +// Данные и операции берутся из tasks-store.js, того же модуля, что обслуживает +// REST. Урок kinds сравнивает два стиля и опирается на то, что задача с одним +// номером в обоих стилях одна и та же. -import tasks from './data/tasks.js'; +import { + build, find, list, parseRange, validate, +} from './tasks-store.js'; // Negative codes are the protocol level ones defined by the JSON-RPC spec. // Application errors like "task not found" get positive codes chosen by the API itself. @@ -12,47 +18,35 @@ const errors = { invalidParams: { code: -32602, message: 'Invalid params' }, }; +const notFound = (id) => ({ error: { code: 1, message: `Task with id ${id} not found` } }); + const methods = { 'tasks.list': (params = {}) => { - const skip = Number(params.skip ?? 0); - const limit = Number(params.limit ?? tasks.length); - if (Number.isNaN(skip) || Number.isNaN(limit)) { + const range = parseRange(params); + if (range === null) { return { error: errors.invalidParams }; } - return { result: { tasks: tasks.slice(skip, skip + limit), total: tasks.length } }; + return { result: list(range) }; }, 'tasks.get': (params = {}) => { - const task = tasks.find((item) => item.id === Number(params.id)); - if (!task) { - return { error: { code: 1, message: `Task with id ${params.id} not found` } }; - } - return { result: task }; + const task = find(params.id); + return task ? { result: task } : notFound(params.id); }, 'tasks.create': (params = {}) => { - if (!params.title || !params.description) { + if (validate(params).length > 0) { return { error: errors.invalidParams }; } - const task = { - id: tasks.length + 1, - title: params.title, - description: params.description, - status: params.status ?? 'Backlog', - }; - return { result: task }; + return { result: build(params) }; }, 'tasks.delete': (params = {}) => { - const task = tasks.find((item) => item.id === Number(params.id)); - if (!task) { - return { error: { code: 1, message: `Task with id ${params.id} not found` } }; - } - return { result: true }; + const task = find(params.id); + return task ? { result: true } : notFound(params.id); }, }; -// The dataset never changes, create and delete only report what would happen. const handleCall = (call) => { const id = call?.id ?? null; diff --git a/custom-server/src/tasks-rest.js b/custom-server/src/tasks-rest.js new file mode 100644 index 0000000..9a78b91 --- /dev/null +++ b/custom-server/src/tasks-rest.js @@ -0,0 +1,95 @@ +// REST-маршруты для /http-api/tasks. +// +// Эти пути забраны у мока prism и обслуживаются здесь, потому что статичный мок +// отдаёт пример из спецификации дословно: он не применял skip и limit и на любой +// /tasks/{id} отдавал одну и ту же задачу. Разбор — FEEDBACK-371, #16. +// +// Коды ответов раньше давал prism из спецификации, и на них построены +// самостоятельные работы курса http-api. Здесь они воспроизводятся руками, а +// держит их прогон bin/smoke-test.js. +// +// Маршрутизация: в Caddyfile у /http-api/tasks стоит handle без среза префикса, +// поэтому пути регистрируются полностью, как у /http-api/rpc. + +import { + build, find, list, merge, parseRange, validate, +} from './tasks-store.js'; + +const COLLECTION = '/http-api/tasks'; +const ITEM = '/http-api/tasks/:id'; + +// Форма ошибки та же, что была у prism: тип, заголовок, код и подробности. +// Студент видел её в уроках, менять её незачем. +const fail = (res, status, title, detail) => res + .code(status) + .send({ title, status, detail }); + +const methodNotAllowed = (res, allow) => res + .code(405) + .header('Allow', allow) + .send({ + title: 'Method Not Allowed', + status: 405, + detail: `Адрес существует, но метод к нему не применяется. Разрешено: ${allow}`, + }); + +export default (app) => { + app.get(COLLECTION, (req, res) => { + const range = parseRange(req.query); + if (range === null) { + return fail(res, 422, 'Invalid request', 'skip и limit это целые числа не меньше нуля'); + } + return res.send(list(range)); + }); + + app.post(COLLECTION, (req, res) => { + const problems = validate(req.body ?? {}); + if (problems.length > 0) { + return fail(res, 422, 'Invalid request', problems.join('; ')); + } + return res.code(201).send(build(req.body)); + }); + + app.get(ITEM, (req, res) => { + const task = find(req.params.id); + if (!task) { + return fail(res, 404, 'Not Found', `Задачи с идентификатором ${req.params.id} нет`); + } + return res.send(task); + }); + + app.patch(ITEM, (req, res) => { + const task = find(req.params.id); + if (!task) { + return fail(res, 404, 'Not Found', `Задачи с идентификатором ${req.params.id} нет`); + } + const problems = validate(req.body ?? {}, { partial: true }); + if (problems.length > 0) { + return fail(res, 422, 'Invalid request', problems.join('; ')); + } + return res.send(merge(task, req.body ?? {})); + }); + + app.delete(ITEM, (req, res) => { + const task = find(req.params.id); + if (!task) { + return fail(res, 404, 'Not Found', `Задачи с идентификатором ${req.params.id} нет`); + } + return res.code(204).send(); + }); + + // Без этих маршрутов fastify ответил бы 404 на существующий адрес с неверным + // методом. Урок kinds просит студента сравнить три неудачных запроса и + // получить три разных кода, и 405 на `DELETE /tasks` один из них. + app.route({ + method: ['DELETE', 'PATCH', 'PUT'], + url: COLLECTION, + handler: (req, res) => methodNotAllowed(res, 'GET, POST'), + }); + + app.route({ + method: ['POST', 'PUT'], + url: ITEM, + handler: (req, res) => methodNotAllowed(res, 'GET, PATCH, DELETE'), + }); +}; diff --git a/custom-server/src/tasks-store.js b/custom-server/src/tasks-store.js new file mode 100644 index 0000000..f897336 --- /dev/null +++ b/custom-server/src/tasks-store.js @@ -0,0 +1,80 @@ +// Операции над задачами, общие для REST-маршрутов и JSON-RPC. +// +// Оба стиля обслуживают одни и те же данные одним и тем же кодом: урок kinds +// курса http-api сравнивает REST и RPC и опирается на то, что задача с одним +// номером в обоих стилях одна и та же. +// +// Набор данных не меняется. Сервер учебный, запросы к нему идут одновременно от +// множества студентов, и мутации сделали бы уроки невоспроизводимыми: следующий +// студент увидел бы не то, что написано в самостоятельной. Поэтому create и +// delete только сообщают, что произошло бы, а список остаётся прежним. + +import tasks from './data/tasks.js'; + +export const DEFAULT_LIMIT = 30; + +const STATUSES = ['Backlog', 'Ready', 'In Progress', 'Done', 'Archived']; + +// Пустая строка не проходит: в спецификации у title и description стоит +// @minLength(1). +const isFilled = (value) => typeof value === 'string' && value.length > 0; + +// Границы страницы приходят и из query REST, и из params RPC, поэтому разбор +// живёт здесь. null означает «значение есть, но негодное» — вызывающий сам +// решает, каким кодом или ошибкой на это ответить. +export const parseRange = ({ skip, limit } = {}) => { + const parse = (value, fallback) => { + if (value === undefined || value === null || value === '') return fallback; + const number = Number(value); + if (!Number.isInteger(number) || number < 0) return null; + return number; + }; + + const parsedSkip = parse(skip, 0); + const parsedLimit = parse(limit, DEFAULT_LIMIT); + if (parsedSkip === null || parsedLimit === null) return null; + return { skip: parsedSkip, limit: parsedLimit }; +}; + +// total это размер всего набора, а не страницы: клиент по нему понимает, есть +// ли ещё записи за limit. +export const list = ({ skip, limit }) => ({ + tasks: tasks.slice(skip, skip + limit), + total: tasks.length, + skip, + limit, +}); + +export const find = (id) => tasks.find((task) => task.id === Number(id)); + +export const validate = (dto = {}, { partial = false } = {}) => { + const problems = []; + + for (const field of ['title', 'description']) { + const value = dto[field]; + if (value === undefined) { + if (!partial) problems.push(`${field} обязательно`); + continue; + } + if (!isFilled(value)) problems.push(`${field} не может быть пустым`); + } + + if (dto.status !== undefined && !STATUSES.includes(dto.status)) { + problems.push(`status должен быть одним из: ${STATUSES.join(', ')}`); + } + + return problems; +}; + +export const build = (dto) => ({ + id: tasks.length + 1, + title: dto.title, + description: dto.description, + status: dto.status ?? 'Backlog', +}); + +export const merge = (task, dto) => ({ + ...task, + ...Object.fromEntries(Object.entries(dto).filter(([, value]) => value !== undefined)), + id: task.id, +}); diff --git a/typespec/http-api/services/tasksService.tsp b/typespec/http-api/services/tasksService.tsp index 4dbe8e9..9417f35 100644 --- a/typespec/http-api/services/tasksService.tsp +++ b/typespec/http-api/services/tasksService.tsp @@ -8,13 +8,16 @@ using TypeSpec.Rest; namespace AppService; +// Эти маршруты обслуживает приложение, а не мок prism, см. custom-server/src/tasks-rest.js. +// Поэтому спецификация здесь описывает настоящее поведение: skip и limit +// применяются, а запрос несуществующей задачи отвечает 404. @tag("Tasks") @route("/tasks") interface TaskService { @get op list( - @query skip?: string, - @query limit?: string, + @query skip?: uint16 = 0, + @query limit?: uint16 = 30, @query select?: string[] ): Tasks; @@ -22,14 +25,14 @@ interface TaskService { op get( @path id: string, @query select?: string - ): Task; + ): Task | NotFoundResponse; @post op create(@header contentType: "application/json" | "application/x-www-form-urlencoded", ...NewTaskDto): CreatedResponse & Task; @patch - op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditTaskDto): Task; + op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditTaskDto): Task | NotFoundResponse; @delete - op delete(@path id: string): void; + op delete(@path id: string): NoContentResponse | NotFoundResponse; } From f5a82bdbed6a29bee9c85b64c2e531ffd993f0db Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Fri, 14 Aug 2026 18:24:25 +0500 Subject: [PATCH 2/2] =?UTF-8?q?test(tasks):=20FEEDBACK-371=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=BF=D0=BE=20=D0=BF=D1=83=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогон дополнен тем, что раньше было сломано и не проверялось: skip и limit применяются, /tasks/{id} отдаёт именно эту задачу, несуществующая отвечает 404, отрицательный skip отвечает 422, а после POST набор задач остаётся прежним. Пути /tasks в прогоне переехали с порта мока на порт приложения, вместе с префиксом: Caddy срезает его перед prism и оставляет перед приложением. Добавлена сверка примеров спецификации с набором данных. Раньше совпадение проверялось само собой, потому что ответы брались из примеров; теперь ответы даёт код, а примеры остались документацией, и разойтись они могут молча. Сверка идёт поиском подстроки: разбирать YAML нечем, отдельная зависимость ради одной проверки того не стоит. AGENTS.md обновлён под новое устройство: таблица кодов разведена по источникам (prism против нашего кода), раздел про ограничение переписан на то, что осталось у остальных коллекций, и описана неизменяемость набора. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 83 ++++++++++++++--------- bin/smoke-test.js | 165 +++++++++++++++++++++++++++++++++------------- 2 files changed, 173 insertions(+), 75 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2d0707d..cbe47ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,14 @@ HTTP API, протокол HTTP, Postman, js-playwright. Уроки и само Четыре спецификации на TypeSpec (`typespec//`) компилируются в OpenAPI (`make compile` → `tsp-output/`), и каждую отдаёт свой мок prism. Рядом живёт -приложение на fastify (`custom-server/`) с рукописными эндпоинтами, которых в -спецификациях нет: `/http-api/rpc`, `/http-api/echo`, стрим и куки для курса про -протокол, статика. Снаружи всё сшивает Caddy на `$PORT`. +приложение на fastify (`custom-server/`) с рукописными эндпоинтами: `/http-api/rpc`, +`/http-api/echo`, стрим и куки для курса про протокол, статика, а также REST-маршруты +`/http-api/tasks` — их забрали у статичного мока: тот не умеет ни пагинации, ни +отбора по пути. Снаружи всё сшивает Caddy на `$PORT`. ```text Caddy :$PORT -├── :4010 fastify rpc, echo, стрим, куки, статика, swagger-ui +├── :4010 fastify tasks, rpc, echo, стрим, куки, статика, swagger-ui ├── :4011 prism http-api ├── :4012 prism http-protocol ├── :4013 prism js-playwright @@ -50,6 +51,13 @@ prism получает `/tasks`. Директива `handle /http-api/rpc` пр статичный режим отдал бы вместо данных заглушки вида `"string"`. Чтобы перевести любую из них на статику, сначала заводят `@example` во всех её моделях. +Маршруты `/http-api/tasks` не обслуживает ни один режим мока: они реализованы в +`custom-server/src/tasks-rest.js`. В статичном режиме пример отдаётся дословно, +поэтому `skip` и `limit` не применялись, а на любой `/tasks/{id}` приходила одна +и та же задача. +Спецификация их по-прежнему описывает, и по ней читают документацию, но +поведение задаёт код. + ## Примеры ответов это текст уроков Значения в `@example` подобраны под то, что уроки уже печатают: @@ -63,28 +71,34 @@ prism получает `/tasks`. Директива `handle /http-api/rpc` пр Правка этих значений это правка курса: заводится вместе с коммитом в `courses/ru/http_api_course`. -Набор задач лежит **в двух местах**: примерами в `typespec/http-api/models/task.tsp` -и данными JSON-RPC в `custom-server/src/data/tasks.js`. Дублирование намеренное: -общего источника у статичного мока и рукописного эндпоинта нет. Обе копии -обязаны совпадать, потому что урок `400-kinds` сравнивает REST и RPC на одних и -тех же данных. Расхождение валит `make test`. +Набор задач лежит **в двух местах**: данными в +`custom-server/src/data/tasks.js`, откуда их берут и REST, и RPC через общий +`tasks-store.js`, и примерами в `typespec/http-api/models/task.tsp`, откуда их +берёт документация. Поведение задают данные, примеры нужны для Swagger UI, и +совпадать они обязаны: иначе документация расходится с ответами. Расхождение +валит `make test`. ## Коды ответов несут уроки -На этих кодах построены самостоятельные работы, и prism отдаёт их из -спецификации: +На этих кодах построены самостоятельные работы: -| Запрос | Код | Что показывает урок | +| Запрос | Код | Кто отдаёт | | --- | --- | --- | -| `GET /nosuch` | 404 | адреса нет | -| `DELETE /tasks` | 405 | метод не тот | -| `POST /tasks` с `{}` | 422 | тело не проходит валидацию | -| `POST /posts` без токена | 401 | нужна аутентификация | -| `POST /posts` с токеном | 201 | создано | -| `DELETE /tasks/1` | 204 | удалено, тела нет | - -Правка `@useAuth`, обязательных полей DTO или `CreatedResponse` меняет эти коды. -Проверку кодов держит `make test`. +| `GET /nosuch` | 404 | prism | +| `POST /posts` без токена | 401 | prism | +| `POST /posts` с токеном | 201 | prism | +| `DELETE /tasks` | 405 | `tasks-rest.js` | +| `POST /tasks` с `{}` | 422 | `tasks-rest.js` | +| `GET /tasks/999` | 404 | `tasks-rest.js` | +| `DELETE /tasks/1` | 204 | `tasks-rest.js` | + +У кодов два источника, и теряются они по-разному. У prism код меняет правка +спецификации: `@useAuth`, обязательные поля DTO, `CreatedResponse`. У `/tasks` +код написан руками, поэтому там его легко потерять рефакторингом — в частности +405 держится отдельными маршрутами на неподходящие методы, иначе fastify ответил +бы 404 на существующий адрес. + +Проверку всех кодов держит `make test`. ## Версии прибиты не для порядка @@ -130,17 +144,26 @@ make deploy APP=http_example HOST=timeweb SKIP=caddy,cron Правка `Caddyfile` этого репозитория деплоится вместе с образом; `Caddyfile` самого сервера живёт в own-heroku и к этому репозиторию отношения не имеет. -## Известное ограничение +## Что мок всё ещё не умеет + +У `/tasks` пагинация и отбор по пути работают, потому что эти маршруты +реализованы кодом. Остальные коллекции по-прежнему за статичным моком, и там +ограничение в силе: `skip` и `limit` не применяются, а `/users/1/posts` отдаёт +тот же список, что `/posts`. Поэтому все посты в примерах приписаны автору 1 — +иначе два списка противоречат друг другу. + +Лечится тем же способом, что `/tasks`: маршруты переносятся в `custom-server` на +общий модуль данных, а коды из таблицы выше воспроизводятся руками и закрываются +прогоном. -В статичном режиме пример возвращается дословно, поэтому `skip` и `limit` не -применяются, а записи по пути не отбираются: `GET /tasks/2` отдаёт ту же -задачу, что `/tasks/1`, а `/users/1/posts` — тот же список, что `/posts`. -Из-за последнего все посты в примерах приписаны автору 1: иначе два списка -противоречат друг другу. +## Набор данных не меняется -Уроки под это подогнаны, и в их тексте стоят скрытые пометки. Починка — -перенести `/tasks` с мока на реализацию в `custom-server`, сохранив коды из -таблицы: issue #16, тикет FEEDBACK-371. +`POST` и `DELETE` отвечают 201 и 204, но список задач остаётся прежним: создание +и удаление только сообщают, что произошло бы. Сервер учебный, запросы к нему идут +одновременно от множества студентов, и мутации сделали бы уроки +невоспроизводимыми: самостоятельная описывает один набор, а следующий студент +получил бы другой. Прогон `make test` проверяет, что после `POST` набор задач +остался прежним. ## Команды diff --git a/bin/smoke-test.js b/bin/smoke-test.js index ee9687a..f456604 100755 --- a/bin/smoke-test.js +++ b/bin/smoke-test.js @@ -1,33 +1,39 @@ #!/usr/bin/env node -// Дымовой прогон http-api: поднимает статичный мок prism и приложение, после -// чего проверяет ответы запросами. +// Дымовой прогон http-api: поднимает мок prism и приложение, после чего +// проверяет ответы запросами. // -// Проверяется три вещи, каждая из которых уже ломалась незаметно. +// Проверяется то, что уже ломалось незаметно. // // Первое: REST и RPC отдают одни и те же задачи. Урок kinds курса http-api -// сравнивает два стиля на одних данных, а живут они в двух местах, в примерах -// спецификации и в custom-server/src/data/tasks.js. Правка одного места без -// второго ломает урок, и снаружи это никак не видно. +// сравнивает два стиля на одних данных. Оба обслуживаются модулем +// custom-server/src/tasks-store.js, а те же задачи продублированы примерами в +// спецификации, откуда их берёт документация курса. // -// Второе: коды ответов. На 404, 405, 422 и 401 построены самостоятельные, и -// переключение мока между статичным и динамическим режимом их задевает. +// Второе: коды ответов. На 404, 405, 422, 401, 201 и 204 построены +// самостоятельные работы. Часть кодов даёт prism из спецификации, а коды +// /tasks — наш код в custom-server/src/tasks-rest.js, и там их легко потерять. // // Третье: соответствие спецификации. Модели объявляют uint16, а динамический -// мок про это ограничение не знал и отдавал отрицательные id. +// мок про это ограничение не знает и отдавал отрицательные id. // -// Каддй здесь не участвует: он есть только в образе, а в CI его нет. Поэтому -// prism и приложение опрашиваются напрямую по своим портам, как это делает -// Caddy, срезая префикс /http-api. +// Четвёртое: skip, limit и отбор по пути. Ровно то, чего не умел статичный мок +// и из-за чего уроки приходилось подгонять под сервер (FEEDBACK-371, #16). +// +// Caddy здесь не участвует: он есть только в образе, а в CI его нет. Поэтому +// prism и приложение опрашиваются напрямую по своим портам. Пути учитывают, что +// Caddy срезает префикс перед prism и оставляет его перед приложением. import { spawn } from 'node:child_process'; import { once } from 'node:events'; +import { readFileSync } from 'node:fs'; import expectedTasks from '../custom-server/src/data/tasks.js'; const SPEC = './tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml'; -const REST = 'http://127.0.0.1:4011'; +const PRISM = 'http://127.0.0.1:4011'; const APP = 'http://127.0.0.1:4010'; +const TASKS = `${APP}/http-api/tasks`; const UINT16_MAX = 65535; const failures = []; @@ -46,11 +52,11 @@ const check = (name, passed, detail = '') => { // читателя, prism упирается в заполненный буфер и встаёт, а прогон выглядит // зависшим без всякой диагностики. Собранный вывод печатается, только если // сервис не поднялся. -// detached обязателен. npx это обёртка, она порождает настоящий процесс внуком, -// и SIGTERM самой обёртке внука не задевает: prism и fastify продолжают жить, -// держат наши трубы открытыми, и прогон не завершается даже после всех проверок. -// Поэтому каждый сервис заводится своей группой процессов и снимается целиком. const start = (name, command, args) => { + // detached обязателен. npx это обёртка, она порождает настоящий процесс внуком, + // и SIGTERM самой обёртке внука не задевает: prism и fastify продолжают жить, + // держат наши трубы открытыми, и прогон не завершается даже после всех проверок. + // Поэтому каждый сервис заводится своей группой процессов и снимается целиком. const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true }); const output = []; child.stdout.on('data', (chunk) => output.push(chunk.toString())); @@ -139,11 +145,11 @@ const run = async () => { 'fastify', 'start', '-p', '4010', '-a', '127.0.0.1', 'custom-server/src/index.js', ]); - await waitFor(`${REST}/tasks`, 'prism'); + await waitFor(`${PRISM}/posts`, 'prism'); await waitFor(`${APP}/`, 'приложение'); console.log('\nREST и RPC отдают одни и те же задачи'); - const restList = await getJson(`${REST}/tasks`); + const restList = await getJson(TASKS); const rpcList = await rpc('tasks.list', {}); check('GET /tasks отвечает 200', restList.status === 200, `получено ${restList.status}`); check( @@ -157,18 +163,54 @@ const run = async () => { JSON.stringify(rpcList.body?.result?.tasks), ); check( - 'total совпадает с длиной списка', + 'total равен размеру всего набора', restList.body?.total === expectedTasks.length, `total = ${restList.body?.total}`, ); - const restOne = await getJson(`${REST}/tasks/1`); - const rpcOne = await rpc('tasks.get', { id: 1 }); + console.log('\nЗапись отбирается по пути'); + for (const task of expectedTasks) { + const one = await getJson(`${TASKS}/${task.id}`); + const viaRpc = await rpc('tasks.get', { id: task.id }); + check( + `GET /tasks/${task.id} отдаёт задачу ${task.id}`, + JSON.stringify(one.body) === JSON.stringify(task), + JSON.stringify(one.body), + ); + check( + `tasks.get id=${task.id} совпадает с REST`, + JSON.stringify(viaRpc.body?.result) === JSON.stringify(one.body), + JSON.stringify(viaRpc.body?.result), + ); + } + const missing = await getJson(`${TASKS}/999`); + check('GET /tasks/999 отвечает 404', missing.status === 404, `получено ${missing.status}`); + + console.log('\nskip и limit применяются'); + const cases = [ + ['limit=2 отдаёт первые две', { skip: 0, limit: 2 }, expectedTasks.slice(0, 2)], + ['skip=1 пропускает первую', { skip: 1, limit: 30 }, expectedTasks.slice(1)], + ['skip=1&limit=1 отдаёт вторую', { skip: 1, limit: 1 }, expectedTasks.slice(1, 2)], + ['skip за концом набора отдаёт пусто', { skip: 99, limit: 10 }, []], + ]; + for (const [name, query, expected] of cases) { + const params = new URLSearchParams(query); + const page = await getJson(`${TASKS}?${params}`); + check(name, JSON.stringify(page.body?.tasks) === JSON.stringify(expected), JSON.stringify(page.body?.tasks)); + check( + `${name}: total остаётся ${expectedTasks.length}`, + page.body?.total === expectedTasks.length, + `total = ${page.body?.total}`, + ); + } + const rpcPage = await rpc('tasks.list', { skip: 1, limit: 1 }); check( - 'GET /tasks/1 и tasks.get id=1 отдают одну задачу', - JSON.stringify(restOne.body) === JSON.stringify(rpcOne.body?.result), - `REST ${JSON.stringify(restOne.body)} против RPC ${JSON.stringify(rpcOne.body?.result)}`, + 'tasks.list со skip и limit совпадает с REST', + JSON.stringify(rpcPage.body?.result?.tasks) === JSON.stringify(expectedTasks.slice(1, 2)), + JSON.stringify(rpcPage.body?.result?.tasks), ); + const badRange = await getJson(`${TASKS}?skip=-1`); + check('отрицательный skip отвечает 422', badRange.status === 422, `получено ${badRange.status}`); console.log('\nRPC держит ошибки в теле, а код оставляет успешным'); const rpcMissing = await rpc('tasks.get', { id: 999 }); @@ -182,46 +224,67 @@ const run = async () => { JSON.stringify(rpcUnknown.body?.error), ); - console.log('\nКоды ответов REST, на них построены самостоятельные'); - const cases = [ - ['GET /nosuch → 404', `${REST}/nosuch`, { method: 'GET' }, 404], - ['DELETE /tasks → 405', `${REST}/tasks`, { method: 'DELETE' }, 405], - ['POST /tasks с пустым телом → 422', `${REST}/tasks`, { + console.log('\nКоды ответов, на них построены самостоятельные'); + const codes = [ + ['GET /nosuch → 404', `${PRISM}/nosuch`, { method: 'GET' }, 404], + ['DELETE /tasks → 405', TASKS, { method: 'DELETE' }, 405], + ['POST /tasks с пустым телом → 422', TASKS, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }, 422], - ['POST /posts без токена → 401', `${REST}/posts`, { + ['DELETE /tasks/1 → 204', `${TASKS}/1`, { method: 'DELETE' }, 204], + ['POST /posts без токена → 401', `${PRISM}/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'title', body: 'body' }), }, 401], - ['GET /courses без ключа → 401', `${REST}/courses`, { method: 'GET' }, 401], + ['GET /courses без ключа → 401', `${PRISM}/courses`, { method: 'GET' }, 401], ]; - for (const [name, url, options, expected] of cases) { + for (const [name, url, options, expected] of codes) { const { status } = await getJson(url, options); check(name, status === expected, `получено ${status}`); } - const created = await getJson(`${REST}/posts`, { + const created = await getJson(TASKS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Новая задача', description: 'Описание' }), + }); + check('POST /tasks с телом → 201', created.status === 201, `получено ${created.status}`); + check( + 'созданная задача получает статус Backlog по умолчанию', + created.body?.status === 'Backlog', + JSON.stringify(created.body), + ); + // Набор данных не меняется: сервер учебный, и мутации сделали бы уроки + // невоспроизводимыми для следующего студента. + const afterCreate = await getJson(TASKS); + check( + 'после POST набор задач не изменился', + JSON.stringify(afterCreate.body?.tasks) === JSON.stringify(expectedTasks), + JSON.stringify(afterCreate.body?.tasks), + ); + + const createdPost = await getJson(`${PRISM}/posts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer any-value' }, body: JSON.stringify({ title: 'title', body: 'body' }), }); - check('POST /posts с токеном → 201', created.status === 201, `получено ${created.status}`); + check('POST /posts с токеном → 201', createdPost.status === 201, `получено ${createdPost.status}`); check( 'созданный пост несёт authorId, проставленный сервером', - Number.isInteger(created.body?.authorId), - JSON.stringify(created.body), + Number.isInteger(createdPost.body?.authorId), + JSON.stringify(createdPost.body), ); - const withKey = await getJson(`${REST}/courses`, { + const withKey = await getJson(`${PRISM}/courses`, { method: 'GET', headers: { 'X-API-KEY': 'any-value' }, }); check('GET /courses с ключом → 200', withKey.status === 200, `получено ${withKey.status}`); - const login = await getJson(`${REST}/login`, { + const login = await getJson(`${PRISM}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'max@hotmail.com', password: 'password' }), @@ -233,18 +296,30 @@ const run = async () => { ); console.log('\nЧисла в ответах не выходят за uint16'); - for (const path of ['/tasks', '/tasks/1', '/posts', '/posts/1', '/users', '/users/1', '/comments']) { - const headers = { 'X-API-KEY': 'any-value' }; - const { body } = await getJson(`${REST}${path}`, { method: 'GET', headers }); + for (const path of ['/posts', '/posts/1', '/users', '/users/1', '/comments']) { + const { body } = await getJson(`${PRISM}${path}`, { method: 'GET' }); const bad = outOfRange(body, path); check(`${path} в границах uint16`, bad.length === 0, bad.join(', ')); } - const { body: coursesBody } = await getJson(`${REST}/courses`, { + const { body: coursesBody } = await getJson(`${PRISM}/courses`, { method: 'GET', headers: { 'X-API-KEY': 'any-value' }, }); - const badCourses = outOfRange(coursesBody, '/courses'); - check('/courses в границах uint16', badCourses.length === 0, badCourses.join(', ')); + check('/courses в границах uint16', outOfRange(coursesBody, '/courses').length === 0); + check('/tasks в границах uint16', outOfRange(restList.body, '/tasks').length === 0); + + console.log('\nПримеры спецификации не расходятся с набором задач'); + // Документацию курса читают по спецификации, а данные отдаёт приложение, то + // есть примеры и набор это две копии. Сверка идёт поиском подстроки: разбирать + // YAML нечем, отдельная зависимость ради одной проверки того не стоит. + const spec = readFileSync(SPEC, 'utf8'); + for (const task of expectedTasks) { + check( + `пример задачи ${task.id} есть в спецификации`, + spec.includes(task.title) && spec.includes(task.description), + `нет «${task.title}»`, + ); + } }; try {