Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@

У всех трёх одинаковое устройство входа. Форма `#login` с полями `#username` и `#password` и кнопкой `button.primary[data-test=login-button]`, флеш-сообщение `#flash`, выход по `/<курс>/logout`. Приложения описаны конфигом `apps` в `custom-server/src/ui-pages.ts`, страницы лежат фрагментами в `custom-server/assets/ui-pages/<курс>/`, префикс в них записан как `{{prefix}}`.

## Демонстрации HTTP для автоматизации

Курс использует отдельные обработчики сессии, кеширования, событий и повторных запросов. Общие коллекции задач, пользователей и публикаций сохраняются.

| Метод и путь | Результат |
| --- | --- |
| `GET /http-protocol/login-form` | Форма с учебным именем `student` |
| `POST /http-protocol/session` | Куки `session=hexlet-demo` на пять минут |
| `GET /http-protocol/session` | `Guest` без куки, `Welcome, student!` с учебной куки |
| `DELETE /http-protocol/session` | Удаление куки клиента |
| `GET /http-protocol/cache` | Расписание, `ETag` и `private, max-age=60` |
| `GET /http-protocol/cache/revalidate` | То же расписание с `no-cache` |
| `GET /http-protocol/cache/no-store` | То же расписание с `no-store` |
| `POST /http-protocol/webhooks/inboxes` | Новый приёмник, его `callback_url` и `events_url` |
| `POST /http-protocol/webhooks/inboxes/{id}/subscription` | Подписка с полученным `callback_url` |
| `POST /http-protocol/webhooks/inboxes/{id}/trigger` | Отправка учебного события настоящим HTTP-запросом в свой приёмник |
| `GET /http-protocol/webhooks/inboxes/{id}/events` | Сохранённые входящие сообщения и ответы приёмника |
| `POST /http-protocol/reliability/runs` | Новый отдельный опыт с `run_id` и `base_url` |
| `GET {base_url}/temporary` | Первый ответ 503 с `Retry-After: 1`, следующий 200 |
| `GET {base_url}/limited` | 429 с `Retry-After: 1`, после ожидания 200 |
| `GET {base_url}/slow` | Ответ через две секунды для проверки таймаута |
| `POST {base_url}/operations?lose_response=1` | Создание операции с обрывом связи до ответа |
| `GET {base_url}/operations` | Результаты созданных операций |
| `GET {base_url}/journal` | Связь попыток с результатами операций |

Вебхуки принимают только подготовленные поля события. Подписка допускает приёмник этого же опыта, произвольные внешние адреса не вызываются. Реальный HTTP-запрос идёт на локальный порт приложения, за тем же публичным адресом. Получатель проверяет учебный `X-Webhook-Key: hexlet-demo-webhook` и подтверждает сохранение сообщения кодом 204. Этот общедоступный пример показывает форму проверки источника, настоящие учётные данные не используются.

При создании операции поле `title` обязательно. Если `Idempotency-Key` повторяется с тем же названием, сервер возвращает существующую операцию. Другое название под тем же ключом даёт 409. Без ключа каждый запрос создаёт отдельную запись. Параметр `lose_response=1` закрывает соединение после записи новой операции; уже найденная по ключу операция возвращается обычным ответом. При обращении через Caddy первый обрыв превращается в ответ 502 без результата операции. Это тоже оставляет клиенту задачу проверить, было ли изменение выполнено.

Приёмник и опыт живут 30 минут, данные хранятся только в памяти. Число опытов и размеры журналов ограничены. После истечения срока или перезапуска создаётся новый опыт. Фиксированный маркер сессии служит для демонстрации обмена куки, проверку настоящего аккаунта он не выполняет.

## Prerequisites

