diff --git a/AGENTS.md b/AGENTS.md index cbe47ed..1f03e28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,14 @@ HTTP API, протокол HTTP, Postman, js-playwright. Уроки и само (`make compile` → `tsp-output/`), и каждую отдаёт свой мок prism. Рядом живёт приложение на fastify (`custom-server/`) с рукописными эндпоинтами: `/http-api/rpc`, `/http-api/echo`, стрим и куки для курса про протокол, статика, а также REST-маршруты -`/http-api/tasks` — их забрали у статичного мока: тот не умеет ни пагинации, ни -отбора по пути. Снаружи всё сшивает Caddy на `$PORT`. +коллекций `/http-api/tasks`, `/users`, `/posts`, `/comments` — их забрали у +статичного мока: тот не умеет ни пагинации, ни выборки полей, ни отбора по пути. +Снаружи всё сшивает Caddy на `$PORT`. ```text Caddy :$PORT -├── :4010 fastify tasks, rpc, echo, стрим, куки, статика, swagger-ui -├── :4011 prism http-api +├── :4010 fastify коллекции, rpc, echo, стрим, куки, статика, swagger-ui +├── :4011 prism http-api: только /courses и /login ├── :4012 prism http-protocol ├── :4013 prism js-playwright └── :4014 prism postman @@ -87,16 +88,18 @@ prism получает `/tasks`. Директива `handle /http-api/rpc` пр | `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` | +| `DELETE /tasks` | 405 | `routes.js` | +| `POST /tasks` с `{}` | 422 | `routes.js` | +| `GET /tasks/999` | 404 | `routes.js` | +| `DELETE /tasks/1` | 204 | `routes.js` | +| `POST /posts` без токена | 401 | `routes.js` | У кодов два источника, и теряются они по-разному. У prism код меняет правка -спецификации: `@useAuth`, обязательные поля DTO, `CreatedResponse`. У `/tasks` +спецификации: `@useAuth`, обязательные поля DTO, `CreatedResponse`. У коллекций код написан руками, поэтому там его легко потерять рефакторингом — в частности 405 держится отдельными маршрутами на неподходящие методы, иначе fastify ответил -бы 404 на существующий адрес. +бы 404 на существующий адрес. Требования к Bearer перенесены из `@useAuth` +спецификации вручную, в `resources.js`. Проверку всех кодов держит `make test`. @@ -144,17 +147,18 @@ 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` на -общий модуль данных, а коды из таблицы выше воспроизводятся руками и закрываются -прогоном. +* пользователей десять — урок `example` объясняет пагинацию тем, что `total` + равен 10, а `?skip=30` отдаёт пустую страницу; +* постов сорок — тот же урок учит на `?skip=30`, значит записей нужно заметно + больше тридцати; +* у автора 1 восемь постов — урок показывает вложенный ресурс + `/users/1/posts`, и на пустом списке он ничего не объясняет; +* первые три пользователя и первые два поста автора 1 приведены в уроке + дословно. ## Набор данных не меняется diff --git a/Caddyfile b/Caddyfile index 989d235..30c6122 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,100 +1,130 @@ :{$PORT} +# Апстримы указаны адресом, а не именем localhost: и prism, и приложение слушают +# только IPv4 (0.0.0.0), а localhost резолвится ещё и в ::1, где не слушает никто. + # http-api course handle /http-api/echo { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-api/rpc { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } -# /tasks обслуживает приложение, а не мок prism: мок отдавал пример дословно и -# поэтому не применял skip и limit и не отбирал записи по пути. Блоки заданы -# точно, без tasks*, чтобы не перехватывать посторонние адреса. Префикс здесь не -# срезается, поэтому приложение регистрирует полные пути. +# Коллекции обслуживает приложение, а не мок prism: мок отдавал пример дословно и +# поэтому не применял skip, limit и select и не отбирал записи по пути. Блоки +# заданы точно, без tasks* и users*, чтобы не перехватывать посторонние адреса. +# Префикс здесь не срезается, поэтому приложение регистрирует полные пути. +# +# /courses и /login остаются за моком: параметров выборки уроки на них не учат, +# а /courses закрыт API-ключом, который мок и проверяет. handle /http-api/tasks { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-api/tasks/* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/users { + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/users/* { + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/posts { + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/posts/* { + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/comments { + reverse_proxy 127.0.0.1:4010 +} + +handle /http-api/comments/* { + reverse_proxy 127.0.0.1:4010 } handle /http-api-openapi* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle_path /http-api/* { - reverse_proxy localhost:4011 + reverse_proxy 127.0.0.1:4011 } # http-protocol course handle /http-protocol/example { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-protocol/login { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-protocol/stream { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-protocol/removed { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-protocol { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /http-protocol-openapi* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle_path /http-protocol/* { - reverse_proxy localhost:4012 + reverse_proxy 127.0.0.1:4012 } # js-playwright course handle /js-playwright/users-list { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /js-playwright-openapi* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle_path /js-playwright/* { - reverse_proxy localhost:4013 + reverse_proxy 127.0.0.1:4013 } # postman course handle /postman/cookie { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle /postman-openapi* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } handle_path /postman/* { - reverse_proxy localhost:4014 + reverse_proxy 127.0.0.1:4014 } # js-dom-testing-library course handle /js-dom-testing-library/users-list { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } # shared/static handle /assets* { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } # default handle { - reverse_proxy localhost:4010 + reverse_proxy 127.0.0.1:4010 } diff --git a/bin/smoke-test.js b/bin/smoke-test.js index f456604..a6b083f 100755 --- a/bin/smoke-test.js +++ b/bin/smoke-test.js @@ -28,12 +28,18 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { readFileSync } from 'node:fs'; +import expectedComments from '../custom-server/src/data/comments.js'; +import expectedPosts from '../custom-server/src/data/posts.js'; import expectedTasks from '../custom-server/src/data/tasks.js'; +import expectedUsers from '../custom-server/src/data/users.js'; const SPEC = './tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml'; const PRISM = 'http://127.0.0.1:4011'; const APP = 'http://127.0.0.1:4010'; const TASKS = `${APP}/http-api/tasks`; +const USERS = `${APP}/http-api/users`; +const POSTS = `${APP}/http-api/posts`; +const COMMENTS = `${APP}/http-api/comments`; const UINT16_MAX = 65535; const failures = []; @@ -212,6 +218,107 @@ const run = async () => { const badRange = await getJson(`${TASKS}?skip=-1`); check('отрицательный skip отвечает 422', badRange.status === 422, `получено ${badRange.status}`); + console.log('\nКоллекции: пагинация, select и вложенные ресурсы'); + // Урок example печатает ровно этот ответ: пользователей десять, поэтому + // страница за тридцатым пустая, а total остаётся десяткой. + const usersSkipped = await getJson(`${USERS}?skip=30`); + check( + '/users?skip=30 отдаёт пустую страницу с total 10', + JSON.stringify(usersSkipped.body) === JSON.stringify({ + users: [], total: expectedUsers.length, skip: 30, limit: 30, + }), + JSON.stringify(usersSkipped.body), + ); + + const postsSkipped = await getJson(`${POSTS}?skip=30`); + check( + '/posts?skip=30 отдаёт последнюю страницу', + JSON.stringify(postsSkipped.body?.posts) === JSON.stringify(expectedPosts.slice(30)), + `постов ${postsSkipped.body?.posts?.length}`, + ); + check( + '/posts?skip=30: total равен всему набору', + postsSkipped.body?.total === expectedPosts.length, + `total = ${postsSkipped.body?.total}`, + ); + + // select оставляет запрошенные поля и всегда идентификатор: без него запись + // бесполезна, и именно так параметр показан в уроке example. + const selected = await getJson(`${USERS}?select=firstName,email`); + check( + 'select оставляет только запрошенные поля и id', + JSON.stringify(Object.keys(selected.body?.users?.[0] ?? {}).sort()) === JSON.stringify(['email', 'firstName', 'id']), + JSON.stringify(selected.body?.users?.[0]), + ); + const selectedOne = await getJson(`${USERS}/1?select=lastName`); + check( + 'select работает и на одиночном ресурсе', + JSON.stringify(Object.keys(selectedOne.body ?? {}).sort()) === JSON.stringify(['id', 'lastName']), + JSON.stringify(selectedOne.body), + ); + + // Вложенный ресурс отбирает по родителю: мок отдавал здесь весь список. + const ownPosts = await getJson(`${USERS}/1/posts`); + const expectedOwn = expectedPosts.filter((post) => post.authorId === 1); + check( + '/users/1/posts отдаёт только посты автора 1', + JSON.stringify(ownPosts.body?.posts) === JSON.stringify(expectedOwn), + `постов ${ownPosts.body?.posts?.length}, ожидалось ${expectedOwn.length}`, + ); + const allPosts = await getJson(POSTS); + check( + '/users/1/posts не совпадает с /posts', + JSON.stringify(ownPosts.body) !== JSON.stringify(allPosts.body), + ); + const postComments = await getJson(`${POSTS}/1/comments`); + check( + '/posts/1/comments отдаёт только комментарии этого поста', + JSON.stringify(postComments.body?.comments) === JSON.stringify(expectedComments.filter((c) => c.postId === 1)), + JSON.stringify(postComments.body?.comments), + ); + const nestedMissing = await getJson(`${USERS}/999/posts`); + check('/users/999/posts → 404', nestedMissing.status === 404, `получено ${nestedMissing.status}`); + + console.log('\nОтсутствующая запись и авторизация по коллекциям'); + for (const [name, url] of [['users', USERS], ['posts', POSTS], ['comments', COMMENTS]]) { + const { status } = await getJson(`${url}/999`); + check(`GET /${name}/999 → 404`, status === 404, `получено ${status}`); + } + // Спецификация закрывает Bearer'ом изменение постов, комментариев и + // пользователей, а создание пользователя оставляет открытым. + const guarded = [ + ['PATCH /users/1 без токена → 401', `${USERS}/1`, 'PATCH', 401], + ['DELETE /users/1 без токена → 401', `${USERS}/1`, 'DELETE', 401], + ['PATCH /posts/1 без токена → 401', `${POSTS}/1`, 'PATCH', 401], + ['DELETE /comments/1 без токена → 401', `${COMMENTS}/1`, 'DELETE', 401], + ]; + for (const [name, url, method, expected] of guarded) { + // Content-Type проставляется только вместе с телом: fastify отвечает 400 на + // пустое тело при заявленном application/json, и проверка мерила бы не то. + const options = method === 'PATCH' + ? { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'x', body: 'y', firstName: 'z' }), + } + : { method }; + const { status } = await getJson(url, options); + check(name, status === expected, `получено ${status}`); + } + const createdUser = await getJson(USERS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'john@mail.com', firstName: 'John', lastName: 'Doe', password: 'secret', + }), + }); + check('POST /users без токена → 201', createdUser.status === 201, `получено ${createdUser.status}`); + check( + 'созданный пользователь без пароля в ответе', + createdUser.body !== null && !('password' in createdUser.body), + JSON.stringify(createdUser.body), + ); + console.log('\nRPC держит ошибки в теле, а код оставляет успешным'); const rpcMissing = await rpc('tasks.get', { id: 999 }); const rpcUnknown = await rpc('tasks.destroy', { id: 1 }); @@ -234,7 +341,7 @@ const run = async () => { body: '{}', }, 422], ['DELETE /tasks/1 → 204', `${TASKS}/1`, { method: 'DELETE' }, 204], - ['POST /posts без токена → 401', `${PRISM}/posts`, { + ['POST /posts без токена → 401', POSTS, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'title', body: 'body' }), @@ -266,7 +373,7 @@ const run = async () => { JSON.stringify(afterCreate.body?.tasks), ); - const createdPost = await getJson(`${PRISM}/posts`, { + const createdPost = await getJson(POSTS, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer any-value' }, body: JSON.stringify({ title: 'title', body: 'body' }), @@ -296,8 +403,9 @@ const run = async () => { ); console.log('\nЧисла в ответах не выходят за uint16'); - for (const path of ['/posts', '/posts/1', '/users', '/users/1', '/comments']) { - const { body } = await getJson(`${PRISM}${path}`, { method: 'GET' }); + for (const url of [TASKS, `${TASKS}/1`, USERS, `${USERS}/1`, POSTS, `${POSTS}/1`, COMMENTS]) { + const path = url.replace(APP, ''); + const { body } = await getJson(url, { method: 'GET' }); const bad = outOfRange(body, path); check(`${path} в границах uint16`, bad.length === 0, bad.join(', ')); } @@ -306,7 +414,6 @@ const run = async () => { headers: { 'X-API-KEY': 'any-value' }, }); check('/courses в границах uint16', outOfRange(coursesBody, '/courses').length === 0); - check('/tasks в границах uint16', outOfRange(restList.body, '/tasks').length === 0); console.log('\nПримеры спецификации не расходятся с набором задач'); // Документацию курса читают по спецификации, а данные отдаёт приложение, то @@ -320,6 +427,26 @@ const run = async () => { `нет «${task.title}»`, ); } + for (const user of expectedUsers.slice(0, 3)) { + check( + `пример пользователя ${user.id} есть в спецификации`, + spec.includes(user.email) && spec.includes(user.firstName), + `нет «${user.email}»`, + ); + } + for (const post of expectedPosts.slice(0, 3)) { + check(`пример поста ${post.id} есть в спецификации`, spec.includes(post.title), `нет «${post.title}»`); + } + check( + 'пример Posts объявляет реальный total', + spec.includes(`total: ${expectedPosts.length}`), + `ожидался total: ${expectedPosts.length}`, + ); + check( + 'пример Comments объявляет реальный total', + spec.includes(`total: ${expectedComments.length}`), + `ожидался total: ${expectedComments.length}`, + ); }; try { diff --git a/custom-server/src/collections.js b/custom-server/src/collections.js new file mode 100644 index 0000000..e02a691 --- /dev/null +++ b/custom-server/src/collections.js @@ -0,0 +1,91 @@ +// Общий слой для коллекций демонстрационного сервера. +// +// Раньше все коллекции отдавал мок prism из примеров спецификации. Мок +// возвращает пример дословно, поэтому `skip`, `limit` и `select` не применялись, +// а `/users/1/posts` отдавал тот же список, что `/posts`. Уроки курса http-api +// учат ровно на этих параметрах, поэтому коллекции обслуживаются кодом +// (FEEDBACK-166, FEEDBACK-371). +// +// Здесь только разбор параметров и выборка. Маршруты и коды ответов лежат в +// routes.js, данные в data/, а один и тот же список задач берёт отсюда же и +// JSON-RPC, чтобы REST и RPC не расходились. + +export const DEFAULT_LIMIT = 30; + +// null означает «значение есть, но негодное». Вызывающий сам решает, каким кодом +// или ошибкой на это ответить: у REST это 422, у JSON-RPC код -32602. +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 }; +}; + +// select приходит либо строкой «firstName,email», либо повторяющимся параметром, +// и тогда fastify отдаёт массив. Обе формы приводятся к списку полей. +export const parseSelect = (select) => { + if (select === undefined || select === null || select === '') return null; + const raw = Array.isArray(select) ? select : [select]; + const fields = raw.flatMap((value) => String(value).split(',')).map((value) => value.trim()); + return fields.filter((field) => field.length > 0); +}; + +// Ключ остаётся в ответе всегда, даже если его не просили: без идентификатора +// запись бесполезна, и именно так параметр показан в уроке example. +// Поля идут в порядке модели, а не в порядке параметра: так ответ на +// `?select=firstName,email` совпадает с тем, что напечатано в уроке example, и в +// целом устойчив к порядку, в котором клиент перечислил поля. +export const project = (item, fields) => { + if (fields === null) return item; + const wanted = new Set(fields); + return Object.fromEntries( + Object.entries(item).filter(([key]) => key === 'id' || wanted.has(key)), + ); +}; + +// total это размер всего набора, а не выданной страницы: по нему клиент понимает, +// есть ли записи за пределами limit. +export const page = ({ + items, envelope, skip, limit, fields = null, +}) => ({ + [envelope]: items.slice(skip, skip + limit).map((item) => project(item, fields)), + total: items.length, + skip, + limit, +}); + +export const findById = (items, id) => items.find((item) => item.id === Number(id)); + +export const nextId = (items) => items.reduce((max, item) => Math.max(max, item.id), 0) + 1; + +export const isFilled = (value) => typeof value === 'string' && value.length > 0; + +// Пустая строка не проходит: в спецификации у текстовых полей стоит @minLength(1). +export const validateFields = (dto = {}, { required = [], optional = [], partial = false } = {}) => { + const problems = []; + + for (const field of required) { + const value = dto[field]; + if (value === undefined) { + if (!partial) problems.push(`${field} обязательно`); + continue; + } + if (!isFilled(value)) problems.push(`${field} не может быть пустым`); + } + + for (const field of optional) { + const value = dto[field]; + if (value !== undefined && !isFilled(value)) { + problems.push(`${field} не может быть пустым`); + } + } + + return problems; +}; diff --git a/custom-server/src/data/comments.js b/custom-server/src/data/comments.js new file mode 100644 index 0000000..5e88f5a --- /dev/null +++ b/custom-server/src/data/comments.js @@ -0,0 +1,20 @@ +// Комментарии демонстрационного сервера. +// +// Данные на английском намеренно: сервер один на все локали курсов, см. шапку +// data/tasks.js. +// +// Разложены по первым десяти постам, по три на пост, чтобы вложенный ресурс +// `/posts/{postId}/comments` отдавал непустой и разный список у разных постов. +// Авторы взяты из users.js и не совпадают с автором поста. +const bodies = [ + 'Thanks, headers finally make sense to me', + 'Where can I read more about this?', + 'The diagram is very clear, bookmarked it', +]; + +export default Array.from({ length: 30 }, (_, index) => ({ + id: index + 1, + authorId: ((index + 1) % 10) + 1, + postId: Math.floor(index / 3) + 1, + body: bodies[index % 3], +})); diff --git a/custom-server/src/data/posts.js b/custom-server/src/data/posts.js new file mode 100644 index 0000000..440ff69 --- /dev/null +++ b/custom-server/src/data/posts.js @@ -0,0 +1,54 @@ +// Посты демонстрационного сервера. +// +// Данные на английском намеренно: сервер один на все локали курсов, см. шапку +// data/tasks.js. +// +// Их сорок, и число выбрано под урок example: он учит пагинации на запросе +// `?skip=30`, а значит записей должно быть заметно больше тридцати. Сорок дают +// последнюю страницу из десяти записей. +// +// Авторы распределены неравномерно, и у автора 1 их восемь: урок показывает +// вложенный ресурс `/users/1/posts`, и на пустом или однозаписочном списке он +// ничего не объясняет. +export default [ + { id: 1, authorId: 1, title: 'How HTTP works', body: 'Taking a request and a response apart: request line, headers, body' }, + { id: 2, authorId: 1, title: 'Status codes in practice', body: 'How 401 differs from 403 and why 404 shows up more often than the rest' }, + { id: 3, authorId: 1, title: 'REST and RPC', body: 'The same list of tasks served in two different ways' }, + { id: 4, authorId: 2, title: 'Headers people forget', body: 'Content-Type, Accept and why the server answers with something else' }, + { id: 5, authorId: 3, title: 'Idempotency in plain words', body: 'Why a repeated PUT is safe and a repeated POST is not' }, + { id: 6, authorId: 1, title: 'Pagination with skip and limit', body: 'Serving long lists in parts and why the response carries total' }, + { id: 7, authorId: 4, title: 'Caching responses', body: 'ETag, Last-Modified and conditional requests on a live example' }, + { id: 8, authorId: 2, title: 'Authentication versus authorization', body: 'Who you are and what you may do: two problems, two mechanisms' }, + { id: 9, authorId: 5, title: 'A bearer token from the inside', body: 'Where the token comes from, where it lives and what to do when it expires' }, + { id: 10, authorId: 1, title: 'JWT is signed, not encrypted', body: 'Anyone can read the payload, the signature is what stops forgery' }, + { id: 11, authorId: 6, title: 'OAuth without magic', body: 'Trading the code for a token step by step, from both sides' }, + { id: 12, authorId: 3, title: 'API versions: path or header', body: 'Where to put the version and what it costs the clients' }, + { id: 13, authorId: 7, title: 'OpenAPI as a contract', body: 'Specification before code and what both sides get out of it' }, + { id: 14, authorId: 2, title: 'Validation at the boundary', body: 'Why 422 is more useful than a handler that crashed' }, + { id: 15, authorId: 8, title: 'Errors in the response body', body: 'A problem document instead of a bare status code: RFC 7807 in practice' }, + { id: 16, authorId: 1, title: 'CORS: whose request is this', body: 'The preflight request, the headers and the usual frontend traps' }, + { id: 17, authorId: 4, title: 'Uploading files through an API', body: 'Multipart against handing the client a direct upload URL' }, + { id: 18, authorId: 9, title: 'Rate limiting', body: 'Windows, quotas and the headers that tell the client where the limit is' }, + { id: 19, authorId: 5, title: 'Webhooks instead of polling', body: 'How the server reports an event and what to do about duplicates' }, + { id: 20, authorId: 2, title: 'Soft deletes', body: 'When a record has to be hidden rather than lost' }, + { id: 21, authorId: 10, title: 'Filtering and sorting lists', body: 'Conventions for query parameters that do not turn into a mess' }, + { id: 22, authorId: 3, title: 'Partial updates', body: 'PATCH and why it is not a PUT with half of the fields' }, + { id: 23, authorId: 6, title: 'Nested resources', body: 'When /users/1/posts reads better than a filter by author' }, + { id: 24, authorId: 1, title: 'Choosing response fields', body: 'The select parameter and saving on data nobody asked for' }, + { id: 25, authorId: 7, title: 'HTTP/2 and many requests', body: 'What changed for the client and why bundling assets is history' }, + { id: 26, authorId: 4, title: 'Timeouts and retries', body: 'A client that does not wait forever and a server that expects retries' }, + { id: 27, authorId: 8, title: 'Request logs without the noise', body: 'What to write to be able to debug and what never to write at all' }, + { id: 28, authorId: 5, title: 'Testing an API', body: 'Contract checks, mocks and why unit tests alone are not enough' }, + { id: 29, authorId: 9, title: 'Documentation people actually use', body: 'Example requests matter more than field descriptions' }, + { id: 30, authorId: 2, title: 'Cursor pagination', body: 'When skip stops working and what replaces it' }, + { id: 31, authorId: 10, title: 'Bulk operations', body: 'One request for many records and what to answer on partial success' }, + { id: 32, authorId: 3, title: 'Long running operations', body: 'Answering 202, handing out a status URL and polling for the result' }, + { id: 33, authorId: 6, title: 'Date format in responses', body: 'ISO 8601, time zones and arguments that can be avoided' }, + { id: 34, authorId: 7, title: 'Numbers and money', body: 'Why an amount should not travel as a floating point number' }, + { id: 35, authorId: 1, title: 'Staying compatible while changing', body: 'What can be added safely and what breaks existing clients' }, + { id: 36, authorId: 4, title: 'GraphQL next to REST', body: 'Where a schema query wins and where plain endpoints do' }, + { id: 37, authorId: 8, title: 'Request size limits', body: 'Status 413, sensible limits and messages that explain them' }, + { id: 38, authorId: 5, title: 'Service health', body: 'Readiness and liveness checks and how they differ' }, + { id: 39, authorId: 9, title: 'Tracing a request', body: 'A request id that travels through every service on the way' }, + { id: 40, authorId: 10, title: 'Retiring an old endpoint', body: 'A warning, a deadline and the Deprecation header' }, +]; diff --git a/custom-server/src/data/tasks.js b/custom-server/src/data/tasks.js index 371eeec..1db04af 100644 --- a/custom-server/src/data/tasks.js +++ b/custom-server/src/data/tasks.js @@ -1,28 +1,31 @@ -// Набор задач, который отдаёт JSON-RPC эндпоинт. +// Задачи, которые отдают REST-маршруты и JSON-RPC. +// +// Данные на английском намеренно: сервер один на все локали курсов, и русский +// текст в ответах читался бы как ошибка у испанского или английского студента. +// По той же причине на латинице пользователи, посты и комментарии. // // Те же самые задачи приведены примерами в спецификации, в моделях Task и Tasks -// (typespec/http-api/models/task.tsp), откуда их берёт статичный мок prism для -// REST-маршрутов /tasks. Урок kinds курса http-api сравнивает REST и RPC на -// одних и тех же данных, поэтому расхождение между этими двумя местами ломает -// урок. Совпадение проверяется прогоном bin/smoke-test.js, то есть правка -// здесь без правки спецификации свалит `make test`. +// (typespec/http-api/models/task.tsp), откуда их берёт документация. Урок kinds +// курса http-api сравнивает REST и RPC на одних и тех же данных, поэтому +// расхождение между этими двумя местами ломает урок. Совпадение проверяется +// прогоном bin/smoke-test.js. export default [ { id: 1, - title: 'Опубликовать курс по основам JavaScript', - description: 'Автор подготовил курс по JavaScript. Нужно его опубликовать', + title: 'Publish the JavaScript basics course', + description: 'The author has prepared the course, it is ready to be published', status: 'Backlog', }, { id: 2, - title: 'Записать скринкаст про HTTP API', - description: 'Показать, чем REST отличается от RPC', + title: 'Record a screencast about HTTP API', + description: 'Show how REST differs from RPC', status: 'In Progress', }, { id: 3, - title: 'Обновить документацию', - description: 'Описать эндпоинт /rpc в спецификации', + title: 'Update the documentation', + description: 'Describe the /rpc endpoint in the specification', status: 'Done', }, ]; diff --git a/custom-server/src/data/users.js b/custom-server/src/data/users.js new file mode 100644 index 0000000..a2ae647 --- /dev/null +++ b/custom-server/src/data/users.js @@ -0,0 +1,20 @@ +// Пользователи демонстрационного сервера. +// +// Первые три приведены дословно в уроке example курса http-api, включая адреса +// и имена: урок печатает их как ответ `/users` и как пример работы параметра +// select. Менять эти три записи нельзя, не правя урок. +// +// Всего их десять, и это тоже из урока: он объясняет пагинацию на том, что +// `total` равен 10, а `?skip=30` отдаёт пустую страницу. +export default [ + { id: 1, email: 'max@hotmail.com', firstName: 'Allison', lastName: 'Bernier' }, + { id: 2, email: 'Colt97@yahoo.com', firstName: 'Hudson', lastName: 'Schowalter' }, + { id: 3, email: 'Landen50@gmail.com', firstName: 'Reinhold', lastName: 'Langosh' }, + { id: 4, email: 'Marcus.Kunde@hotmail.com', firstName: 'Marcus', lastName: 'Kunde' }, + { id: 5, email: 'Elena_Padberg@yahoo.com', firstName: 'Elena', lastName: 'Padberg' }, + { id: 6, email: 'Oscar.Runolfsson@gmail.com', firstName: 'Oscar', lastName: 'Runolfsson' }, + { id: 7, email: 'Nadia_Hessel@outlook.com', firstName: 'Nadia', lastName: 'Hessel' }, + { id: 8, email: 'Felix.Wiegand@yahoo.com', firstName: 'Felix', lastName: 'Wiegand' }, + { id: 9, email: 'Iris_Turcotte@gmail.com', firstName: 'Iris', lastName: 'Turcotte' }, + { id: 10, email: 'Damian.Volkman@hotmail.com', firstName: 'Damian', lastName: 'Volkman' }, +]; diff --git a/custom-server/src/index.js b/custom-server/src/index.js index 4b77ffa..13cc3bc 100644 --- a/custom-server/src/index.js +++ b/custom-server/src/index.js @@ -8,7 +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'; +import setUpResources from './resources.js'; const { dirname } = import.meta; @@ -118,9 +118,9 @@ export default async (app, _options) => { setUpRpc(app); - // REST-маршруты /http-api/tasks обслуживаются здесь, а не моком prism: см. - // шапку tasks-rest.js. - setUpTasks(app); + // REST-маршруты коллекций обслуживаются приложением, а не моком prism: см. + // шапку custom-server/src/routes.js. + setUpResources(app); app.get('/postman/cookie', (req, res) => { res.setCookie('myCookie', 'cookieValue', { diff --git a/custom-server/src/resources.js b/custom-server/src/resources.js new file mode 100644 index 0000000..6d4c3c4 --- /dev/null +++ b/custom-server/src/resources.js @@ -0,0 +1,116 @@ +// Описание коллекций: данные, проверки и требования к авторизации. +// +// Требования к авторизации взяты из спецификации: @useAuth(BearerAuth) стоит у +// создания, обновления и удаления постов и комментариев и у обновления с +// удалением пользователей. У задач авторизации нет. +// +// Наборы данных не меняются. Сервер учебный, запросы к нему идут одновременно от +// множества студентов, и мутации сделали бы уроки невоспроизводимыми: +// самостоятельная описывает один набор, а следующий студент получил бы другой. +// Поэтому create, update и delete только сообщают, что произошло бы. + +import { nextId, validateFields } from './collections.js'; +import { registerCollection, registerNested } from './routes.js'; +// Задачи описаны отдельным модулем, потому что тот же код вызывает JSON-RPC. +import * as taskStore from './tasks-store.js'; + +import comments from './data/comments.js'; +import posts from './data/posts.js'; +import users from './data/users.js'; + +// Автор создаваемой записи не приходит в теле: сервер узнаёт его по токену. +// Урок authentication обращает на это внимание отдельно, показывая, что в ответе +// появилось поле authorId, которого в запросе не было. +const TOKEN_USER_ID = 1; + +export default (app) => { + registerCollection(app, { + base: '/http-api/tasks', + envelope: 'tasks', + items: taskStore.items, + validate: taskStore.validate, + build: taskStore.build, + }); + + registerCollection(app, { + base: '/http-api/users', + envelope: 'users', + items: () => users, + validate: (dto, options = {}) => validateFields(dto, { + required: ['email', 'firstName', 'lastName', 'password'], + ...options, + }), + // Пароль в ответе не возвращается, его нет в модели User. + build: (dto) => ({ + id: nextId(users), + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + }), + auth: { update: true, remove: true }, + }); + + registerCollection(app, { + base: '/http-api/posts', + envelope: 'posts', + items: () => posts, + validate: (dto, options = {}) => validateFields(dto, { + required: ['title', 'body'], + ...options, + }), + build: (dto) => ({ + id: nextId(posts), + authorId: TOKEN_USER_ID, + title: dto.title, + body: dto.body, + }), + auth: { create: true, update: true, remove: true }, + }); + + registerCollection(app, { + base: '/http-api/comments', + envelope: 'comments', + items: () => comments, + validate: (dto, options = {}) => { + const problems = validateFields(dto, { required: ['body'], ...options }); + const needsPostId = options.partial !== true; + if (dto.postId === undefined) { + if (needsPostId) problems.push('postId обязательно'); + } else if (!Number.isInteger(Number(dto.postId))) { + problems.push('postId это целое число'); + } + return problems; + }, + build: (dto) => ({ + id: nextId(comments), + authorId: TOKEN_USER_ID, + postId: Number(dto.postId), + body: dto.body, + }), + auth: { create: true, update: true, remove: true }, + }); + + registerNested(app, { + base: '/http-api/users', + envelope: 'posts', + parents: () => users, + children: () => posts, + foreignKey: 'authorId', + }); + + registerNested(app, { + base: '/http-api/users', + envelope: 'comments', + parents: () => users, + children: () => comments, + foreignKey: 'authorId', + }); + + registerNested(app, { + base: '/http-api/posts', + envelope: 'comments', + parents: () => posts, + children: () => comments, + foreignKey: 'postId', + }); +}; diff --git a/custom-server/src/routes.js b/custom-server/src/routes.js new file mode 100644 index 0000000..da5b2c6 --- /dev/null +++ b/custom-server/src/routes.js @@ -0,0 +1,146 @@ +// Маршруты коллекций демонстрационного сервера. +// +// Все коллекции регистрируются одним и тем же кодом, потому что коды ответов — +// это учебный материал: на 404, 405, 422, 401, 201 и 204 построены +// самостоятельные работы курса http-api. Раньше их отдавал мок prism из +// спецификации, теперь они написаны руками, и разъехавшиеся реализации дали бы +// разъехавшиеся уроки. +// +// В Caddyfile у этих путей стоит handle без среза префикса, поэтому пути +// регистрируются полностью, как у /http-api/rpc. + +import { + findById, page, parseRange, parseSelect, project, +} from './collections.js'; + +// Форма ошибки повторяет то, что отдавал prism: заголовок, код и подробности. +const fail = (res, status, title, detail) => res.code(status).send({ title, status, detail }); + +const notFound = (res, id) => fail(res, 404, 'Not Found', `Записи с идентификатором ${id} нет`); + +const methodNotAllowed = (res, allow) => res + .code(405) + .header('Allow', allow) + .send({ + title: 'Method Not Allowed', + status: 405, + detail: `Адрес существует, но метод к нему не применяется. Разрешено: ${allow}`, + }); + +// Сервер демонстрационный и значение токена не проверяет, важно только наличие +// заголовка. Настоящий сервис здесь сверил бы подпись и срок. Урок +// authentication построен на том, что без заголовка приходит 401. +const hasBearer = (req) => { + const header = req.headers.authorization; + return typeof header === 'string' && /^Bearer\s+\S/i.test(header); +}; + +const unauthorized = (res) => res + .code(401) + .header('WWW-Authenticate', 'Bearer') + .send({ + title: 'Unauthorized', + status: 401, + detail: 'Нужен заголовок Authorization с Bearer-токеном', + }); + +const withAuth = (needsAuth, handler) => (req, res) => { + if (needsAuth && !hasBearer(req)) return unauthorized(res); + return handler(req, res); +}; + +// items передаётся функцией, а не массивом: у вложенных ресурсов список зависит +// от идентификатора в пути. +export const registerCollection = (app, { + base, + envelope, + items, + validate, + build, + auth = {}, +}) => { + const item = `${base}/:id`; + + app.get(base, (req, res) => { + const range = parseRange(req.query); + if (range === null) { + return fail(res, 422, 'Invalid request', 'skip и limit это целые числа не меньше нуля'); + } + const fields = parseSelect(req.query.select); + return res.send(page({ items: items(), envelope, ...range, fields })); + }); + + app.get(item, (req, res) => { + const found = findById(items(), req.params.id); + if (!found) return notFound(res, req.params.id); + return res.send(project(found, parseSelect(req.query.select))); + }); + + app.post(base, withAuth(auth.create, (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.patch(item, withAuth(auth.update, (req, res) => { + const found = findById(items(), req.params.id); + if (!found) return notFound(res, req.params.id); + const problems = validate(req.body ?? {}, { partial: true }); + if (problems.length > 0) { + return fail(res, 422, 'Invalid request', problems.join('; ')); + } + const dto = Object.fromEntries( + Object.entries(req.body ?? {}).filter(([, value]) => value !== undefined), + ); + return res.send({ ...found, ...dto, id: found.id }); + })); + + app.delete(item, withAuth(auth.remove, (req, res) => { + const found = findById(items(), req.params.id); + if (!found) return notFound(res, req.params.id); + return res.code(204).send(); + })); + + // Без этих маршрутов fastify ответил бы 404 на существующий адрес с неверным + // методом. Урок kinds просит сравнить три неудачных запроса и получить три + // разных кода, и 405 на `DELETE /tasks` один из них. + app.route({ + method: ['DELETE', 'PATCH', 'PUT'], + url: base, + handler: (req, res) => methodNotAllowed(res, 'GET, POST'), + }); + + app.route({ + method: ['POST', 'PUT'], + url: item, + handler: (req, res) => methodNotAllowed(res, 'GET, PATCH, DELETE'), + }); +}; + +// Вложенный ресурс отбирает записи по родителю: именно этого не умел мок, из-за +// чего `/users/1/posts` отдавал тот же список, что `/posts`. +export const registerNested = (app, { + base, envelope, parents, children, foreignKey, +}) => { + const url = `${base}/:id/${envelope}`; + + app.get(url, (req, res) => { + if (!findById(parents(), req.params.id)) return notFound(res, req.params.id); + const range = parseRange(req.query); + if (range === null) { + return fail(res, 422, 'Invalid request', 'skip и limit это целые числа не меньше нуля'); + } + const own = children().filter((child) => child[foreignKey] === Number(req.params.id)); + return res.send(page({ + items: own, envelope, ...range, fields: parseSelect(req.query.select), + })); + }); + + app.route({ + method: ['POST', 'PATCH', 'PUT', 'DELETE'], + url, + handler: (req, res) => methodNotAllowed(res, 'GET'), + }); +}; diff --git a/custom-server/src/rpc.js b/custom-server/src/rpc.js index 6e106a8..fd47c53 100644 --- a/custom-server/src/rpc.js +++ b/custom-server/src/rpc.js @@ -2,9 +2,9 @@ // 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 сравнивает два стиля и опирается на то, что задача с одним -// номером в обоих стилях одна и та же. +// Данные и операции берутся из tasks-store.js, того же модуля, из которого +// собираются REST-маршруты. Урок kinds сравнивает два стиля и опирается на то, +// что задача с одним номером в обоих одна и та же. import { build, find, list, parseRange, validate, diff --git a/custom-server/src/tasks-rest.js b/custom-server/src/tasks-rest.js deleted file mode 100644 index 9a78b91..0000000 --- a/custom-server/src/tasks-rest.js +++ /dev/null @@ -1,95 +0,0 @@ -// 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 index f897336..7354375 100644 --- a/custom-server/src/tasks-store.js +++ b/custom-server/src/tasks-store.js @@ -1,80 +1,38 @@ -// Операции над задачами, общие для REST-маршрутов и JSON-RPC. +// Задачи: данные и операции, общие для REST и JSON-RPC. // -// Оба стиля обслуживают одни и те же данные одним и тем же кодом: урок kinds -// курса http-api сравнивает REST и RPC и опирается на то, что задача с одним -// номером в обоих стилях одна и та же. -// -// Набор данных не меняется. Сервер учебный, запросы к нему идут одновременно от -// множества студентов, и мутации сделали бы уроки невоспроизводимыми: следующий -// студент увидел бы не то, что написано в самостоятельной. Поэтому create и -// delete только сообщают, что произошло бы, а список остаётся прежним. - +// REST-маршруты собираются из этого модуля в resources.js, а JSON-RPC вызывает +// его напрямую. Урок kinds курса http-api сравнивает два стиля и опирается на то, +// что задача с одним номером в обоих одна и та же, поэтому общий модуль здесь не +// украшение, а условие работоспособности урока. + +import { + findById, nextId, page, parseRange, parseSelect, validateFields, +} from './collections.js'; 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; - }; +export { parseRange }; - 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} не может быть пустым`); - } +export const items = () => tasks; +export const validate = (dto = {}, options = {}) => { + const problems = validateFields(dto, { required: ['title', 'description'], ...options }); if (dto.status !== undefined && !STATUSES.includes(dto.status)) { problems.push(`status должен быть одним из: ${STATUSES.join(', ')}`); } - return problems; }; export const build = (dto) => ({ - id: tasks.length + 1, + id: nextId(tasks), 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, +export const list = ({ skip, limit, select } = {}) => page({ + items: tasks, envelope: 'tasks', skip, limit, fields: parseSelect(select), }); + +export const find = (id) => findById(tasks, id); diff --git a/typespec/http-api/models/comment.tsp b/typespec/http-api/models/comment.tsp index e9049d7..4c162e8 100644 --- a/typespec/http-api/models/comment.tsp +++ b/typespec/http-api/models/comment.tsp @@ -18,7 +18,7 @@ model EditCommentDto { id: 1, authorId: 2, postId: 1, - body: "Спасибо, наконец понял, зачем нужны заголовки", + body: "Thanks, headers finally make sense to me", }) model Comment { @key @@ -33,13 +33,13 @@ model Comment { @example(#{ comments: #[ - #{ id: 1, authorId: 2, postId: 1, body: "Спасибо, наконец понял, зачем нужны заголовки" }, - #{ id: 2, authorId: 3, postId: 1, body: "А где почитать про кеширование подробнее?" }, - #{ id: 3, authorId: 4, postId: 1, body: "Схема запроса очень наглядная" } + #{ id: 1, authorId: 2, postId: 1, body: "Thanks, headers finally make sense to me" }, + #{ id: 2, authorId: 3, postId: 1, body: "Where can I read more about this?" }, + #{ id: 3, authorId: 4, postId: 1, body: "The diagram is very clear, bookmarked it" } ], - total: 3, + total: 30, skip: 0, - limit: 30, + limit: 3, }) model Comments { comments: Comment[]; diff --git a/typespec/http-api/models/course.tsp b/typespec/http-api/models/course.tsp index 16902be..49199c9 100644 --- a/typespec/http-api/models/course.tsp +++ b/typespec/http-api/models/course.tsp @@ -18,7 +18,7 @@ model EditCourseDto { @example(#{ id: 1, title: "HTTP API", - description: "Учимся проектировать и вызывать API по HTTP", + description: "Designing and calling APIs over HTTP", }) model Course { @key @@ -34,9 +34,9 @@ model Course { @example(#{ courses: #[ - #{ id: 1, title: "HTTP API", description: "Учимся проектировать и вызывать API по HTTP" }, - #{ id: 2, title: "Протокол HTTP", description: "Разбираем запрос, ответ, коды и заголовки" }, - #{ id: 3, title: "Основы JavaScript", description: "Первый язык программирования с нуля" } + #{ id: 1, title: "HTTP API", description: "Designing and calling APIs over HTTP" }, + #{ id: 2, title: "The HTTP protocol", description: "Requests, responses, status codes and headers" }, + #{ id: 3, title: "JavaScript basics", description: "A first programming language from scratch" } ], total: 3, skip: 0, diff --git a/typespec/http-api/models/post.tsp b/typespec/http-api/models/post.tsp index 427ea8a..d22eff4 100644 --- a/typespec/http-api/models/post.tsp +++ b/typespec/http-api/models/post.tsp @@ -21,8 +21,8 @@ model EditPostDto { @example(#{ id: 1, authorId: 1, - title: "Как устроен HTTP", - body: "Разбираем запрос и ответ по частям: строка запроса, заголовки, тело", + title: "How HTTP works", + body: "Taking a request and a response apart: request line, headers, body", }) model Post { @key @@ -42,25 +42,25 @@ model Post { #{ id: 1, authorId: 1, - title: "Как устроен HTTP", - body: "Разбираем запрос и ответ по частям: строка запроса, заголовки, тело", + title: "How HTTP works", + body: "Taking a request and a response apart: request line, headers, body", }, #{ id: 2, authorId: 1, - title: "Коды ответов на практике", - body: "Чем 401 отличается от 403 и почему 404 приходит чаще остальных", + title: "Status codes in practice", + body: "How 401 differs from 403 and why 404 shows up more often than the rest", }, #{ id: 3, authorId: 1, - title: "REST и RPC", - body: "Один и тот же список задач двумя разными способами", + title: "REST and RPC", + body: "The same list of tasks served in two different ways", } ], - total: 3, + total: 40, skip: 0, - limit: 30, + limit: 3, }) model Posts { posts: Post[]; diff --git a/typespec/http-api/models/task.tsp b/typespec/http-api/models/task.tsp index b03e900..f2d34f9 100644 --- a/typespec/http-api/models/task.tsp +++ b/typespec/http-api/models/task.tsp @@ -23,8 +23,8 @@ model EditTaskDto { // поэтому расхождение здесь ломает урок. Совпадение проверяет bin/smoke-test.js. @example(#{ id: 1, - title: "Опубликовать курс по основам JavaScript", - description: "Автор подготовил курс по JavaScript. Нужно его опубликовать", + title: "Publish the JavaScript basics course", + description: "The author has prepared the course, it is ready to be published", status: Status.Backlog, }) model Task { @@ -44,20 +44,20 @@ model Task { tasks: #[ #{ id: 1, - title: "Опубликовать курс по основам JavaScript", - description: "Автор подготовил курс по JavaScript. Нужно его опубликовать", + title: "Publish the JavaScript basics course", + description: "The author has prepared the course, it is ready to be published", status: Status.Backlog, }, #{ id: 2, - title: "Записать скринкаст про HTTP API", - description: "Показать, чем REST отличается от RPC", + title: "Record a screencast about HTTP API", + description: "Show how REST differs from RPC", status: Status.InProgress, }, #{ id: 3, - title: "Обновить документацию", - description: "Описать эндпоинт /rpc в спецификации", + title: "Update the documentation", + description: "Describe the /rpc endpoint in the specification", status: Status.Done, } ], diff --git a/typespec/http-api/models/user.tsp b/typespec/http-api/models/user.tsp index b372f2c..1926223 100644 --- a/typespec/http-api/models/user.tsp +++ b/typespec/http-api/models/user.tsp @@ -50,7 +50,7 @@ model User { users: #[ #{ id: 1, email: "max@hotmail.com", firstName: "Allison", lastName: "Bernier" }, #{ id: 2, email: "Colt97@yahoo.com", firstName: "Hudson", lastName: "Schowalter" }, - #{ id: 3, email: "Reyna_Bahringer@gmail.com", firstName: "Reyna", lastName: "Bahringer" }, + #{ id: 3, email: "Landen50@gmail.com", firstName: "Reinhold", lastName: "Langosh" }, #{ id: 4, email: "Marcus.Kunde@hotmail.com", firstName: "Marcus", lastName: "Kunde" }, #{ id: 5, email: "Elena_Padberg@yahoo.com", firstName: "Elena", lastName: "Padberg" }, #{ id: 6, email: "Oscar.Runolfsson@gmail.com", firstName: "Oscar", lastName: "Runolfsson" }, diff --git a/typespec/http-api/services/commentsService.tsp b/typespec/http-api/services/commentsService.tsp index ea27d8a..d9b372d 100644 --- a/typespec/http-api/services/commentsService.tsp +++ b/typespec/http-api/services/commentsService.tsp @@ -23,7 +23,7 @@ interface CommentService { @path id: string, @query select?: string - ): Comment; + ): Comment | NotFoundResponse; @useAuth(BearerAuth) @post @@ -31,9 +31,9 @@ interface CommentService { @useAuth(BearerAuth) @patch - op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditCommentDto): Comment; + op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditCommentDto): Comment | NotFoundResponse; @useAuth(BearerAuth) @delete - op delete(@path id: string): void; + op delete(@path id: string): NoContentResponse | NotFoundResponse; } diff --git a/typespec/http-api/services/postsService.tsp b/typespec/http-api/services/postsService.tsp index 406c196..363ded5 100644 --- a/typespec/http-api/services/postsService.tsp +++ b/typespec/http-api/services/postsService.tsp @@ -22,7 +22,7 @@ interface PostService { op get( @path id: string, @query select?: string - ): Post; + ): Post | NotFoundResponse; @useAuth(BearerAuth) @post @@ -30,11 +30,11 @@ interface PostService { @useAuth(BearerAuth) @patch - op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditPostDto): Post; + op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditPostDto): Post | NotFoundResponse; @useAuth(BearerAuth) @delete - op delete(@path id: string): void; + op delete(@path id: string): NoContentResponse | NotFoundResponse; @route("/{postId}/comments") op getComments( diff --git a/typespec/http-api/services/usersService.tsp b/typespec/http-api/services/usersService.tsp index aa5a110..6bd50f1 100644 --- a/typespec/http-api/services/usersService.tsp +++ b/typespec/http-api/services/usersService.tsp @@ -1,3 +1,7 @@ +// Эти маршруты обслуживает приложение, а не мок prism, см. custom-server/src/resources.js. +// Спецификация описывает настоящее поведение: skip, limit и select применяются, +// вложенные ресурсы отбирают записи по родителю, а запрос несуществующей записи +// отвечает 404. import "@typespec/http"; import "@typespec/rest"; import "../models/user.tsp"; @@ -16,8 +20,8 @@ namespace AppService; interface UserService { @get op list( - @query skip?: string, - @query limit?: string, + @query skip?: uint16 = 0, + @query limit?: uint16 = 30, @query select?: string[] ): Users; @@ -25,18 +29,18 @@ interface UserService { op get( @path id: string, @query select?: string - ): User; + ): User | NotFoundResponse; @post op create(@header contentType: "application/json" | "application/x-www-form-urlencoded", ...NewUserDto): CreatedResponse & User; @useAuth(BearerAuth) @patch - op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditUserDto): User; + op update(@header contentType: "application/json" | "application/x-www-form-urlencoded", @path id: string, ...EditUserDto): User | NotFoundResponse; @useAuth(BearerAuth) @delete - op delete(@path id: string): void; + op delete(@path id: string): NoContentResponse | NotFoundResponse; @route("/{authorId}/posts") op getPosts(