diff --git a/README.md b/README.md index e3aa7e2..844807f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,19 @@ Устройство короткое: четыре спецификации на TypeSpec (`typespec//`, по одной на курс) компилируются в OpenAPI, а маршруты, валидацию и авторизацию строит из них `fastify-openapi-glue`. Руками написаны только данные и то, что в OpenAPI не выражается. Значит документация и поведение разъехаться не могут: студент читает ту же спецификацию, по которой работает сервер. +## Примеры форматов тела + +Урок `body-formats` курса HTTP для автоматизации использует следующие адреса. Обработчики показывают полученные данные без сохранения задач. + +| Метод и путь | Результат | +| --- | --- | +| `GET /http-protocol/form` | HTML-форма с полями `title` и `description` | +| `POST /http-protocol/form` | Полученные значения обоих полей обычным текстом | +| `POST /http-protocol/body` | JSON с заголовками запроса и данными в `json`, `form` или `files` | +| `GET /http-protocol/image.png` | Файл PNG размером 8090 байтов | + +Обработчик `body` принимает JSON, `application/x-www-form-urlencoded` и multipart. Для примера с файлом он возвращает его содержимое как строку UTF-8. Лимиты multipart допускают один файл до 64 КиБ и до десяти текстовых полей по 4 КиБ. Исходное тестовое изображение взято из [httpbin](https://httpbin.org/image/png), который распространяется под [лицензией MIT](https://github.com/postmanlabs/httpbin/blob/master/LICENSE). + ## Prerequisites - Make diff --git a/bin/body-smoke-test.js b/bin/body-smoke-test.js new file mode 100644 index 0000000..9fe0d02 --- /dev/null +++ b/bin/body-smoke-test.js @@ -0,0 +1,73 @@ +export default async function checkBodyExamples({ APP, check, getJson }) { + console.log("\nФорматы тела учебных запросов"); + const fields = { title: "Check the automation task", description: "Review the test request" }; + const formPage = await fetch(`${APP}/http-protocol/form`); + const formHtml = await formPage.text(); + check( + "форма доступна и задаёт имена обоих полей", + formPage.status === 200 && + formHtml.includes('name="title"') && + formHtml.includes('name="description"'), + ); + const formReply = await fetch(`${APP}/http-protocol/form`, { + method: "POST", + body: new URLSearchParams(fields), + }); + check( + "браузерная форма возвращает прочитанные значения", + formReply.status === 200 && + (await formReply.text()) === `title=${fields.title}\ndescription=${fields.description}\n`, + ); + const jsonReply = await getJson(`${APP}/http-protocol/body`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ ...fields, priority: 2, urgent: false }), + }); + check( + "JSON сохраняет типы и отражает заголовки", + jsonReply.status === 200 && + jsonReply.body.json.priority === 2 && + jsonReply.body.json.urgent === false && + jsonReply.body.headers.Accept === "application/json", + ); + const encodedReply = await getJson(`${APP}/http-protocol/body`, { + method: "POST", + body: new URLSearchParams(fields), + }); + check( + "кодированные поля декодируются с пробелами", + encodedReply.status === 200 && + encodedReply.body.form.title === fields.title && + encodedReply.body.json === null, + ); + const multipartBody = new FormData(); + multipartBody.set("title", fields.title); + multipartBody.set( + "attachment", + new Blob(["Please check the automation task\n"], { + type: "text/plain", + }), + "http-body-note.txt", + ); + const multipartReply = await getJson(`${APP}/http-protocol/body`, { + method: "POST", + body: multipartBody, + }); + check( + "multipart передаёт содержимое файла и текстовое поле", + multipartReply.status === 200 && + multipartReply.body.form.title === fields.title && + multipartReply.body.files.attachment === "Please check the automation task\n" && + multipartReply.body.headers["Content-Type"].includes("boundary="), + ); + const imageReply = await fetch(`${APP}/http-protocol/image.png`); + const imageData = new Uint8Array(await imageReply.arrayBuffer()); + check( + "PNG имеет заявленный размер и сигнатуру", + imageReply.status === 200 && + imageReply.headers.get("Content-Type").startsWith("image/png") && + Number(imageReply.headers.get("Content-Length")) === imageData.length && + imageData.length === 8090 && + imageData.slice(0, 8).join(",") === "137,80,78,71,13,10,26,10", + ); +} diff --git a/bin/smoke-test.js b/bin/smoke-test.js index dca754a..afd6e76 100755 --- a/bin/smoke-test.js +++ b/bin/smoke-test.js @@ -30,6 +30,7 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { readFileSync } from 'node:fs'; +import checkBodyExamples from './body-smoke-test.js'; import expectedComments from '../custom-server/src/data/comments.js'; import expectedCourses from '../custom-server/src/data/courses.js'; @@ -157,6 +158,8 @@ const run = async () => { await waitFor(`${APP}/`, 'приложение'); + await checkBodyExamples({ APP, check, getJson }); + console.log('\nREST и RPC отдают одни и те же задачи'); const restList = await getJson(TASKS); const rpcList = await rpc('tasks.list', {}); diff --git a/custom-server/assets/http-protocol/form.html b/custom-server/assets/http-protocol/form.html new file mode 100644 index 0000000..30da5c1 --- /dev/null +++ b/custom-server/assets/http-protocol/form.html @@ -0,0 +1,32 @@ + + + + + + HTTP Form + + + + HTTP examples +