- [mise](https://mise.jdx.dev) (ставит Node, pnpm, Caddy и jactionlint по `mise.toml`)
Expand Down
2 changes: 2 additions & 0 deletions custom-server/assets/http-protocol/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ <h1>It works!</h1>
<li><a href="/http-protocol/stream">Stream</a></li>
<li><a href="/http-protocol/form">HTML form</a></li>
<li><a href="/http-protocol/image.png">PNG image</a></li>
<li><a href="/http-protocol/login-form">Demo session</a></li>
<li><a href="/http-protocol/cache">Cache example</a></li>
</ul>
</body>
</html>
16 changes: 16 additions & 0 deletions custom-server/assets/http-protocol/login-form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo session</title>
</head>
<body>
<h1>Demo session</h1>
<form action="/http-protocol/session" method="post">
<label>Username <input name="username" value="student" /></label>
<button type="submit">Sign in</button>
</form>
<p><a href="/http-protocol/session">View session</a></p>
</body>
</html>
31 changes: 31 additions & 0 deletions custom-server/src/demo-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { randomBytes } from 'node:crypto';

// Данные отдельного опыта не меняют общие учебные коллекции. Хранилище ограничено
// по числу опытов и сроку жизни, чтобы публичный стенд не накапливал данные.
export function createDemoStore<T>() {
const records = new Map<string, { value: T; expires: number }>();
return {
create(value: T) {
const now = Date.now();
for (const [id, record] of records) if (record.expires <= now) records.delete(id);
if (records.size >= 1000) return null;
const id = randomBytes(12).toString('hex');
records.set(id, { value, expires: now + 30 * 60 * 1000 });
return id;
},
get(id: string) {
const record = records.get(id);
if (!record) return undefined;
if (record.expires <= Date.now()) {
records.delete(id);
return undefined;
}
return record.value;
},
};
}

export const DEMO_ORIGIN = 'https://http.hexlet.app';
export interface DemoParams {
id: string;
}
6 changes: 6 additions & 0 deletions custom-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import rawConfig from '../../app.config.json' with { type: 'json' };
import setUpRpc from './rpc.ts';
import setUpBodyExamples from './body-examples.ts';
import setUpUiPages from './ui-pages.ts';
import setUpSessionCacheExamples from './session-cache-examples.ts';
import setUpWebhookExamples from './webhook-examples.ts';
import setUpReliabilityExamples from './reliability-examples.ts';
import setUpSpec from './openapi-spec.ts';

// title в app.config.json нет, и документация всегда отдавалась без заголовка.
Expand Down Expand Up @@ -115,6 +118,9 @@ export default async (app: FastifyInstance, _options: unknown) => {

await app.register(setUpBodyExamples);
await app.register(setUpUiPages);
await app.register(setUpSessionCacheExamples);
await app.register(setUpWebhookExamples);
await app.register(setUpReliabilityExamples);

await setupDocs(app);

Expand Down
119 changes: 119 additions & 0 deletions custom-server/src/reliability-examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { setTimeout as wait } from 'node:timers/promises';
import type { FastifyInstance } from 'fastify';
import { createDemoStore, DEMO_ORIGIN } from './demo-store.ts';
import type { DemoParams } from './demo-store.ts';

interface Operation {
id: number;
title: string;
}
interface Run {
temporaryAttempts: number;
slowPending: boolean;
nextAllowedAt: number | null;
operations: Operation[];
keys: Map<string, Operation>;
journal: { attempt: number; operation_id: number; result: string; response: string }[];
}
export default async function setUpReliabilityExamples(app: FastifyInstance) {
const runs = createDemoStore<Run>();
const prefix = '/http-protocol/reliability/runs';
app.post(prefix, (_request, reply) => {
const id = runs.create({
temporaryAttempts: 0,
slowPending: false,
nextAllowedAt: null,
operations: [],
keys: new Map(),
journal: [],
});
if (!id) return reply.code(503).send({ error: 'demo_capacity_reached' });
return reply
.code(201)
.header('Cache-Control', 'no-store')
.send({ run_id: id, base_url: `${DEMO_ORIGIN}${prefix}/${id}` });
});
app.get<{ Params: DemoParams }>(`${prefix}/:id/temporary`, (request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
reply.header('Cache-Control', 'no-store');
if (run.temporaryAttempts++ === 0)
return reply.code(503).header('Retry-After', '1').send({ error: 'temporarily_unavailable' });
return { status: 'ready' };
});
app.get<{ Params: DemoParams }>(`${prefix}/:id/limited`, (request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
reply.header('Cache-Control', 'no-store');
run.nextAllowedAt ??= Date.now() + 1000;
if (Date.now() < run.nextAllowedAt)
return reply.code(429).header('Retry-After', '1').send({ error: 'rate_limit_exceeded' });
return { status: 'ready' };
});
app.get<{ Params: DemoParams }>(`${prefix}/:id/slow`, async (request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
if (run.slowPending) return reply.code(429).send({ error: 'slow_request_in_progress' });
run.slowPending = true;
try {
await wait(2000);
return reply.header('Cache-Control', 'no-store').send({ status: 'ready' });
} finally {
run.slowPending = false;
}
});
app.get<{ Params: DemoParams }>(`${prefix}/:id/operations`, (request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
return reply.header('Cache-Control', 'no-store').send({ operations: run.operations });
});
app.get<{ Params: DemoParams }>(`${prefix}/:id/journal`, (request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
return reply.header('Cache-Control', 'no-store').send({ journal: run.journal });
});
app.post<{ Params: DemoParams; Querystring: { lose_response?: string }; Body: { title?: string } }>(
`${prefix}/:id/operations`,
(request, reply) => {
const run = runs.get(request.params.id);
if (!run) return reply.code(404).send({ error: 'demo_not_found' });
const title = request.body?.title;
const key = request.headers['idempotency-key'];
if (
typeof title !== 'string' ||
!title.trim() ||
title.length > 120 ||
(key !== undefined && (typeof key !== 'string' || key.length > 80 || !key))
) {
return reply.code(422).send({ error: 'invalid_operation' });
}
const previous = key === undefined ? undefined : run.keys.get(key);
if (previous && previous.title !== title) return reply.code(409).send({ error: 'idempotency_key_conflict' });
if (run.journal.length >= 40 || (!previous && run.operations.length >= 20)) {
return reply.code(429).send({ error: 'demo_operation_limit' });
}
const operation = previous ?? { id: run.operations.length + 1, title };
if (!previous) {
run.operations.push(operation);
if (key !== undefined) run.keys.set(key, operation);
}
const lose = !previous && request.query.lose_response === '1';
run.journal.push({
attempt: run.journal.length + 1,
operation_id: operation.id,
result: previous ? 'reused' : 'created',
response: lose ? 'connection_closed' : 'sent',
});
if (lose) {
// Операция уже зафиксирована, но клиент не получает HTTP-ответ.
reply.hijack();
reply.raw.destroy();
return reply;
}
return reply
.code(previous ? 200 : 201)
.header('Cache-Control', 'no-store')
.send(operation);
},
);
}
56 changes: 56 additions & 0 deletions custom-server/src/session-cache-examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { FastifyInstance } from 'fastify';

const OFFICE_HOURS = 'Office hours: 09:00-18:00';
const VERSION = '"office-v1"';

export default async function setUpSessionCacheExamples(app: FastifyInstance) {
app.get('/http-protocol/login-form', (_request, reply) => reply.sendFile('http-protocol/login-form.html'));
app.get('/http-protocol/session', (request, reply) => {
const text = request.cookies.session === 'hexlet-demo' ? 'Welcome, student!' : 'Guest';
return reply.type('text/plain; charset=utf-8').header('Cache-Control', 'no-store').send(text);
});
app.post<{ Body: { username?: string } }>('/http-protocol/session', (request, reply) => {
if (request.body?.username !== 'student') {
return reply.code(400).type('text/plain; charset=utf-8').send('Use the demo username student');
}
return reply
.setCookie('session', 'hexlet-demo', {
path: '/http-protocol',
maxAge: 300,
httpOnly: true,
secure: true,
sameSite: 'lax',
})
.type('text/plain; charset=utf-8')
.header('Cache-Control', 'no-store')
.send('Session started');
});
app.delete('/http-protocol/session', (_request, reply) =>
reply
.clearCookie('session', {
path: '/http-protocol',
httpOnly: true,
secure: true,
sameSite: 'lax',
})
.type('text/plain; charset=utf-8')
.header('Cache-Control', 'no-store')
.send('Session ended'),
);

for (const [url, policy] of [
['/http-protocol/cache', 'private, max-age=60'],
['/http-protocol/cache/revalidate', 'no-cache'],
['/http-protocol/cache/no-store', 'no-store'],
]) {
app.get(url, (request, reply) => {
reply.header('Cache-Control', policy);
if (policy !== 'no-store') {
reply.header('ETag', VERSION);
const matches = request.headers['if-none-match']?.split(',').map((tag) => tag.trim().replace(/^W\//, ''));
if (matches?.includes(VERSION) || matches?.includes('*')) return reply.code(304).send();
}
return reply.type('text/plain; charset=utf-8').send(OFFICE_HOURS);
});
}
}
Loading
Loading