Send task details

+
+ + + +
+ + diff --git a/custom-server/assets/http-protocol/image.png b/custom-server/assets/http-protocol/image.png new file mode 100644 index 0000000..cc0c128 Binary files /dev/null and b/custom-server/assets/http-protocol/image.png differ diff --git a/custom-server/assets/http-protocol/index.html b/custom-server/assets/http-protocol/index.html index 66dcc2d..59cd05c 100644 --- a/custom-server/assets/http-protocol/index.html +++ b/custom-server/assets/http-protocol/index.html @@ -11,6 +11,8 @@

It works!

  • Example
  • Redirect
  • Stream
  • +
  • HTML form
  • +
  • PNG image
  • diff --git a/custom-server/assets/main/index.html b/custom-server/assets/main/index.html index 3a250d1..ccebe28 100644 --- a/custom-server/assets/main/index.html +++ b/custom-server/assets/main/index.html @@ -10,6 +10,7 @@

    It works!

    diff --git a/custom-server/src/body-examples.js b/custom-server/src/body-examples.js new file mode 100644 index 0000000..e5522ad --- /dev/null +++ b/custom-server/src/body-examples.js @@ -0,0 +1,39 @@ +import multipart from "@fastify/multipart"; + +// Изолированный учебный обработчик отражает данные без изменения коллекций. +export default async function setUpBodyExamples(app) { + await app.register(multipart, { + limits: { files: 1, fields: 10, parts: 11, fileSize: 65536, fieldSize: 4096 }, + }); + + app.get("/http-protocol/form", (_req, res) => res.sendFile("http-protocol/form.html")); + app.post("/http-protocol/form", (req, res) => { + const { title = "", description = "" } = req.body ?? {}; + return res + .type("text/plain; charset=utf-8") + .send(`title=${title}\ndescription=${description}\n`); + }); + + app.get("/http-protocol/image.png", (_req, res) => res.sendFile("http-protocol/image.png")); + + app.post("/http-protocol/body", async (req) => { + const headers = { "Content-Type": req.headers["content-type"] }; + if (req.headers.accept) headers.Accept = req.headers.accept; + const result = { headers, json: null, form: Object.create(null), files: Object.create(null) }; + if (req.isMultipart()) { + for await (const part of req.parts()) { + if (part.type === "file") { + const data = await part.toBuffer(); + result.files[part.fieldname] = data.toString("utf8"); + } else { + result.form[part.fieldname] = part.value; + } + } + } else if (req.headers["content-type"]?.startsWith("application/json")) { + result.json = req.body; + } else { + result.form = req.body ?? {}; + } + return result; + }); +} diff --git a/custom-server/src/index.js b/custom-server/src/index.js index 1e56bcd..b159b3f 100644 --- a/custom-server/src/index.js +++ b/custom-server/src/index.js @@ -9,6 +9,7 @@ import fastifyCookie from '@fastify/cookie'; import appConfig from '../../app.config.json' with {type: 'json'} import setUpRpc from './rpc.js'; +import setUpBodyExamples from './body-examples.js'; import setUpSpec from './openapi-spec.ts'; const { dirname } = import.meta; @@ -95,6 +96,8 @@ export default async (app, _options) => { }); setUpStaticAssets(app); + await app.register(setUpBodyExamples); + await setupDocs(app); app.get('/http-protocol/example', (req, res) => { diff --git a/package.json b/package.json index b986e42..95d8b66 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/formbody": "^9.0.0", + "@fastify/multipart": "^10.1.3", "@fastify/static": "^10.1.5", "@fastify/swagger": "^9.9.0", "@fastify/swagger-ui": "^6.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a177a15..9b117a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@fastify/formbody': specifier: ^9.0.0 version: 9.0.0 + '@fastify/multipart': + specifier: ^10.1.3 + version: 10.1.3 '@fastify/static': specifier: ^10.1.5 version: 10.1.5 @@ -243,6 +246,9 @@ packages: '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + '@fastify/busboy@3.2.3': + resolution: {integrity: sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==} + '@fastify/cookie@11.1.2': resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} @@ -264,6 +270,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.1.3': + resolution: {integrity: sha512-nLZopLYgAj+0BhcSm6upfT5x4fwKDfGlJ51U4Bkg1HknFaO6LjfCc2prqbXMNjRdM0cCVzp3L4bjkvfuuTLxJg==} + '@fastify/proxy-addr@5.1.1': resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} @@ -1435,6 +1444,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 4.2.1 + '@fastify/busboy@3.2.3': {} + '@fastify/cookie@11.1.2': dependencies: cookie: 2.0.1 @@ -1459,6 +1470,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.1.3': + dependencies: + '@fastify/busboy': 3.2.3 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 6.0.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.1': dependencies: '@fastify/forwarded': 3.0.2