From a346e19644b54e265531b24447a11f5434c7438a Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:16:18 -0400 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20=D0=BF=D0=BE=D0=B4=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D1=82=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=86=D0=B5=D0=BB=D0=B8,=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D0=B8=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правки из рабочего дерева, не связанные с фиксами ниже: мёртвый root.test.js (vitest его не запускал — include только .ts, а маршрута / нет), неиспользуемый openapi() с импортом v2, `make routes` на несуществующий файл, .PHONY, outDir без emit. --- AGENTS.md | 88 ++++++++++++++++++++++++++++------------ Makefile | 12 +++--- lib/utils.ts | 5 --- package.json | 7 +++- routes/README.md | 84 ++++++++++++++++++++++++++------------ test/routes/root.test.js | 12 ------ tsconfig.json | 7 +--- 7 files changed, 135 insertions(+), 80 deletions(-) delete mode 100644 test/routes/root.test.js diff --git a/AGENTS.md b/AGENTS.md index b9bce53..e514666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,39 +2,75 @@ ## Project Structure & Module Organization -- `app.js`: Fastify entry; autoloads `plugins/` and `routes/`. -- `routes/`: HTTP handlers. -- `plugins/`: App plugins (JWT auth, DB, sensible errors, response validation, route glue). -- `db/`: Drizzle ORM schema and seeds; `drizzle/` holds generated migrations. -- `lib/`: Utilities and test data builders. -- `test/`: Node tests; helpers in `test/helper.js`, route specs in `test/routes/*.test.js`. -- `types/`: ts types -- `main.tsp`, `tsp-output/`: TypeSpec and generated OpenAPI/handler typings. +- `app.ts`: Fastify entry. Autoloads `plugins/`, then registers + `fastify-openapi-glue` with the generated OpenAPI spec and the handler map + from `routes/index.ts`. Routes are **not** autoloaded — the route table comes + from the spec, so a route exists only if it is described in `main.tsp`. +- `main.tsp`, `tsp-output/`: TypeSpec contract (source of truth) and the OpenAPI + it emits. Versions `v1` and `v2` are declared; code and codegen use `v1`. +- `routes/api/`: handler modules grouped by resource (`users.ts`, `courses.ts`, + `courses/lessons.ts`, `tokens.ts`), each wrapped in `defineHandlers`. + `routes/index.ts` merges them into `RouteHandlers` — the full generated type, + not `Partial`, so a missing handler is a compile error. +- `plugins/`: JWT auth, Drizzle (in-memory SQLite, migrated and seeded on boot), + response validation, `@fastify/sensible`. +- `db/`: Drizzle schema and seeds; generated migrations live in `drizzle/`. +- `validators/`, `rules/`: business validation (zod, built on the generated + schemas), kept out of handlers. +- `serializers/`, `policies/`, `lib/`: response shaping, authorization, + shared helpers. +- `types/`: `fastify.d.ts` (hand-written decorator typings) and + `types/handlers/*.gen.ts` — **generated, never edit by hand**. +- `test/`: Vitest specs in `test/routes/`, mirroring `routes/`; server bootstrap + in `test/helper.ts`. ## Build, Test, and Development Commands -- `npm run dev`: Start Fastify with watch on http://localhost:3000. -- `npm start`: Start in production mode. -- `npm test` or `make test`: Run Node tests (`node --test`). -- `make lint` / `make lint-fix`: Lint and autofix. -- `make check-types`: Type-check with `tsc` (JS + d.ts). -- `make generate-types`: Compile TypeSpec and generate Fastify handler types. -- `make migration-generate`: Generate Drizzle migrations. -- `make mock`: Serve mocked API from generated OpenAPI. + +Package manager is pnpm (`packageManager` in `package.json`); Node >= 26. + +- `make install`: install dependencies. +- `make dev`: Fastify with watch on http://localhost:3000. +- `make test`: Vitest run. +- `make lint`: oxlint + `tsc` + format check. `make lint-fix` autofixes. +- `make check-types`: `tsc` only (same check `make lint` runs). +- `make routes`: print the route table registered from the spec. +- `make generate-types`: TypeSpec → OpenAPI → handler types and zod schemas, + then format. `make generate-check` (CI) fails if the result is not committed. +- `make migration-generate`: Drizzle migration from the changed schema. +- `make mock`: Prism mock server from the generated OpenAPI. ## Coding Style & Naming Conventions -- **Modules**: ESM only (`type: module`). Prefer named exports; keep JSDoc types consistent with `types/`. -- **Formatting**: 2-space indent, no semicolons; follow ESLint + `@stylistic` rules. Run `make lint` before committing. -- **Files**: Group endpoints by resource in `routes/api/` (e.g., `routes/api/books.js`). Co-locate validators and serializers by domain when present. + +- **TypeScript only**, ESM (`type: module`). Node executes `.ts` directly — no + build step, no bundler. +- **Local imports carry the explicit `.ts` extension** (`allowImportingTsExtensions` + with `NodeNext`): `import users from "./api/users.ts"`. +- `tsconfig.json` sets `noEmit: true` — `tsc` type-checks, it never emits. +- **Formatting** by oxfmt: 2-space indent, semicolons, double quotes. Linting by + oxlint (`.oxlintrc.json`). Run `make lint` before committing. +- Changing the API means editing `main.tsp` first, then `make generate-types`, + then the handler. Never patch `tsp-output/` or `types/handlers/` directly. ## Testing Guidelines -- **Framework**: Node built-in `node:test` with `app.inject()`; see `test/helper.js` for server bootstrap. -- **Naming**: Place specs under `test/routes/` as `*.test.js` (e.g., `test/routes/users.test.js`). -- **Scope**: Add success tests for each new/changed route. No coverage gate enforced. + +- **Framework**: Vitest with `app.inject()`; see `test/helper.ts`. +- **Naming**: specs go to `test/routes/**/*.test.ts`. `vitest.config.ts` includes + only `*.test.ts` — a `.test.js` file is silently skipped. +- **Scope**: add success tests for each new/changed route. No coverage gate. ## Commit & Pull Request Guidelines -- **Commits**: Use clear, imperative messages (optionally Conventional Commits). Reference issues when applicable. -- **PRs**: Provide purpose, summary, linked issues, test plan, and example requests/responses (curl or HTTPie). Keep diffs focused. + +- **Commits**: Conventional Commits, imperative mood (this repo writes them in + Russian). Reference issues when applicable. +- **PRs**: the PR title must be a Conventional Commit — it becomes the squash + commit message and CI checks it. Provide purpose, summary, test plan, and + example requests/responses. Keep diffs focused. ## Security & Configuration Tips -- **Secrets**: Move JWT secret to env (e.g., `JWT_SECRET`) rather than hardcoding; use `.env` locally and never commit secrets. -- **DB**: Current DB is in-memory SQLite (`plugins/drizzle.js`). Switch to file/real DB for persistence before production. + +- **Secrets**: the JWT secret is hardcoded in `plugins/jwt.ts` for teaching + purposes. Move it to an env var (`JWT_SECRET`) before any real deployment; + use `.env` locally and never commit it. +- **DB**: SQLite in memory (`plugins/drizzle.ts`) — the database is recreated, + migrated, and seeded on every boot. Switch to a file or a real server for + persistence. diff --git a/Makefile b/Makefile index 32acb8c..7f17d80 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,10 @@ check-types: deps-update: npx ncu -u +# Таблица маршрутов целиком: их регистрирует glue по спеке, отдельного файла +# с маршрутами нет — печатать нужно приложение. routes: - pnpm exec fastify print-routes routes/api/users.js + pnpm exec fastify print-routes app.ts migration-generate: pnpm exec drizzle-kit generate @@ -47,9 +49,9 @@ generate-check: generate-types mock: pnpm exec prism mock ./tsp-output/@typespec/openapi3/openapi.v1.json -tsp-build: - -.PHONY: test routes - install: pnpm install + +.PHONY: install test dev check-types deps-update routes migration-generate \ + lint lint-fix generate-openapi generate-openapi-ts-types generate-types \ + generate-check mock diff --git a/lib/utils.ts b/lib/utils.ts index a279200..f38a920 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,4 @@ import type { FastifyReply } from "fastify"; -import openapiConst from "../tsp-output/@typespec/openapi3/openapi.v2.json" with { type: "json" }; import type { RouteHandlers } from "../types/handlers/fastify.gen.ts"; export function getPagingOptions(page: number, perPage = 10) { @@ -9,10 +8,6 @@ export function getPagingOptions(page: number, perPage = 10) { }; } -export function openapi() { - return openapiConst; -} - export function ensure( reply: FastifyReply, value: T | null | undefined, diff --git a/package.json b/package.json index 9d144ba..01720a7 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "type": "module", "name": "js-fastify-rest-api-example", - "description": "This project was bootstrapped with Fastify-CLI.", + "description": "REST API на Fastify: контракт на TypeSpec, типы и валидация из OpenAPI, Drizzle ORM.", "version": "1.0.0", - "main": "app.js", + "main": "app.ts", + "engines": { + "node": ">=26" + }, "directories": { "test": "test" }, diff --git a/routes/README.md b/routes/README.md index 75b5658..24bb751 100644 --- a/routes/README.md +++ b/routes/README.md @@ -1,29 +1,63 @@ # Routes Folder -Routes define the pathways within your application. -Fastify's structure supports the modular monolith approach, where your -application is organized into distinct, self-contained modules. -This facilitates easier scaling and future transition to a microservice architecture. -In the future you might want to independently deploy some of those. - -In this folder you should define all the routes that define the endpoints -of your web application. -Each service is a [Fastify -plugin](https://fastify.dev/docs/latest/Reference/Plugins/), it is -encapsulated (it can have its own independent plugins) and it is -typically stored in a file; be careful to group your routes logically, -e.g. all `/users` routes in a `users.js` file. We have added -a `root.js` file for you with a '/' root added. - -If a single file becomes too large, create a folder and add a `index.js` file there: -this file must be a Fastify plugin, and it will be loaded automatically -by the application. You can now add as many files as you want inside that folder. -In this way you can create complex routes within a single monolith, -and eventually extract them. - -If you need to share functionality between routes, place that -functionality into the `plugins` folder, and share it via +The route table of this application is **not** built from this folder's file +names. It is derived from the API contract: `main.tsp` (TypeSpec) is compiled to +OpenAPI in `tsp-output/`, and `fastify-openapi-glue` registers every operation +found there. This folder only supplies the implementations. + +Note what follows from that, because it differs from the default Fastify +scaffold: files here are plain handler modules, not encapsulated plugins, and +`app.ts` does not autoload them (the `@fastify/autoload` call for `routes/` is +deliberately left out). Adding a file does not add an endpoint. + +## How a handler is wired + +`fastify-openapi-glue` matches the `operationId` of each operation in the spec +to a key in the handler map it is given. `routes/index.ts` builds that map: + +```ts +const serviceHandlers: RouteHandlers = { + ...users, + ...courses, + ...lessons, + ...tokens, +} +``` + +`RouteHandlers` is generated from the spec and used in full, not as `Partial`, +so a contract operation without a handler fails type-checking instead of +surfacing as a runtime warning. + +Each module exports its handlers through `defineHandlers` from `lib/utils.ts`, +which only supplies the generated types — request params, query, body, and +allowed responses are all typed from the contract: + +```ts +const handlers = defineHandlers({ + async usersShow(request, reply) { ... }, +}) +``` + +## Adding an endpoint + +1. Describe the operation in `main.tsp`. Every operation there declares an + explicit `@operationId` — that string is the handler key, so pick it + deliberately. +2. Run `make generate-types` and commit the regenerated files — CI's + `make generate-check` fails otherwise. +3. Implement the handler in the module for that resource and spread the module + into `routes/index.ts` if it is new. +4. Add a spec under `test/routes/`. + +## Where the rest of the work lives + +Request and response shapes are checked against the spec by glue and the +`response-validation` plugin, so handlers do not repeat those checks. Business +validation (uniqueness and the like) belongs in `validators/`, authorization in +`policies/`, response shaping in `serializers/`, and anything shared across +requests in `plugins/`, exposed via [decorators](https://fastify.dev/docs/latest/Reference/Decorators/). -If you're a bit confused about using `async/await` to write routes, you would -better take a look at [Promise resolution](https://fastify.dev/docs/latest/Reference/Routes/#promise-resolution) for more details. +Group endpoints by resource: all `/users` operations in `users.ts`. When a +resource is nested, mirror the path with a folder — `/courses/{courseId}/lessons` +lives in `routes/api/courses/lessons.ts`. diff --git a/test/routes/root.test.js b/test/routes/root.test.js deleted file mode 100644 index e603300..0000000 --- a/test/routes/root.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as assert from "node:assert"; -import { test } from "vitest"; -import { build } from "../helper.ts"; - -test("default root route", async () => { - const app = await build(); - - const res = await app.inject({ - url: "/", - }); - assert.deepStrictEqual(JSON.parse(res.payload), { root: true }); -}); diff --git a/tsconfig.json b/tsconfig.json index accbb52..46d11e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,12 +11,9 @@ "allowSyntheticDefaultImports": true, "module": "NodeNext", "esModuleInterop": true, - "resolveJsonModule": true, "noUnusedLocals": false, - "noUnusedParameters": false, - "outDir": "dist", - "rootDir": "." + "noUnusedParameters": false }, "include": ["**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } From 456fb2e7461c3df1587c84acb2bdc9bb841fe7f0 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:16:28 -0400 Subject: [PATCH 02/14] =?UTF-8?q?fix(auth):=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=D1=82=D1=8C=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B2=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @useAuth(BearerAuth) стоял у пяти операций /users, но jwtVerify() в routes/api/users.ts не вызывался ни разу: список, правка и удаление любого пользователя работали без токена (проверено — 200/200/200/204). Спека уже знает, где нужна авторизация, поэтому её применяет glue через securityHandlers: имя обработчика совпадает с именем схемы безопасности. Ручные jwtVerify() из courses и lessons убраны — забыть их больше негде. Заодно удалён декоратор authenticate: он не имел ни одного вызова. Тест перечисляет защищённые операции и проверяет 401 без токена и с битым токеном, а публичные — что они остались доступны. --- app.ts | 11 ++++- plugins/jwt.ts | 12 +----- routes/api/courses.ts | 6 +-- routes/api/courses/lessons.ts | 1 - test/routes/api/auth.test.ts | 76 +++++++++++++++++++++++++++++++++++ types/fastify.d.ts | 1 - 6 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 test/routes/api/auth.test.ts diff --git a/app.ts b/app.ts index 492c66e..c915a9b 100644 --- a/app.ts +++ b/app.ts @@ -1,7 +1,7 @@ import path from "node:path"; import type { AutoloadPluginOptions } from "@fastify/autoload"; import AutoLoad from "@fastify/autoload"; -import type { FastifyPluginAsync, FastifyServerOptions } from "fastify"; +import type { FastifyPluginAsync, FastifyRequest, FastifyServerOptions } from "fastify"; import glue from "fastify-openapi-glue"; import * as z from "zod"; import serviceHandlers from "./routes/index.ts"; @@ -45,9 +45,18 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise options: opts, }); + // Авторизацию навешивает glue по `security` из спеки, сопоставляя имя + // обработчика с именем схемы (BearerAuth). Руками её писать нельзя: пока + // jwtVerify вызывался в каждом обработчике, во всех пяти операциях /users + // его забыли, и список, правка и удаление пользователей были открыты. fastify.register(glue, { // prefix: 'v1', serviceHandlers, + securityHandlers: { + BearerAuth: async (request: FastifyRequest) => { + await request.jwtVerify(); + }, + }, specification: "./tsp-output/@typespec/openapi3/openapi.v1.json", }); diff --git a/plugins/jwt.ts b/plugins/jwt.ts index 815db9c..ac61dfd 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -1,19 +1,9 @@ import jwtPlugin from "@fastify/jwt"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; export default fp(async (fastify: FastifyInstance) => { fastify.register(jwtPlugin, { secret: "supersecret", }); - fastify.decorate( - "authenticate", - async function (this: FastifyInstance, request: FastifyRequest, reply: FastifyReply) { - try { - await request.jwtVerify(); - } catch (err) { - reply.send(err); - } - }, - ); }); diff --git a/routes/api/courses.ts b/routes/api/courses.ts index 469dd46..db37b71 100644 --- a/routes/api/courses.ts +++ b/routes/api/courses.ts @@ -23,12 +23,10 @@ const handlers = defineHandlers({ }, async coursesCreate(request, reply) { - await request.jwtVerify(); const validated = await CourseValidator.validateCreate(request.db, request.body); - const creatorId = request.user?.id; const values = { ...validated, - creatorId: creatorId, + creatorId: request.user.id, }; const [course] = await request.db.insert(schemas.courses).values(values).returning(); @@ -36,7 +34,6 @@ const handlers = defineHandlers({ }, async coursesUpdate(request, reply) { - await request.jwtVerify(); const validated = await CourseValidator.validateEdit(request.db, request.body); const [course] = await request.db .update(schemas.courses) @@ -48,7 +45,6 @@ const handlers = defineHandlers({ }, async coursesDestroy(request, reply) { - await request.jwtVerify(); const [course] = await request.db .delete(schemas.courses) .where(eq(schemas.courses.id, request.params.id)) diff --git a/routes/api/courses/lessons.ts b/routes/api/courses/lessons.ts index 751e25f..ed690a6 100644 --- a/routes/api/courses/lessons.ts +++ b/routes/api/courses/lessons.ts @@ -27,7 +27,6 @@ const handlers = defineHandlers({ }, async coursesLessonsCreate(request, reply) { - await request.jwtVerify(); const validated = await LessonValidator.validateCreate(request.db, request.body); const values = { ...validated, diff --git a/test/routes/api/auth.test.ts b/test/routes/api/auth.test.ts new file mode 100644 index 0000000..4f47557 --- /dev/null +++ b/test/routes/api/auth.test.ts @@ -0,0 +1,76 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build, getAuthHeader } from "../../helper.ts"; + +// Операции, у которых в main.tsp стоит @useAuth(BearerAuth). Список повторяет +// спеку намеренно: тест должен падать, если авторизацию отвяжут от неё и +// снова начнут писать jwtVerify в обработчиках руками. +// +// Тела валидные: glue вешает проверку безопасности на preHandler, то есть +// после валидации запроса, и на кривом теле без токена придёт 400, а не 401. +const protectedOperations = [ + { method: "get", url: "/users" }, + { method: "get", url: "/users/1" }, + { method: "put", url: "/users/1", body: { fullName: "Someone Else" } }, + { method: "delete", url: "/users/1" }, + { method: "post", url: "/courses", body: { name: "Course", description: "Text" } }, + { method: "put", url: "/courses/1", body: { name: "Renamed" } }, + { method: "delete", url: "/courses/1" }, + { method: "post", url: "/courses/1/lessons", body: { name: "Lesson", body: "Text" } }, +] as const; + +const publicOperations = [ + { method: "get", url: "/courses" }, + { method: "get", url: "/courses/1" }, + { method: "get", url: "/courses/1/lessons" }, +] as const; + +// Тесты собирают все расхождения в массив и сверяют его целиком, а не падают +// на первом: иначе одна открытая операция маскирует остальные. +test("protected operations reject requests without a token", async () => { + const app = await build(); + + const failures: string[] = []; + for (const { method, url, ...rest } of protectedOperations) { + const res = await app.inject({ method, url, ...rest }); + if (res.statusCode !== 401) + failures.push(`${method.toUpperCase()} ${url} -> ${res.statusCode}`); + } + assert.deepStrictEqual(failures, []); +}); + +test("protected operations reject a malformed token", async () => { + const app = await build(); + + const failures: string[] = []; + for (const { method, url, ...rest } of protectedOperations) { + const res = await app.inject({ + method, + url, + headers: { Authorization: "Bearer not-a-jwt" }, + ...rest, + }); + if (res.statusCode !== 401) + failures.push(`${method.toUpperCase()} ${url} -> ${res.statusCode}`); + } + assert.deepStrictEqual(failures, []); +}); + +test("public operations stay reachable without a token", async () => { + const app = await build(); + + const failures: string[] = []; + for (const { method, url } of publicOperations) { + const res = await app.inject({ method, url }); + if (res.statusCode === 401) failures.push(`${method.toUpperCase()} ${url} -> 401`); + } + assert.deepStrictEqual(failures, []); +}); + +test("protected operations accept a valid token", async () => { + const app = await build(); + const authHeader = await getAuthHeader(app); + + const res = await app.inject({ url: "/users", headers: { ...authHeader } }); + assert.equal(res.statusCode, 200, res.body); +}); diff --git a/types/fastify.d.ts b/types/fastify.d.ts index f173041..7290b67 100644 --- a/types/fastify.d.ts +++ b/types/fastify.d.ts @@ -19,7 +19,6 @@ declare module "fastify" { } interface FastifyInstance extends FastifyJwtNamespace<{ namespace: "security" }> { db: ReturnType>; - authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; } // type FastifyTypebox = FastifyInstance< // RawServerDefault, From a2763f6f83a14bbc1af1b18964291a8bf0fe2d5a Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:22:15 -0400 Subject: [PATCH 03/14] =?UTF-8?q?fix(auth):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B2=20/tokens=20=D0=B8=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20ensure()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две правки в одном коммите намеренно: по отдельности получается состояние, где /tokens отдаёт статус, которого нет в контракте. ensure() вызывал httpErrors.createError, который ошибку только создаёт. 404 не наступал никогда: показ отсутствующей записи отдавал 200 с пустым телом, удаление — 204, а /tokens с неизвестным email падал в 500 при чтении поля у undefined. Сигнатура asserts при этом заставляла tsc ручаться за проверку, которой не происходило. Теперь ensure() бросает, а параметр reply ушёл: httpErrors берётся из @fastify/sensible напрямую. Пароль в /tokens не проверялся вовсе — токен выдавался на любой пароль, потому что колонки для него не было в схеме. Добавлены password_digest, хеширование scrypt из node:crypto (без новых зависимостей) и сверка при выдаче токена. Ответ на неизвестный email и на неверный пароль совпадает, иначе эндпоинт превращается в перебор зарегистрированных адресов. Хеш не должен уезжать в ответ: у User в контракте нет additionalProperties: false, поэтому схема ответа лишнее поле не отсечёт. Запросы к users ограничены публичной проекцией из db/projections.ts. В контракте: password в UserCreateDTO/UserEditDTO (@secret, minLength 8) и UnauthorizedError у tokensCreate. Это ломающее изменение v1 — версия нигде не опубликована, а AuthInfo и так обещал пароль, которого не существовало. Обработчик ошибок теперь рендерит problem+json для всех HTTP-ошибок, а не только для ZodError. Текст 5xx наружу не уходит. Побочно: seed() был асинхронным, но не ожидался в plugins/drizzle.ts — с хешированием это уже настоящая гонка. Миграция добавляет NOT NULL без DEFAULT: на пустой базе это работает, на живой понадобится трёхшаговая (добавить nullable, заполнить, ужесточить). --- app.ts | 22 +- db/projections.ts | 21 ++ db/schema.ts | 1 + db/seeds.ts | 18 +- drizzle/0001_friendly_forge.sql | 1 + drizzle/meta/0001_snapshot.json | 199 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- lib/data.ts | 16 +- lib/password.ts | 30 +++ lib/utils.ts | 13 +- main.tsp | 12 +- plugins/drizzle.ts | 2 +- routes/api/courses.ts | 6 +- routes/api/courses/lessons.ts | 2 +- routes/api/tokens.ts | 17 +- routes/api/users.ts | 31 ++- test/routes/api/not-found.test.ts | 46 ++++ test/routes/api/tokens.test.ts | 53 ++++- test/routes/api/users-security.test.ts | 99 +++++++++ tsp-output/@typespec/openapi3/openapi.v1.json | 26 ++- tsp-output/@typespec/openapi3/openapi.v2.json | 26 ++- types/handlers/types.gen.ts | 6 + types/handlers/zod.gen.ts | 2 + 23 files changed, 618 insertions(+), 40 deletions(-) create mode 100644 db/projections.ts create mode 100644 drizzle/0001_friendly_forge.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 lib/password.ts create mode 100644 test/routes/api/not-found.test.ts create mode 100644 test/routes/api/users-security.test.ts diff --git a/app.ts b/app.ts index c915a9b..4b06618 100644 --- a/app.ts +++ b/app.ts @@ -1,3 +1,4 @@ +import { STATUS_CODES } from "node:http"; import path from "node:path"; import type { AutoloadPluginOptions } from "@fastify/autoload"; import AutoLoad from "@fastify/autoload"; @@ -12,7 +13,11 @@ export interface AppOptions extends FastifyServerOptions, Partial = async (fastify, opts): Promise => { - fastify.setErrorHandler((error, _request, reply) => { + // Все модели ошибок в контракте наследуют ProblemDetails (RFC 9457), поэтому + // и отдавать их надо в этом виде. Раньше так уходил только ZodError, а + // остальное — дефолтным форматом fastify: пока 404 не наступал никогда, это + // было незаметно. + fastify.setErrorHandler((error: Error & { statusCode?: number }, _request, reply) => { if (error instanceof z.ZodError) { const errors = error.issues.map((issue) => ({ message: issue.message, @@ -26,9 +31,20 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise errors, }; reply.type("application/problem+json").code(422).send(errorDetail); - } else { - reply.send(error); + return; } + + const status = typeof error.statusCode === "number" ? error.statusCode : 500; + reply + .type("application/problem+json") + .code(status) + .send({ + status, + title: STATUS_CODES[status] ?? "Error", + // Текст ошибки 5xx наружу не уходит: он может содержать что угодно, + // вплоть до фрагмента запроса к базе. + detail: status >= 500 ? "Internal Server Error" : error.message, + }); }); fastify.addContentTypeParser( diff --git a/db/projections.ts b/db/projections.ts new file mode 100644 index 0000000..61adbf0 --- /dev/null +++ b/db/projections.ts @@ -0,0 +1,21 @@ +import { users } from "./schema.ts"; + +// Хеш пароля не должен покидать базу, а обработчики отдают строку целиком и +// схема ответа лишнее не отсечёт: у User в контракте нет +// additionalProperties: false. Поэтому публичная проекция описана здесь один +// раз — columns для relational query API, fields для .returning() у +// insert/update. Набор полей повторяет модель User из main.tsp. +// +// Лежит отдельно от schema.ts намеренно: в объект, который уходит в +// drizzle({ schema }), должны попадать только таблицы. +export const publicUserColumns = { + id: true, + fullName: true, + email: true, +} as const; + +export const publicUserFields = { + id: users.id, + fullName: users.fullName, + email: users.email, +}; diff --git a/db/schema.ts b/db/schema.ts index f37a918..5e06fd2 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -5,6 +5,7 @@ export const users = sqliteTable("users", { id: integer("id").primaryKey(), fullName: text("full_name"), email: text("email").notNull().unique(), + passwordDigest: text("password_digest").notNull(), updatedAt: text("updated_at"), createdAt: text("created_at") .notNull() diff --git a/db/seeds.ts b/db/seeds.ts index fe162f6..bad1ee6 100644 --- a/db/seeds.ts +++ b/db/seeds.ts @@ -1,16 +1,20 @@ -import { buildCourse, buildCourseLesson, buildUser } from "../lib/data.ts"; +import { buildCourse, buildCourseLesson, buildUserRecord } from "../lib/data.ts"; import type { DrizzleDB } from "../types/index.ts"; import * as schemas from "./schema.ts"; -/** - * @param {import("drizzle-orm/better-sqlite3").BetterSQLite3Database} db - */ + export default async (db: DrizzleDB) => { - const [_user1] = await db.insert(schemas.users).values(buildUser()).returning(); - const [user2] = await db.insert(schemas.users).values(buildUser()).returning(); + const [_user1] = await db + .insert(schemas.users) + .values(await buildUserRecord()) + .returning(); + const [user2] = await db + .insert(schemas.users) + .values(await buildUserRecord()) + .returning(); const [_user3] = await db .insert(schemas.users) .values( - buildUser({ + await buildUserRecord({ email: "support@hexlet.io", fullName: "Тото Поддерживающий", }), diff --git a/drizzle/0001_friendly_forge.sql b/drizzle/0001_friendly_forge.sql new file mode 100644 index 0000000..9e4891e --- /dev/null +++ b/drizzle/0001_friendly_forge.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `password_digest` text NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..2f62a5e --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,199 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "31bfd2e6-7a14-443a-b57b-47de42564d25", + "prevId": "3d6c5f9b-cbab-4fbb-b936-cbe84a1c98f6", + "tables": { + "course_lessons": { + "name": "course_lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "courseId": { + "name": "courseId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "course_lessons_courseId_courses_id_fk": { + "name": "course_lessons_courseId_courses_id_fk", + "tableFrom": "course_lessons", + "tableTo": "courses", + "columnsFrom": [ + "courseId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "courses": { + "name": "courses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "courses_creator_id_users_id_fk": { + "name": "courses_creator_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_digest": { + "name": "password_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f5b43d9..27cde96 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1724955209819, "tag": "0000_omniscient_warbound", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1787444426840, + "tag": "0001_friendly_forge", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/lib/data.ts b/lib/data.ts index 452ca4e..36b2be4 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -1,15 +1,29 @@ import { faker } from "@faker-js/faker"; import type { Course, CourseLesson, User } from "../types/index.js"; +import { hashPassword } from "./password.ts"; -export function buildUser(params: Partial = {}) { +// Пароль у всех тестовых пользователей один: тестам нужно уметь логиниться под +// любым из них, а перебирать значения незачем. +export const DEFAULT_PASSWORD = "correct-horse-battery-staple"; + +// Форма запроса к API: с открытым паролем. +export function buildUser(params: Partial & { password?: string } = {}) { const user = { fullName: faker.person.fullName(), email: faker.internet.email().toLowerCase(), + password: DEFAULT_PASSWORD, }; return Object.assign({}, user, params); } +// Форма строки в базе: с хешем вместо пароля. Нужна сидам и тестам, которые +// заводят пользователя напрямую, минуя эндпоинт. +export async function buildUserRecord(params: Partial = {}) { + const { password, ...rest } = buildUser(params); + return Object.assign({}, rest, { passwordDigest: await hashPassword(password) }, params); +} + export function buildCourse(params: Partial = {}) { const user = { creatorId: null, diff --git a/lib/password.ts b/lib/password.ts new file mode 100644 index 0000000..f88fe4d --- /dev/null +++ b/lib/password.ts @@ -0,0 +1,30 @@ +import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; + +// scrypt из node:crypto, без внешних зависимостей: параметры по умолчанию у +// него уже подобраны под пароли. Соль хранится рядом с хешем в одной строке — +// отдельная колонка ничего не даёт, соль не секрет. +const scryptAsync = promisify(scrypt) as ( + password: string, + salt: string, + keylen: number, +) => Promise; + +const SALT_BYTES = 16; +const KEY_LENGTH = 64; + +export async function hashPassword(password: string) { + const salt = randomBytes(SALT_BYTES).toString("hex"); + const derived = await scryptAsync(password, salt, KEY_LENGTH); + return `${salt}:${derived.toString("hex")}`; +} + +export async function verifyPassword(password: string, digest: string) { + const [salt, key] = digest.split(":"); + if (!salt || !key) return false; + + const expected = Buffer.from(key, "hex"); + const derived = await scryptAsync(password, salt, expected.length); + // Сравнение за постоянное время: обычное === утекает длину общего префикса. + return expected.length === derived.length && timingSafeEqual(expected, derived); +} diff --git a/lib/utils.ts b/lib/utils.ts index f38a920..ffc4974 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,4 @@ -import type { FastifyReply } from "fastify"; +import { httpErrors } from "@fastify/sensible"; import type { RouteHandlers } from "../types/handlers/fastify.gen.ts"; export function getPagingOptions(page: number, perPage = 10) { @@ -8,14 +8,17 @@ export function getPagingOptions(page: number, perPage = 10) { }; } +// Раньше здесь вызывался createError, который ошибку только создаёт. Из-за +// этого 404 не наступал никогда: отсутствующая запись давала 200 с пустым +// телом, а /tokens с неизвестным email — 500 при обращении к полю у undefined. +// Хуже того, сигнатура asserts заставляла tsc ручаться за проверку, которой не +// происходило. export function ensure( - reply: FastifyReply, value: T | null | undefined, status: number = 404, - msg?: string, + msg = "Not Found", ): asserts value is NonNullable { - const m = msg || "Not Found"; - if (value == null) reply.server.httpErrors.createError(status, m); + if (value == null) throw httpErrors.createError(status, msg); } export function defineHandlers>(t: T) { diff --git a/main.tsp b/main.tsp index 63c2d14..27c9bc0 100644 --- a/main.tsp +++ b/main.tsp @@ -81,10 +81,18 @@ model User { model UserCreateDTO { fullName?: string; email: string; + + @minLength(8) + @secret + password: string; } model UserEditDTO { fullName?: string; + + @minLength(8) + @secret + password?: string; } model Course { @@ -114,6 +122,8 @@ model CourseLesson { model AuthInfo { email: string; + + @secret password: string; } @@ -173,7 +183,7 @@ namespace tokens { op create(@body auth_info: AuthInfo): { @body token: TokenInfo; @statusCode statusCode: 201; - } | UnprocessableEntityError; + } | UnprocessableEntityError | UnauthorizedError; } @route("/courses") diff --git a/plugins/drizzle.ts b/plugins/drizzle.ts index 876a44a..28b20cb 100644 --- a/plugins/drizzle.ts +++ b/plugins/drizzle.ts @@ -10,7 +10,7 @@ export default fp(async (fastify) => { const sqlite = new Database(":memory:"); const db = drizzle(sqlite, { schema: schemas }); migrate(db, { migrationsFolder: "drizzle" }); - seed(db); + await seed(db); if (!fastify.hasRequestDecorator("db")) { fastify.decorate("db", db); diff --git a/routes/api/courses.ts b/routes/api/courses.ts index db37b71..d673c7e 100644 --- a/routes/api/courses.ts +++ b/routes/api/courses.ts @@ -18,7 +18,7 @@ const handlers = defineHandlers({ const course = await request.db.query.courses.findFirst({ where: eq(schemas.courses.id, request.params.id), }); - ensure(reply, course, 404); + ensure(course, 404); return reply.code(200).send(course); }, @@ -40,7 +40,7 @@ const handlers = defineHandlers({ .set(validated) .where(eq(schemas.courses.id, request.params.id)) .returning(); - ensure(reply, course, 404); + ensure(course, 404); return reply.code(200).send(course); }, @@ -49,7 +49,7 @@ const handlers = defineHandlers({ .delete(schemas.courses) .where(eq(schemas.courses.id, request.params.id)) .returning(); - ensure(reply, course, 404); + ensure(course, 404); return reply.code(204).send(); }, }); diff --git a/routes/api/courses/lessons.ts b/routes/api/courses/lessons.ts index ed690a6..ec74cb3 100644 --- a/routes/api/courses/lessons.ts +++ b/routes/api/courses/lessons.ts @@ -22,7 +22,7 @@ const handlers = defineHandlers({ eq(schemas.courseLessons.id, request.params.id), ), }); - ensure(reply, lesson, 404); + ensure(lesson, 404); return reply.code(200).send(lesson); }, diff --git a/routes/api/tokens.ts b/routes/api/tokens.ts index 9d781de..1f5ab00 100644 --- a/routes/api/tokens.ts +++ b/routes/api/tokens.ts @@ -1,14 +1,25 @@ +import { httpErrors } from "@fastify/sensible"; import { eq } from "drizzle-orm"; import * as schemas from "../../db/schema.ts"; -import { defineHandlers, ensure } from "../../lib/utils.ts"; +import { verifyPassword } from "../../lib/password.ts"; +import { defineHandlers } from "../../lib/utils.ts"; const handlers = defineHandlers({ async tokensCreate(request, reply) { const client = await request.db.query.users.findFirst({ - where: eq(schemas.users.email, request.body.email), + where: eq(schemas.users.email, request.body.email.toLowerCase()), }); - ensure(reply, client, 404); + + // Один и тот же ответ на «нет такого email» и «неверный пароль»: иначе + // эндпоинт превращается в проверку того, зарегистрирован ли адрес. + const valid = client + ? await verifyPassword(request.body.password, client.passwordDigest) + : false; + if (!client || !valid) { + throw httpErrors.unauthorized("Invalid email or password"); + } + const token = request.server.jwt.sign({ id: client.id }); return reply.code(201).send({ token }); }, diff --git a/routes/api/users.ts b/routes/api/users.ts index b388504..42cd0eb 100644 --- a/routes/api/users.ts +++ b/routes/api/users.ts @@ -1,41 +1,54 @@ import { asc, eq } from "drizzle-orm"; +import { publicUserColumns, publicUserFields } from "../../db/projections.ts"; import * as schemas from "../../db/schema.ts"; +import { hashPassword } from "../../lib/password.ts"; import { defineHandlers, ensure, getPagingOptions } from "../../lib/utils.ts"; import UserValidator from "../../validators/UserValidator.ts"; +// Каждый запрос ограничен публичной проекцией из db/projections.ts: в строке +// users лежит passwordDigest, и без явного перечисления полей он уедет в ответ. const handlers = defineHandlers({ async usersIndex(request, reply) { const page = request.query?.page ?? 1; const users = await request.db.query.users.findMany({ + columns: publicUserColumns, orderBy: asc(schemas.users.id), ...getPagingOptions(page, 1), }); return reply.code(200).send({ data: users }); }, + async usersShow(request, reply) { const user = await request.db.query.users.findFirst({ + columns: publicUserColumns, where: eq(schemas.users.id, request.params.id), }); - ensure(reply, user, 404); + ensure(user, 404); return reply.code(200).send(user); }, async usersCreate(request, reply) { - const validated = await UserValidator.validateCreate(request.db, request.body); - const [user] = await request.db.insert(schemas.users).values(validated).returning(); + const { password, ...validated } = await UserValidator.validateCreate(request.db, request.body); + const [user] = await request.db + .insert(schemas.users) + .values({ ...validated, passwordDigest: await hashPassword(password) }) + .returning(publicUserFields); return reply.code(201).send(user); }, async usersUpdate(request, reply) { - const validated = await UserValidator.validateEdit(request.db, request.body); + const { password, ...validated } = await UserValidator.validateEdit(request.db, request.body); + const values = password + ? { ...validated, passwordDigest: await hashPassword(password) } + : validated; const [user] = await request.db .update(schemas.users) - .set(validated) + .set(values) .where(eq(schemas.users.id, request.params.id)) - .returning(); - ensure(reply, user, 404); + .returning(publicUserFields); + ensure(user, 404); return reply.code(200).send(user); }, @@ -43,8 +56,8 @@ const handlers = defineHandlers({ const [user] = await request.db .delete(schemas.users) .where(eq(schemas.users.id, request.params.id)) - .returning(); - ensure(reply, user, 404); + .returning(publicUserFields); + ensure(user, 404); return reply.code(204).send(); }, }); diff --git a/test/routes/api/not-found.test.ts b/test/routes/api/not-found.test.ts new file mode 100644 index 0000000..db191e2 --- /dev/null +++ b/test/routes/api/not-found.test.ts @@ -0,0 +1,46 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build, getAuthHeader } from "../../helper.ts"; + +const MISSING_ID = 999_999; + +// ensure() вызывал httpErrors.createError, который ошибку только создаёт, но не +// бросает. Из-за этого ни один из этих запросов не отдавал 404: показ уходил с +// 200 и пустым телом, удаление — с 204. +test("operations on a missing record answer 404", async () => { + const app = await build(); + const authHeader = await getAuthHeader(app); + + const cases = [ + { method: "get", url: `/users/${MISSING_ID}` }, + { method: "put", url: `/users/${MISSING_ID}`, body: { fullName: "Nobody At All" } }, + { method: "delete", url: `/users/${MISSING_ID}` }, + { method: "get", url: `/courses/${MISSING_ID}` }, + { method: "get", url: `/courses/1/lessons/${MISSING_ID}` }, + ] as const; + + const failures: string[] = []; + for (const { method, url, ...rest } of cases) { + const res = await app.inject({ method, url, headers: { ...authHeader }, ...rest }); + if (res.statusCode !== 404) + failures.push(`${method.toUpperCase()} ${url} -> ${res.statusCode}`); + } + assert.deepStrictEqual(failures, []); +}); + +// Все модели ошибок в main.tsp наследуют ProblemDetails, значит и тело должно +// быть problem+json, а не дефолтным форматом fastify. +test("a 404 is rendered as RFC 9457 problem details", async () => { + const app = await build(); + const authHeader = await getAuthHeader(app); + + const res = await app.inject({ url: `/users/${MISSING_ID}`, headers: { ...authHeader } }); + + assert.equal(res.statusCode, 404); + assert.match(res.headers["content-type"] as string, /application\/problem\+json/); + assert.deepStrictEqual(JSON.parse(res.body), { + status: 404, + title: "Not Found", + detail: "Not Found", + }); +}); diff --git a/test/routes/api/tokens.test.ts b/test/routes/api/tokens.test.ts index 8f05b84..b0840cf 100644 --- a/test/routes/api/tokens.test.ts +++ b/test/routes/api/tokens.test.ts @@ -1,6 +1,7 @@ import { test } from "vitest"; import * as assert from "node:assert"; import { build } from "../../helper.ts"; +import { DEFAULT_PASSWORD } from "../../../lib/data.ts"; test("post tokens", async () => { const app = await build(); @@ -13,8 +14,58 @@ test("post tokens", async () => { url: `/tokens`, body: { email: user.email, - password: "", + password: DEFAULT_PASSWORD, }, }); assert.equal(res.statusCode, 201, res.body); }); + +test("post tokens rejects a wrong password", async () => { + const app = await build(); + + const user = await app.db.query.users.findFirst(); + assert.ok(user); + + const res = await app.inject({ + method: "post", + url: `/tokens`, + body: { email: user.email, password: "definitely-not-the-password" }, + }); + assert.equal(res.statusCode, 401, res.body); +}); + +// Неизвестный email раньше давал 500: ensure() ошибку не бросал, и обработчик +// шёл дальше читать поле у undefined. +test("post tokens rejects an unknown email", async () => { + const app = await build(); + + const res = await app.inject({ + method: "post", + url: `/tokens`, + body: { email: "nobody@hexlet.io", password: DEFAULT_PASSWORD }, + }); + assert.equal(res.statusCode, 401, res.body); +}); + +// Ответ на неизвестный email и на неверный пароль обязан совпадать, иначе по +// эндпоинту можно перебирать зарегистрированные адреса. +test("post tokens does not reveal whether an email is registered", async () => { + const app = await build(); + + const user = await app.db.query.users.findFirst(); + assert.ok(user); + + const unknown = await app.inject({ + method: "post", + url: `/tokens`, + body: { email: "nobody@hexlet.io", password: DEFAULT_PASSWORD }, + }); + const wrongPassword = await app.inject({ + method: "post", + url: `/tokens`, + body: { email: user.email, password: "definitely-not-the-password" }, + }); + + assert.equal(unknown.statusCode, wrongPassword.statusCode); + assert.deepStrictEqual(JSON.parse(unknown.body), JSON.parse(wrongPassword.body)); +}); diff --git a/test/routes/api/users-security.test.ts b/test/routes/api/users-security.test.ts new file mode 100644 index 0000000..b429790 --- /dev/null +++ b/test/routes/api/users-security.test.ts @@ -0,0 +1,99 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build, getAuthHeader } from "../../helper.ts"; +import { buildUser } from "../../../lib/data.ts"; + +// В строке users лежит passwordDigest, а схема ответа его не отсечёт: у User в +// контракте нет additionalProperties: false. Единственное, что его удерживает, +// — публичная проекция в db/projections.ts, и проверять надо каждый эндпоинт, +// который отдаёт пользователя. +test("no users endpoint leaks the password digest", async () => { + const app = await build(); + const authHeader = await getAuthHeader(app); + const user = await app.db.query.users.findFirst(); + assert.ok(user); + + const created = await app.inject({ + method: "post", + url: "/users", + body: buildUser(), + }); + assert.equal(created.statusCode, 201, created.body); + + const responses = { + index: await app.inject({ url: "/users", headers: { ...authHeader } }), + show: await app.inject({ url: `/users/${user.id}`, headers: { ...authHeader } }), + create: created, + update: await app.inject({ + method: "put", + url: `/users/${user.id}`, + headers: { ...authHeader }, + body: { fullName: "Renamed Person" }, + }), + }; + + const leaking: string[] = []; + for (const [name, res] of Object.entries(responses)) { + assert.ok(res.statusCode < 400, `${name} -> ${res.statusCode}: ${res.body}`); + if (/password/i.test(res.body)) leaking.push(`${name}: ${res.body}`); + } + assert.deepStrictEqual(leaking, []); +}); + +test("a created user can authenticate with the password they set", async () => { + const app = await build(); + const attrs = buildUser(); + + const created = await app.inject({ method: "post", url: "/users", body: attrs }); + assert.equal(created.statusCode, 201, created.body); + + const token = await app.inject({ + method: "post", + url: "/tokens", + body: { email: attrs.email, password: attrs.password }, + }); + assert.equal(token.statusCode, 201, token.body); +}); + +test("changing the password invalidates the old one", async () => { + const app = await build(); + const attrs = buildUser(); + + const created = await app.inject({ method: "post", url: "/users", body: attrs }); + assert.equal(created.statusCode, 201, created.body); + const { id } = JSON.parse(created.body); + + const authHeader = await getAuthHeader(app, id); + const updated = await app.inject({ + method: "put", + url: `/users/${id}`, + headers: { ...authHeader }, + body: { password: "a-brand-new-password" }, + }); + assert.equal(updated.statusCode, 200, updated.body); + + const withOld = await app.inject({ + method: "post", + url: "/tokens", + body: { email: attrs.email, password: attrs.password }, + }); + assert.equal(withOld.statusCode, 401, withOld.body); + + const withNew = await app.inject({ + method: "post", + url: "/tokens", + body: { email: attrs.email, password: "a-brand-new-password" }, + }); + assert.equal(withNew.statusCode, 201, withNew.body); +}); + +test("a password shorter than the contract allows is rejected", async () => { + const app = await build(); + + const res = await app.inject({ + method: "post", + url: "/users", + body: { ...buildUser(), password: "short" }, + }); + assert.equal(res.statusCode, 400, res.body); +}); diff --git a/tsp-output/@typespec/openapi3/openapi.v1.json b/tsp-output/@typespec/openapi3/openapi.v1.json index f9a0881..dd1d70f 100644 --- a/tsp-output/@typespec/openapi3/openapi.v1.json +++ b/tsp-output/@typespec/openapi3/openapi.v1.json @@ -405,6 +405,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -658,7 +668,8 @@ "type": "string" }, "password": { - "type": "string" + "type": "string", + "format": "password" } } }, @@ -910,7 +921,8 @@ "UserCreateDTO": { "type": "object", "required": [ - "email" + "email", + "password" ], "properties": { "fullName": { @@ -918,6 +930,11 @@ }, "email": { "type": "string" + }, + "password": { + "type": "string", + "minLength": 8, + "format": "password" } } }, @@ -926,6 +943,11 @@ "properties": { "fullName": { "type": "string" + }, + "password": { + "type": "string", + "minLength": 8, + "format": "password" } } }, diff --git a/tsp-output/@typespec/openapi3/openapi.v2.json b/tsp-output/@typespec/openapi3/openapi.v2.json index 41c5480..adb0880 100644 --- a/tsp-output/@typespec/openapi3/openapi.v2.json +++ b/tsp-output/@typespec/openapi3/openapi.v2.json @@ -405,6 +405,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -658,7 +668,8 @@ "type": "string" }, "password": { - "type": "string" + "type": "string", + "format": "password" } } }, @@ -914,7 +925,8 @@ "UserCreateDTO": { "type": "object", "required": [ - "email" + "email", + "password" ], "properties": { "fullName": { @@ -922,6 +934,11 @@ }, "email": { "type": "string" + }, + "password": { + "type": "string", + "minLength": 8, + "format": "password" } } }, @@ -930,6 +947,11 @@ "properties": { "fullName": { "type": "string" + }, + "password": { + "type": "string", + "minLength": 8, + "format": "password" } } }, diff --git a/types/handlers/types.gen.ts b/types/handlers/types.gen.ts index 672987e..605591c 100644 --- a/types/handlers/types.gen.ts +++ b/types/handlers/types.gen.ts @@ -102,10 +102,12 @@ export type User = { export type UserCreateDto = { fullName?: string; email: string; + password: string; }; export type UserEditDto = { fullName?: string; + password?: string; }; export type Versions = "v1" | "v2"; @@ -341,6 +343,10 @@ export type TokensCreateData = { }; export type TokensCreateErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; /** * Client error */ diff --git a/types/handlers/zod.gen.ts b/types/handlers/zod.gen.ts index faaba9c..89bcf52 100644 --- a/types/handlers/zod.gen.ts +++ b/types/handlers/zod.gen.ts @@ -100,10 +100,12 @@ export const zUser = z.object({ export const zUserCreateDto = z.object({ fullName: z.string().optional(), email: z.string(), + password: z.string().min(8), }); export const zUserEditDto = z.object({ fullName: z.string().optional(), + password: z.string().min(8).optional(), }); export const zVersions = z.enum(["v1", "v2"]); From 9c959ea565019c6fedf054abecbb97f02a8879e4 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:24:39 -0400 Subject: [PATCH 04/14] =?UTF-8?q?fix(courses):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B5=20=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обработчики требовали токен, но не смотрели, чей это токен: любой аутентифицированный пользователь правил и удалял чужие курсы и дописывал в них уроки (проверено — 204 на чужом курсе). policies/CoursePolicy при этом был пустым классом без единого вызова. Правка и удаление теперь читают курс до изменения — иначе проверять владельца не на чем, — и отвечают 403. Создание урока проверяет тот же признак по курсу: урок меняет чужой курс. Несуществующий курс там раньше упирался в ограничение внешнего ключа и давал 500, теперь это 404. В контракте у coursesUpdate/coursesDestroy/coursesLessonsCreate появился ForbiddenError — модель была описана в main.tsp, но не использовалась. --- main.tsp | 7 +- policies/CoursePolicy.ts | 17 +++- routes/api/courses.ts | 29 ++++-- routes/api/courses/lessons.ts | 13 +++ test/helper.ts | 5 +- test/routes/api/courses/lessons.test.ts | 2 +- test/routes/api/ownership.test.ts | 97 +++++++++++++++++++ tsp-output/@typespec/openapi3/openapi.v1.json | 40 ++++++++ tsp-output/@typespec/openapi3/openapi.v2.json | 40 ++++++++ types/handlers/types.gen.ts | 16 +++ 10 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 test/routes/api/ownership.test.ts diff --git a/main.tsp b/main.tsp index 27c9bc0..95df363 100644 --- a/main.tsp +++ b/main.tsp @@ -219,14 +219,15 @@ namespace courses { } | NotFoundError | UnprocessableEntityError - | UnauthorizedError; + | UnauthorizedError + | ForbiddenError; @delete @useAuth(BearerAuth) @operationId("coursesDestroy") op destroy(@path id: numeric): { @statusCode statusCode: 204; - } | NotFoundError | UnauthorizedError; + } | NotFoundError | UnauthorizedError | ForbiddenError; } @route("/courses/{courseId}/lessons") @@ -251,6 +252,6 @@ namespace courses_lessons { op create(@path courseId: numeric, @body course: CourseLessonCreateDTO): { @body lesson: CourseLesson; @statusCode statusCode: 201; - } | UnprocessableEntityError | UnauthorizedError; + } | UnprocessableEntityError | UnauthorizedError | ForbiddenError | NotFoundError; } diff --git a/policies/CoursePolicy.ts b/policies/CoursePolicy.ts index f54d670..2777446 100644 --- a/policies/CoursePolicy.ts +++ b/policies/CoursePolicy.ts @@ -1,5 +1,18 @@ +import type { Course } from "../types/index.ts"; + +// Права на курс отдельно от обработчиков: правило одно и то же для правки, +// удаления и добавления уроков, а список операций будет расти. export default class CoursePolicy { - canShowIndex() { - // Проверка + static canUpdate(course: Course, userId: number) { + return course.creatorId === userId; + } + + static canDestroy(course: Course, userId: number) { + return course.creatorId === userId; + } + + // Урок меняет чужой курс, поэтому право то же, что на правку самого курса. + static canAddLesson(course: Course, userId: number) { + return course.creatorId === userId; } } diff --git a/routes/api/courses.ts b/routes/api/courses.ts index d673c7e..abadf27 100644 --- a/routes/api/courses.ts +++ b/routes/api/courses.ts @@ -1,7 +1,9 @@ +import { httpErrors } from "@fastify/sensible"; import { asc, eq } from "drizzle-orm"; import * as schemas from "../../db/schema.ts"; import { defineHandlers, ensure, getPagingOptions } from "../../lib/utils.ts"; +import CoursePolicy from "../../policies/CoursePolicy.ts"; import CourseValidator from "../../validators/CourseValidator.ts"; const handlers = defineHandlers({ @@ -33,23 +35,36 @@ const handlers = defineHandlers({ return reply.code(201).send(course); }, + // Курс читается до правки, а не правится сразу с returning: иначе проверить + // владельца не на чем, и любой аутентифицированный менял чужой курс. async coursesUpdate(request, reply) { + const course = await request.db.query.courses.findFirst({ + where: eq(schemas.courses.id, request.params.id), + }); + ensure(course, 404); + if (!CoursePolicy.canUpdate(course, request.user.id)) { + throw httpErrors.forbidden("You can only change your own courses"); + } + const validated = await CourseValidator.validateEdit(request.db, request.body); - const [course] = await request.db + const [updated] = await request.db .update(schemas.courses) .set(validated) .where(eq(schemas.courses.id, request.params.id)) .returning(); - ensure(course, 404); - return reply.code(200).send(course); + return reply.code(200).send(updated); }, async coursesDestroy(request, reply) { - const [course] = await request.db - .delete(schemas.courses) - .where(eq(schemas.courses.id, request.params.id)) - .returning(); + const course = await request.db.query.courses.findFirst({ + where: eq(schemas.courses.id, request.params.id), + }); ensure(course, 404); + if (!CoursePolicy.canDestroy(course, request.user.id)) { + throw httpErrors.forbidden("You can only delete your own courses"); + } + + await request.db.delete(schemas.courses).where(eq(schemas.courses.id, request.params.id)); return reply.code(204).send(); }, }); diff --git a/routes/api/courses/lessons.ts b/routes/api/courses/lessons.ts index ec74cb3..2c35669 100644 --- a/routes/api/courses/lessons.ts +++ b/routes/api/courses/lessons.ts @@ -1,7 +1,9 @@ +import { httpErrors } from "@fastify/sensible"; import { and, asc, eq } from "drizzle-orm"; import * as schemas from "../../../db/schema.ts"; import { defineHandlers, ensure, getPagingOptions } from "../../../lib/utils.ts"; +import CoursePolicy from "../../../policies/CoursePolicy.ts"; import LessonValidator from "../../../validators/Course/LessonValidator.ts"; const handlers = defineHandlers({ @@ -26,7 +28,18 @@ const handlers = defineHandlers({ return reply.code(200).send(lesson); }, + // Урок добавляется в чужой курс, поэтому право проверяется по курсу. Раньше + // хватало любого токена, а несуществующий курс упирался в ограничение + // внешнего ключа и давал 500. async coursesLessonsCreate(request, reply) { + const course = await request.db.query.courses.findFirst({ + where: eq(schemas.courses.id, request.params.courseId), + }); + ensure(course, 404); + if (!CoursePolicy.canAddLesson(course, request.user.id)) { + throw httpErrors.forbidden("You can only add lessons to your own courses"); + } + const validated = await LessonValidator.validateCreate(request.db, request.body); const values = { ...validated, diff --git a/test/helper.ts b/test/helper.ts index 3a33888..7843a21 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -30,7 +30,10 @@ function serverConfig() { }; } -async function build() { +// Тип возвращаемого значения проставлен руками: helper из fastify-cli — это +// нетипизированный JS, и без аннотации весь app в тестах становится any, а +// вместе с ним и всё, что из него читают. +async function build(): Promise { // you can set all the options supported by the fastify CLI command const argv = [AppPath]; diff --git a/test/routes/api/courses/lessons.test.ts b/test/routes/api/courses/lessons.test.ts index 58beaa8..351f3c1 100644 --- a/test/routes/api/courses/lessons.test.ts +++ b/test/routes/api/courses/lessons.test.ts @@ -34,7 +34,7 @@ test("post lessons", async () => { const body = buildCourseLesson(); - const authHeader = await getAuthHeader(app); + const authHeader = await getAuthHeader(app, course.creatorId); const res = await app.inject({ method: "post", url: `/courses/${course.id}/lessons`, diff --git a/test/routes/api/ownership.test.ts b/test/routes/api/ownership.test.ts new file mode 100644 index 0000000..de8c77d --- /dev/null +++ b/test/routes/api/ownership.test.ts @@ -0,0 +1,97 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build, getAuthHeader } from "../../helper.ts"; +import { buildCourseLesson } from "../../../lib/data.ts"; + +// Возвращает курс и пользователя, который его не создавал. +async function buildWithOutsider() { + const app = await build(); + const course = await app.db.query.courses.findFirst(); + assert.ok(course); + + const users = await app.db.query.users.findMany(); + const outsider = users.find((user) => user.id !== course.creatorId); + assert.ok(outsider, "seeds must contain a user who owns no courses"); + + return { app, course, authHeader: await getAuthHeader(app, outsider.id) }; +} + +// Проверялся только факт аутентификации, но не владение: любой пользователь с +// токеном правил и удалял чужие курсы и дописывал в них уроки. +test("a stranger cannot update someone else's course", async () => { + const { app, course, authHeader } = await buildWithOutsider(); + + const res = await app.inject({ + method: "put", + url: `/courses/${course.id}`, + headers: { ...authHeader }, + body: { name: "Hijacked" }, + }); + assert.equal(res.statusCode, 403, res.body); + + const unchanged = await app.db.query.courses.findFirst(); + assert.equal(unchanged?.name, course.name); +}); + +test("a stranger cannot delete someone else's course", async () => { + const { app, course, authHeader } = await buildWithOutsider(); + + const res = await app.inject({ + method: "delete", + url: `/courses/${course.id}`, + headers: { ...authHeader }, + }); + assert.equal(res.statusCode, 403, res.body); + + const survivors = await app.db.query.courses.findMany(); + assert.ok(survivors.some((item) => item.id === course.id)); +}); + +test("a stranger cannot add a lesson to someone else's course", async () => { + const { app, course, authHeader } = await buildWithOutsider(); + + const res = await app.inject({ + method: "post", + url: `/courses/${course.id}/lessons`, + headers: { ...authHeader }, + body: buildCourseLesson(), + }); + assert.equal(res.statusCode, 403, res.body); +}); + +// Несуществующий курс раньше упирался в ограничение внешнего ключа и давал 500. +test("adding a lesson to a missing course answers 404", async () => { + const app = await build(); + const authHeader = await getAuthHeader(app); + + const res = await app.inject({ + method: "post", + url: `/courses/999999/lessons`, + headers: { ...authHeader }, + body: buildCourseLesson(), + }); + assert.equal(res.statusCode, 404, res.body); +}); + +test("the owner can still update, delete and extend their own course", async () => { + const app = await build(); + const course = await app.db.query.courses.findFirst(); + assert.ok(course); + const authHeader = await getAuthHeader(app, course.creatorId); + + const lesson = await app.inject({ + method: "post", + url: `/courses/${course.id}/lessons`, + headers: { ...authHeader }, + body: buildCourseLesson(), + }); + assert.equal(lesson.statusCode, 201, lesson.body); + + const updated = await app.inject({ + method: "put", + url: `/courses/${course.id}`, + headers: { ...authHeader }, + body: { name: "Renamed by the owner" }, + }); + assert.equal(updated.statusCode, 200, updated.body); +}); diff --git a/tsp-output/@typespec/openapi3/openapi.v1.json b/tsp-output/@typespec/openapi3/openapi.v1.json index dd1d70f..7eabdaa 100644 --- a/tsp-output/@typespec/openapi3/openapi.v1.json +++ b/tsp-output/@typespec/openapi3/openapi.v1.json @@ -167,6 +167,26 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "The server cannot find the requested resource.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -309,6 +329,16 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -372,6 +402,16 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { diff --git a/tsp-output/@typespec/openapi3/openapi.v2.json b/tsp-output/@typespec/openapi3/openapi.v2.json index adb0880..39f0582 100644 --- a/tsp-output/@typespec/openapi3/openapi.v2.json +++ b/tsp-output/@typespec/openapi3/openapi.v2.json @@ -167,6 +167,26 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "The server cannot find the requested resource.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -309,6 +329,16 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -372,6 +402,16 @@ } } }, + "403": { + "description": "Access is forbidden.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { diff --git a/types/handlers/types.gen.ts b/types/handlers/types.gen.ts index 605591c..4f5f4c9 100644 --- a/types/handlers/types.gen.ts +++ b/types/handlers/types.gen.ts @@ -194,6 +194,14 @@ export type CoursesLessonsCreateErrors = { * Access is unauthorized. */ 401: UnauthorizedError; + /** + * Access is forbidden. + */ + 403: ForbiddenError; + /** + * The server cannot find the requested resource. + */ + 404: NotFoundError; /** * Client error */ @@ -256,6 +264,10 @@ export type CoursesDestroyErrors = { * Access is unauthorized. */ 401: UnauthorizedError; + /** + * Access is forbidden. + */ + 403: ForbiddenError; /** * The server cannot find the requested resource. */ @@ -314,6 +326,10 @@ export type CoursesUpdateErrors = { * Access is unauthorized. */ 401: UnauthorizedError; + /** + * Access is forbidden. + */ + 403: ForbiddenError; /** * The server cannot find the requested resource. */ From cbf30e4199da49f13c47c7648031e07f125f7bef Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:34:34 -0400 Subject: [PATCH 05/14] =?UTF-8?q?fix(db):=20=D0=B2=D0=B5=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B9=D0=BC=D1=81=D1=82=D0=B5=D0=BC=D0=BF=D1=8B?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20drizzle,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20SQL-=D0=B4=D0=B5=D1=84=D0=BE=D0=BB=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updatedAt не обновлялся никогда: обработчики его не писали, а DEFAULT срабатывает только на INSERT. Теперь его ведёт $onUpdate, и он есть у всех трёх таблиц, а не у одних users. createdAt был text с default (unixepoch()), то есть в текстовую колонку клалось число. Тип сменён на integer с mode: timestamp — наружу теперь уходит дата, а не строка вида "1787349516". SELECT'ы в миграции поправлены руками: drizzle-kit сгенерировал перенос updated_at из courses и course_lessons, где такой колонки не было, и миграция падала. Ровно поэтому миграции и не входят в generate-check. --- db/schema.ts | 28 +++-- drizzle/0002_handy_paladin.sql | 48 ++++++++ drizzle/meta/0002_snapshot.json | 210 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 ++ test/db/timestamps.test.ts | 36 ++++++ 5 files changed, 318 insertions(+), 11 deletions(-) create mode 100644 drizzle/0002_handy_paladin.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 test/db/timestamps.test.ts diff --git a/db/schema.ts b/db/schema.ts index 5e06fd2..5aae325 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,15 +1,25 @@ -import { sql } from "drizzle-orm"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +// Таймстемпы ведёт drizzle, а не SQL-дефолты: updatedAt иначе не обновляется +// никогда — обработчики его не писали, а DEFAULT срабатывает только на INSERT. +// Тип integer, а не text: раньше default (unixepoch()) клал число в текстовую +// колонку, и наружу уезжала строка вида "1787349516". +const timestamps = { + createdAt: integer("created_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .$defaultFn(() => new Date()) + .$onUpdate(() => new Date()), +}; + export const users = sqliteTable("users", { id: integer("id").primaryKey(), fullName: text("full_name"), email: text("email").notNull().unique(), passwordDigest: text("password_digest").notNull(), - updatedAt: text("updated_at"), - createdAt: text("created_at") - .notNull() - .default(sql`(unixepoch())`), + ...timestamps, }); export const courses = sqliteTable("courses", { @@ -19,9 +29,7 @@ export const courses = sqliteTable("courses", { .references(() => users.id) .notNull(), description: text("description").notNull(), - createdAt: text("created_at") - .notNull() - .default(sql`(unixepoch())`), + ...timestamps, }); export const courseLessons = sqliteTable("course_lessons", { @@ -31,7 +39,5 @@ export const courseLessons = sqliteTable("course_lessons", { .references(() => courses.id) .notNull(), body: text("body").notNull(), - createdAt: text("created_at") - .notNull() - .default(sql`(unixepoch())`), + ...timestamps, }); diff --git a/drizzle/0002_handy_paladin.sql b/drizzle/0002_handy_paladin.sql new file mode 100644 index 0000000..ba3ca0f --- /dev/null +++ b/drizzle/0002_handy_paladin.sql @@ -0,0 +1,48 @@ +-- Пересоздание таблиц под таймстемпы: created_at из text в integer, updated_at +-- добавлен и стал NOT NULL. +-- +-- SELECT'ы поправлены руками. drizzle-kit сгенерировал перенос updated_at из +-- courses и course_lessons, где такой колонки никогда не было, — миграция +-- падала с «no such column: updated_at». Для них updated_at заполняется из +-- created_at, для users — из старого значения, если оно было. +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_course_lessons` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `courseId` integer NOT NULL, + `body` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`courseId`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_course_lessons`("id", "name", "courseId", "body", "created_at", "updated_at") SELECT "id", "name", "courseId", "body", CAST("created_at" AS integer), CAST("created_at" AS integer) FROM `course_lessons`;--> statement-breakpoint +DROP TABLE `course_lessons`;--> statement-breakpoint +ALTER TABLE `__new_course_lessons` RENAME TO `course_lessons`;--> statement-breakpoint +CREATE TABLE `__new_courses` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `creator_id` integer NOT NULL, + `description` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_courses`("id", "name", "creator_id", "description", "created_at", "updated_at") SELECT "id", "name", "creator_id", "description", CAST("created_at" AS integer), CAST("created_at" AS integer) FROM `courses`;--> statement-breakpoint +DROP TABLE `courses`;--> statement-breakpoint +ALTER TABLE `__new_courses` RENAME TO `courses`;--> statement-breakpoint +CREATE TABLE `__new_users` ( + `id` integer PRIMARY KEY NOT NULL, + `full_name` text, + `email` text NOT NULL, + `password_digest` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_users`("id", "full_name", "email", "password_digest", "created_at", "updated_at") SELECT "id", "full_name", "email", "password_digest", CAST("created_at" AS integer), COALESCE(CAST("updated_at" AS integer), CAST("created_at" AS integer)) FROM `users`;--> statement-breakpoint +DROP TABLE `users`;--> statement-breakpoint +ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint +CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7a5fc43 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,210 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "71e237eb-50bc-4f3c-a0ae-200bd03e64c7", + "prevId": "31bfd2e6-7a14-443a-b57b-47de42564d25", + "tables": { + "course_lessons": { + "name": "course_lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "courseId": { + "name": "courseId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "course_lessons_courseId_courses_id_fk": { + "name": "course_lessons_courseId_courses_id_fk", + "tableFrom": "course_lessons", + "tableTo": "courses", + "columnsFrom": [ + "courseId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "courses": { + "name": "courses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "courses_creator_id_users_id_fk": { + "name": "courses_creator_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_digest": { + "name": "password_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 27cde96..76c8bfb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1787444426840, "tag": "0001_friendly_forge", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1787444748820, + "tag": "0002_handy_paladin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/test/db/timestamps.test.ts b/test/db/timestamps.test.ts new file mode 100644 index 0000000..7256f9e --- /dev/null +++ b/test/db/timestamps.test.ts @@ -0,0 +1,36 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { eq } from "drizzle-orm"; +import { build } from "../helper.ts"; +import { buildUserRecord } from "../../lib/data.ts"; +import * as schemas from "../../db/schema.ts"; + +// updatedAt не писался никогда: обработчики его не трогали, а SQL-дефолт +// срабатывает только на INSERT. Теперь его ведёт drizzle через $onUpdate. +test("updatedAt moves on update and createdAt stays put", async () => { + const app = await build(); + + // Отметка ставится заведомо старой, а не через паузу в тесте: хранятся они с + // точностью до секунды, и sleep на секунду ради одного сравнения — плохой + // обмен. + const past = new Date("2020-01-01T00:00:00Z"); + const [user] = await app.db + .insert(schemas.users) + .values({ ...(await buildUserRecord()), createdAt: past, updatedAt: past }) + .returning(); + + assert.ok(user.createdAt instanceof Date, `createdAt is ${typeof user.createdAt}`); + assert.equal(user.updatedAt.getTime(), past.getTime()); + + const [updated] = await app.db + .update(schemas.users) + .set({ fullName: "Renamed Person" }) + .where(eq(schemas.users.id, user.id)) + .returning(); + + assert.ok( + updated.updatedAt.getTime() > past.getTime(), + `updatedAt did not move: ${updated.updatedAt.toISOString()}`, + ); + assert.equal(updated.createdAt.getTime(), past.getTime()); +}); From 150e9b696aafa7386cca8248e63d4a295a2a8029 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:34:35 -0400 Subject: [PATCH 06/14] =?UTF-8?q?perf(test):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D1=86=D0=B5=D0=BD=D1=83=20scrypt=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BD=D0=B8=D0=B7=D0=B8=D1=82=D1=8C=20=D0=B5?= =?UTF-8?q?=D1=91=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Боевые параметры scrypt — ~230 мс на хеш, а сиды заводят трёх пользователей на каждый build(). Прогон тестов от этого вырос на десятки секунд. Стоимость читается из SCRYPT_COST и хранится внутри дайджеста (scrypt$N$salt$hash), поэтому её смена не обесценивает уже выданные хеши — проверка берёт параметры из самой строки. В vitest выставлено 1024. Заодно include в vitest расширен на .js: раньше .js-тест молча не запускался. --- lib/data.ts | 13 ++++++++++++- lib/password.ts | 43 +++++++++++++++++++++++++++++++++++-------- vitest.config.ts | 8 +++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/lib/data.ts b/lib/data.ts index 36b2be4..d13bcdd 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -17,11 +17,22 @@ export function buildUser(params: Partial & { password?: string } = {}) { return Object.assign({}, user, params); } +// scrypt считается десятки миллисекунд — это его работа. Но сиды прогоняются +// на каждый build() в тестах, и пароль там всегда один, поэтому хеш дефолтного +// считается один раз на процесс. +let defaultDigest: Promise | undefined; + +function digestOf(password: string) { + if (password !== DEFAULT_PASSWORD) return hashPassword(password); + defaultDigest ??= hashPassword(DEFAULT_PASSWORD); + return defaultDigest; +} + // Форма строки в базе: с хешем вместо пароля. Нужна сидам и тестам, которые // заводят пользователя напрямую, минуя эндпоинт. export async function buildUserRecord(params: Partial = {}) { const { password, ...rest } = buildUser(params); - return Object.assign({}, rest, { passwordDigest: await hashPassword(password) }, params); + return Object.assign({}, rest, { passwordDigest: await digestOf(password) }, params); } export function buildCourse(params: Partial = {}) { diff --git a/lib/password.ts b/lib/password.ts index f88fe4d..9352a4d 100644 --- a/lib/password.ts +++ b/lib/password.ts @@ -1,30 +1,57 @@ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; -// scrypt из node:crypto, без внешних зависимостей: параметры по умолчанию у -// него уже подобраны под пароли. Соль хранится рядом с хешем в одной строке — -// отдельная колонка ничего не даёт, соль не секрет. +// scrypt из node:crypto, без внешних зависимостей. const scryptAsync = promisify(scrypt) as ( password: string, salt: string, keylen: number, + options: { cost: number; maxmem: number }, ) => Promise; const SALT_BYTES = 16; const KEY_LENGTH = 64; +// Цена подбора. Дефолт node — 16384, это ~230 мс на хеш; в тестах, где сиды +// заводят пользователей на каждый build(), столько платить незачем, поэтому +// стоимость выносится в окружение. +const DEFAULT_COST = 16384; + +function currentCost() { + const configured = Number(process.env.SCRYPT_COST); + return Number.isSafeInteger(configured) && configured > 1 ? configured : DEFAULT_COST; +} + +// scrypt требует памяти порядка 128 * N * r; дефолтного maxmem хватает только +// до N = 16384, поэтому лимит считается от стоимости. +function memoryFor(cost: number) { + return 128 * cost * 8 * 2; +} + +// Стоимость хранится внутри дайджеста, а не берётся из окружения при проверке: +// иначе смена SCRYPT_COST разом обесценивает все выданные хеши. export async function hashPassword(password: string) { + const cost = currentCost(); const salt = randomBytes(SALT_BYTES).toString("hex"); - const derived = await scryptAsync(password, salt, KEY_LENGTH); - return `${salt}:${derived.toString("hex")}`; + const derived = await scryptAsync(password, salt, KEY_LENGTH, { + cost, + maxmem: memoryFor(cost), + }); + return `scrypt$${cost}$${salt}$${derived.toString("hex")}`; } export async function verifyPassword(password: string, digest: string) { - const [salt, key] = digest.split(":"); - if (!salt || !key) return false; + const [scheme, rawCost, salt, key] = digest.split("$"); + if (scheme !== "scrypt" || !rawCost || !salt || !key) return false; + + const cost = Number(rawCost); + if (!Number.isSafeInteger(cost) || cost < 2) return false; const expected = Buffer.from(key, "hex"); - const derived = await scryptAsync(password, salt, expected.length); + const derived = await scryptAsync(password, salt, expected.length, { + cost, + maxmem: memoryFor(cost), + }); // Сравнение за постоянное время: обычное === утекает длину общего префикса. return expected.length === derived.length && timingSafeEqual(expected, derived); } diff --git a/vitest.config.ts b/vitest.config.ts index ed8bf77..1773adf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["test/**/*.test.ts"], + include: ["test/**/*.test.{ts,js}"], + env: { + // Боевая цена scrypt — ~230 мс на хеш, а сиды прогоняются на каждый + // build(). Стоимость лежит внутри дайджеста, так что проверка от этого + // не ломается. + SCRYPT_COST: "1024", + }, }, }); From ad49b93227c1fabdd55a357494f0a33881e4a547 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:38:47 -0400 Subject: [PATCH 07/14] =?UTF-8?q?feat(config):=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=20=D0=B8=D0=B7=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5,=20security-=D0=BF=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D0=B4=D0=BE=D0=BA-=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT-секрет был зашит в код строкой "supersecret". Теперь конфиг проверяется схемой @fastify/env на старте: без JWT_SECRET длиной от 32 символов приложение не поднимается. plugins/jwt объявляет зависимость от env явно, а не рассчитывает на алфавитный порядок autoload. Добавлены штатные плагины: helmet, cors и rate-limit — все три настраиваются из того же конфига. CSP у helmet выключен: страницу документации он ломает. Документация — @scalar/fastify-api-reference на /docs поверх /openapi.json, который отдаётся из той же спеки, по которой glue регистрирует маршруты. pino-pretty переехал в devDependencies: test/helper.ts настраивает его транспортом, а в package.json его не было — работал только потому, что всплыл в node_modules из fastify-cli. Локально нужен .env, шаблон в .env.example. --- .env.example | 11 +++ package.json | 6 ++ plugins/docs.ts | 17 ++++ plugins/env.ts | 25 ++++++ plugins/jwt.ts | 16 ++-- plugins/security.ts | 21 +++++ pnpm-lock.yaml | 150 ++++++++++++++++++++++++++++++++++++ test/plugins/config.test.ts | 52 +++++++++++++ types/fastify.d.ts | 6 ++ vitest.config.ts | 6 ++ 10 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 plugins/docs.ts create mode 100644 plugins/env.ts create mode 100644 plugins/security.ts create mode 100644 test/plugins/config.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd46a9e --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Скопируйте в .env: `cp .env.example .env`. +# Схема проверяется на старте в plugins/env.ts — без JWT_SECRET приложение не +# поднимется. + +# Минимум 32 символа. Сгенерировать: openssl rand -hex 32 +JWT_SECRET=replace-me-with-at-least-32-characters + +# Необязательные, значения по умолчанию в схеме. +# NODE_ENV=development +# CORS_ORIGIN=* +# RATE_LIMIT_MAX=100 diff --git a/package.json b/package.json index 01720a7..17cb04c 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,14 @@ }, "dependencies": { "@fastify/autoload": "^6.5.0", + "@fastify/cors": "^11.3.0", + "@fastify/env": "^7.0.0", + "@fastify/helmet": "^13.1.1", "@fastify/jwt": "^10.2.2", + "@fastify/rate-limit": "^11.2.0", "@fastify/response-validation": "^3.0.4", "@fastify/sensible": "^6.0.5", + "@scalar/fastify-api-reference": "^1.66.1", "@typespec/openapi": "^1.15.0", "ajv-formats": "^3.0.1", "better-sqlite3": "^13.0.3", @@ -42,6 +47,7 @@ "npm-check-updates": "^23.0.2", "oxfmt": "^0.64.0", "oxlint": "^1.79.0", + "pino-pretty": "^13.1.3", "typescript": "^7.0.2", "vitest": "^4.1.11" }, diff --git a/plugins/docs.ts b/plugins/docs.ts new file mode 100644 index 0000000..36a6312 --- /dev/null +++ b/plugins/docs.ts @@ -0,0 +1,17 @@ +import scalar from "@scalar/fastify-api-reference"; +import fp from "fastify-plugin"; +import openapi from "../tsp-output/@typespec/openapi3/openapi.v1.json" with { type: "json" }; + +// Документация отдаётся из той же спеки, по которой зарегистрированы маршруты, +// поэтому разойтись с реализацией ей неоткуда. +export default fp( + async (fastify) => { + fastify.get("/openapi.json", async () => openapi); + + await fastify.register(scalar, { + routePrefix: "/docs", + configuration: { url: "/openapi.json" }, + }); + }, + { name: "docs" }, +); diff --git a/plugins/env.ts b/plugins/env.ts new file mode 100644 index 0000000..642f0af --- /dev/null +++ b/plugins/env.ts @@ -0,0 +1,25 @@ +import env from "@fastify/env"; +import fp from "fastify-plugin"; + +// Конфиг проверяется схемой на старте: приложение с пустым JWT_SECRET не +// поднимется вовсе, вместо того чтобы молча подписывать токены строкой +// "supersecret", как было раньше. +const schema = { + type: "object", + required: ["JWT_SECRET"], + properties: { + JWT_SECRET: { type: "string", minLength: 32 }, + NODE_ENV: { type: "string", default: "development" }, + CORS_ORIGIN: { type: "string", default: "*" }, + // Лимит на IP в минуту. В тестах приложение поднимается десятки раз в одном + // процессе, поэтому значение выносится наружу, а не зашивается в код. + RATE_LIMIT_MAX: { type: "number", default: 100 }, + }, +} as const; + +export default fp( + async (fastify) => { + await fastify.register(env, { schema, dotenv: true }); + }, + { name: "env" }, +); diff --git a/plugins/jwt.ts b/plugins/jwt.ts index ac61dfd..a48226a 100644 --- a/plugins/jwt.ts +++ b/plugins/jwt.ts @@ -2,8 +2,14 @@ import jwtPlugin from "@fastify/jwt"; import type { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; -export default fp(async (fastify: FastifyInstance) => { - fastify.register(jwtPlugin, { - secret: "supersecret", - }); -}); +// dependencies, а не расчёт на алфавитный порядок autoload: секрет берётся из +// проверенного конфига, и если env почему-то не загрузился, падать надо на +// старте, а не на первом подписанном токене. +export default fp( + async (fastify: FastifyInstance) => { + await fastify.register(jwtPlugin, { + secret: fastify.config.JWT_SECRET, + }); + }, + { name: "jwt", dependencies: ["env"] }, +); diff --git a/plugins/security.ts b/plugins/security.ts new file mode 100644 index 0000000..e8aefbe --- /dev/null +++ b/plugins/security.ts @@ -0,0 +1,21 @@ +import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; +import rateLimit from "@fastify/rate-limit"; +import fp from "fastify-plugin"; + +export default fp( + async (fastify) => { + // contentSecurityPolicy выключен: API отдаёт JSON, а страницу + // документации Scalar собирает инлайновыми стилями и скриптом, и дефолтный + // CSP её ломает. + await fastify.register(helmet, { contentSecurityPolicy: false }); + + await fastify.register(cors, { origin: fastify.config.CORS_ORIGIN }); + + await fastify.register(rateLimit, { + max: fastify.config.RATE_LIMIT_MAX, + timeWindow: "1 minute", + }); + }, + { name: "security", dependencies: ["env"] }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef6f82e..e534bac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,30 @@ importers: '@fastify/autoload': specifier: ^6.5.0 version: 6.5.0 + '@fastify/cors': + specifier: ^11.3.0 + version: 11.3.0 + '@fastify/env': + specifier: ^7.0.0 + version: 7.0.0 + '@fastify/helmet': + specifier: ^13.1.1 + version: 13.1.1 '@fastify/jwt': specifier: ^10.2.2 version: 10.2.2 + '@fastify/rate-limit': + specifier: ^11.2.0 + version: 11.2.0 '@fastify/response-validation': specifier: ^3.0.4 version: 3.0.4 '@fastify/sensible': specifier: ^6.0.5 version: 6.0.5 + '@scalar/fastify-api-reference': + specifier: ^1.66.1 + version: 1.66.1 '@typespec/openapi': specifier: ^1.15.0 version: 1.15.0(@typespec/compiler@1.15.0(@types/node@26.2.0))(@typespec/http@1.15.0(@typespec/compiler@1.15.0(@types/node@26.2.0))) @@ -96,6 +111,9 @@ importers: oxlint: specifier: ^1.79.0 version: 1.79.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -582,9 +600,15 @@ packages: '@fastify/autoload@6.5.0': resolution: {integrity: sha512-JkjNcIia6PW1EhHLhAbI4vR6sO++8dW7hBwkcZ/p3ewBTQzrjyPXvQ0ChaUpkd+3/wNqg7W6GLsc0Az+KpW0Qw==} + '@fastify/cors@11.3.0': + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} + '@fastify/deepmerge@3.2.1': resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/env@7.0.0': + resolution: {integrity: sha512-uXoxDzQ9FR/J6XXmcFndAksHM+hAxGXf/v6L4x9iLmK/04Tp/ZceV1oBxsJJo4bNaSu7WHhJqIQReIfz0Ul+tg==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -594,6 +618,9 @@ packages: '@fastify/forwarded@3.0.2': resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + '@fastify/helmet@13.1.1': + resolution: {integrity: sha512-bSat5DTq8geASv8G6P0KW1UbltZ+xGD/zyd9S72pT7ogAHehcsWL85GdjMRCjDsExJvaEvgEZ52qU/2HXirVCw==} + '@fastify/jwt@10.2.2': resolution: {integrity: sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==} @@ -603,6 +630,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.2.0': + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==} + '@fastify/response-validation@3.0.4': resolution: {integrity: sha512-e5J7PgYg9QsFbkT1UGmuqz7vvSuLrFuXm11K+E5Fg+H1u3HWBcPFOAEjf20g2qdZVTj2U4RS15fT0WXCVzFbVA==} @@ -1142,6 +1172,14 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scalar/client-side-rendering@0.3.9': + resolution: {integrity: sha512-Gg+VLhreiWHmHN0i9uatEoIxzsr0FaJoq0x+PkypmFgZFniD477DVCAnmWqx+KXlpFg0VtDanDHARIJXZ1fIeQ==} + engines: {node: '>=22'} + + '@scalar/fastify-api-reference@1.66.1': + resolution: {integrity: sha512-QSQAPMrR4SEWn8ezJvXrGRoamYy+PEu8WVujveLl1S4H4dAa1aBlLP9MvivnFUOGAy7eQR7/ElaUrLHhXdZnzQ==} + engines: {node: '>=22'} + '@scalar/helpers@0.10.0': resolution: {integrity: sha512-IAfnpIZnXY6ni+zyFMwWHBa5b/7yr4mCSvoTGR84oS9q4Quy2wjgafnr7CyfL65ney3iilBZHigZYLYqdqCu3Q==} engines: {node: '>=22'} @@ -1170,6 +1208,18 @@ packages: resolution: {integrity: sha512-yqROK9U96ElasEL4Wl/+PIjQZlqrQXVUUpXk9PA6xBnrp8KdEv7at8pR5gsZkvddJfYVwLILPlctyqUg4u4YZA==} engines: {node: '>=22'} + '@scalar/schemas@0.8.3': + resolution: {integrity: sha512-cTjgiJxXFXMqXlFZafXOyTOKm1lUfbDTbe9La+dPcQPe6q0zXAiVtys0IKILQWNrjXPidY0eaB9Va/rd16bfxw==} + engines: {node: '>=22'} + + '@scalar/types@0.18.2': + resolution: {integrity: sha512-q7fGMn0IygdLbYk9W4quM0w1caHDfL9FIxsYXNsgIep6uuO0T/t4BOStyf0qyzQ3pjt0B7rWFxO//EM9I7F/Tw==} + engines: {node: '>=22'} + + '@scalar/validation@0.6.3': + resolution: {integrity: sha512-j3s9XPv8Wo1EG5/naBi4XGF0uXYQ2A3QZNI0NKWgCMxRHVrDJGlZEYymcHDHY7fquRm73P8U3c8xqJqB5yad6w==} + engines: {node: '>=20'} + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1887,6 +1937,9 @@ packages: resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} engines: {node: '>=20'} + env-schema@7.0.0: + resolution: {integrity: sha512-b8rdAwdFpekDxNAUNuT6KbV/QEX/pTBs0gjehEPmBUUteh9zBDm9zxFSQt55D29odo3rJt4feoWRqn4U/tzicw==} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2007,6 +2060,9 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -2115,6 +2171,9 @@ packages: resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2149,6 +2208,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -2183,6 +2246,10 @@ packages: peerDependencies: fp-ts: ^2.5.0 + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + ipaddr.js@2.5.0: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} @@ -2504,6 +2571,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -2946,6 +3018,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tar@7.5.22: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} @@ -3012,6 +3088,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3507,8 +3587,18 @@ snapshots: '@fastify/autoload@6.5.0': {} + '@fastify/cors@11.3.0': + dependencies: + fastify-plugin: 6.0.0 + toad-cache: 3.7.4 + '@fastify/deepmerge@3.2.1': {} + '@fastify/env@7.0.0': + dependencies: + env-schema: 7.0.0 + fastify-plugin: 5.1.0 + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.1.0': @@ -3517,6 +3607,11 @@ snapshots: '@fastify/forwarded@3.0.2': {} + '@fastify/helmet@13.1.1': + dependencies: + fastify-plugin: 6.0.0 + helmet: 8.3.0 + '@fastify/jwt@10.2.2': dependencies: '@fastify/error': 4.2.0 @@ -3534,6 +3629,13 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/rate-limit@11.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + ip-address: 10.5.0 + toad-cache: 3.7.4 + '@fastify/response-validation@3.0.4': dependencies: '@fastify/error': 4.2.0 @@ -3899,6 +4001,20 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scalar/client-side-rendering@0.3.9': + dependencies: + '@scalar/schemas': 0.8.3 + '@scalar/types': 0.18.2 + '@scalar/validation': 0.6.3 + + '@scalar/fastify-api-reference@1.66.1': + dependencies: + '@scalar/client-side-rendering': 0.3.9 + '@scalar/openapi-parser': 0.28.16 + '@scalar/openapi-types': 0.9.5 + fastify-plugin: 4.5.1 + github-slugger: 2.0.0 + '@scalar/helpers@0.10.0': {} '@scalar/helpers@0.11.1': {} @@ -3934,6 +4050,20 @@ snapshots: dependencies: '@scalar/openapi-types': 0.9.5 + '@scalar/schemas@0.8.3': + dependencies: + '@scalar/helpers': 0.11.1 + '@scalar/validation': 0.6.3 + + '@scalar/types@0.18.2': + dependencies: + '@scalar/helpers': 0.11.1 + nanoid: 5.1.16 + type-fest: 5.8.0 + zod: 4.4.3 + + '@scalar/validation@0.6.3': {} + '@scarf/scarf@1.4.0': {} '@seriousme/openapi-schema-validator@2.9.1': @@ -4595,6 +4725,10 @@ snapshots: dependencies: is-safe-filename: 0.1.1 + env-schema@7.0.0: + dependencies: + ajv: 8.20.0 + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4795,6 +4929,8 @@ snapshots: fastify-plugin: 6.0.0 yaml: 2.9.0 + fastify-plugin@4.5.1: {} + fastify-plugin@5.1.0: {} fastify-plugin@6.0.0: {} @@ -4915,6 +5051,8 @@ snapshots: giget@3.3.1: {} + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4937,6 +5075,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.3.0: {} + help-me@5.0.0: {} http-errors@2.0.1: @@ -4981,6 +5121,8 @@ snapshots: dependencies: fp-ts: 2.16.11 + ip-address@10.5.0: {} + ipaddr.js@2.5.0: {} is-arrayish@0.2.1: {} @@ -5230,6 +5372,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@5.1.16: {} + negotiator@0.6.3: {} node-addon-api@8.9.2: {} @@ -5687,6 +5831,8 @@ snapshots: dependencies: has-flag: 4.0.0 + tagged-tag@1.0.0: {} + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -5743,6 +5889,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 diff --git a/test/plugins/config.test.ts b/test/plugins/config.test.ts new file mode 100644 index 0000000..b19f095 --- /dev/null +++ b/test/plugins/config.test.ts @@ -0,0 +1,52 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build } from "../helper.ts"; + +// Секрет раньше был зашит в plugins/jwt.ts строкой "supersecret". Теперь он +// приходит из проверенного схемой конфига, и смысл проверки в том, что +// приложение с плохим секретом не поднимается вовсе. +test("the app refuses to boot without a JWT secret", async () => { + const original = process.env.JWT_SECRET; + delete process.env.JWT_SECRET; + + try { + await assert.rejects(build(), /JWT_SECRET/); + } finally { + process.env.JWT_SECRET = original; + } +}); + +test("the app refuses to boot with a too-short JWT secret", async () => { + const original = process.env.JWT_SECRET; + process.env.JWT_SECRET = "short"; + + try { + await assert.rejects(build(), /JWT_SECRET/); + } finally { + process.env.JWT_SECRET = original; + } +}); + +test("the openapi document and reference page are served", async () => { + const app = await build(); + + const document = await app.inject({ url: "/openapi.json" }); + assert.equal(document.statusCode, 200, document.body); + assert.ok(JSON.parse(document.body).openapi.startsWith("3.")); + + // Scalar отдаёт страницу на /docs/ и уводит на неё редиректом с /docs. + const redirect = await app.inject({ url: "/docs" }); + assert.equal(redirect.statusCode, 301); + + const page = await app.inject({ url: redirect.headers.location as string }); + assert.equal(page.statusCode, 200, page.body.slice(0, 200)); + assert.match(page.body, /openapi\.json/); +}); + +test("responses carry the security headers helmet adds", async () => { + const app = await build(); + + const res = await app.inject({ url: "/courses" }); + assert.equal(res.headers["x-content-type-options"], "nosniff"); + assert.ok(res.headers["x-frame-options"], "x-frame-options is missing"); +}); diff --git a/types/fastify.d.ts b/types/fastify.d.ts index 7290b67..3f9f5fe 100644 --- a/types/fastify.d.ts +++ b/types/fastify.d.ts @@ -19,6 +19,12 @@ declare module "fastify" { } interface FastifyInstance extends FastifyJwtNamespace<{ namespace: "security" }> { db: ReturnType>; + config: { + JWT_SECRET: string; + NODE_ENV: string; + CORS_ORIGIN: string; + RATE_LIMIT_MAX: number; + }; } // type FastifyTypebox = FastifyInstance< // RawServerDefault, diff --git a/vitest.config.ts b/vitest.config.ts index 1773adf..cb2c94a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,12 @@ export default defineConfig({ // build(). Стоимость лежит внутри дайджеста, так что проверка от этого // не ломается. SCRYPT_COST: "1024", + // Схема в plugins/env.ts требует секрет, и это правильно: приложение не + // должно подниматься с пустым. Тестам он нужен любой. + JWT_SECRET: "test-secret-not-used-anywhere-else-0123456789", + // Лимитер живёт в памяти каждого поднятого приложения, но потолок лучше + // задрать: иначе тест, который шлёт много запросов, начнёт ловить 429. + RATE_LIMIT_MAX: "100000", }, }, }); From 8928069daee2eefa18819017f3ec03609cdd1169 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:46:13 -0400 Subject: [PATCH 08/14] =?UTF-8?q?ci:=20=D0=B2=D1=8B=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D1=8C=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D1=8B?= =?UTF-8?q?,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please — вторая половина того, что уже было настроено: pr-title.yml требовал conventional commit в заголовке PR именно затем, чтобы по нему определялся разряд версии, но выпускать было нечему. make migration-check ловит изменённую схему без миграции: перегенерирует и падает, если что-то появилось. drizzle-kit check вызывается флагами, а не через конфиг — читая drizzle.config.ts, он принимает dialect за параметр AWS Data API и падает. Покрытие с порогами 95/95/80/95 при фактических 100/100/85/97. Тесты собирают приложение напрямую вместо helper из fastify-cli. Тот грузил app.ts в обход трансформации vite, из-за чего app в тестах был any, а покрытие показывало 28% по tokens.ts при четырёх прицельных тестах на него. Реальные цифры видны только теперь. concurrency отменяет прошлый прогон ветки: пуш в ветку с открытым PR запускал сборку дважды. На main не отменяет. deps-update вызывает ncu через pnpm exec: пакет в devDependencies, а npx при его отсутствии тянул бы из сети другую версию. --- .github/workflows/nodeci.yml | 14 ++- .github/workflows/pr-title.yml | 4 +- .github/workflows/release-please.yml | 21 ++++ Makefile | 26 ++++- package.json | 1 + pnpm-lock.yaml | 156 +++++++++++++++++++++++++-- test/helper.ts | 58 +++------- vitest.config.ts | 14 +++ 8 files changed, 234 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/release-please.yml diff --git a/.github/workflows/nodeci.yml b/.github/workflows/nodeci.yml index b770738..65f819d 100644 --- a/.github/workflows/nodeci.yml +++ b/.github/workflows/nodeci.yml @@ -1,7 +1,5 @@ name: Node CI -# На каждое событие ровно один workflow: здесь нет release-please, поэтому -# main проверяет Node CI. on: push: branches: @@ -10,6 +8,12 @@ on: branches: - main +# Пуш в ветку с открытым PR запускает сборку дважды. Прошлый прогон отменяется, +# кроме main: там история сборок нужна целиком. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: build: runs-on: ubuntu-latest @@ -45,5 +49,9 @@ jobs: # руками. - name: Check generated code run: make generate-check + # Схема без миграции — молчаливая поломка: код ждёт колонку, которой в + # базе не появится. + - name: Check migrations + run: make migration-check - name: Run tests - run: make test + run: make test-coverage diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index e7de3c9..c2bc054 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -1,8 +1,8 @@ name: PR Title # Заголовок PR обязан быть conventional commit: при squash-мерже он становится -# сообщением коммита, а по нему release-please определяет разряд версии. -# Отдельные коммиты ветки намеренно не проверяются. +# сообщением коммита, а по нему release-please (см. release-please.yml) +# определяет разряд версии. Отдельные коммиты ветки намеренно не проверяются. on: pull_request_target: types: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..8d4b22c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: Release Please + +# Вторая половина того, что уже настроено: pr-title.yml требует conventional +# commit в заголовке PR именно затем, чтобы по нему определялся разряд версии. +# Без этого workflow заголовки проверялись, но ничего не выпускали. +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: node diff --git a/Makefile b/Makefile index 7f17d80..b3a03ae 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,20 @@ test: pnpm test +test-coverage: + pnpm exec vitest run --coverage + dev: pnpm run dev check-types: pnpm exec tsc +# ncu, а не npx: пакет стоит в devDependencies, и npx при его отсутствии молча +# тянул бы из сети другую версию. Обычно обновления приносит dependabot — цель +# нужна, когда хочется обновиться сразу и локально. deps-update: - npx ncu -u + pnpm exec ncu -u # Таблица маршрутов целиком: их регистрирует glue по спеке, отдельного файла # с маршрутами нет — печатать нужно приложение. @@ -18,6 +24,22 @@ routes: migration-generate: pnpm exec drizzle-kit generate +# Схема без миграции — молчаливая поломка: код ждёт колонку, которой в базе не +# появится. Цель ловит это, перегенерировав и проверив, что ничего нового не +# возникло. В generate-check миграции не входят намеренно: там речь про +# сгенерированное из спеки, а миграцию автор создаёт осознанно. +# +# check вызывается флагами, а не через конфиг: читая drizzle.config.ts, он +# принимает dialect за параметр AWS Data API и падает (drizzle-kit 0.31). +migration-check: + pnpm exec drizzle-kit check --dialect sqlite --out ./drizzle + pnpm exec drizzle-kit generate + @test -z "$$(git status --porcelain drizzle)" || { \ + echo "Схема изменилась без миграции — запустите make migration-generate:"; \ + git status --porcelain drizzle; \ + exit 1; \ + } + lint: pnpm --silent run lint pnpm exec tsc @@ -54,4 +76,4 @@ install: .PHONY: install test dev check-types deps-update routes migration-generate \ lint lint-fix generate-openapi generate-openapi-ts-types generate-types \ - generate-check mock + generate-check migration-check mock test-coverage diff --git a/package.json b/package.json index 17cb04c..7c40724 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@typespec/openapi3": "^1.15.0", "@typespec/rest": "^0.85.0", "@typespec/versioning": "^0.85.0", + "@vitest/coverage-v8": "^4.1.11", "drizzle-kit": "^0.31.10", "globals": "^17.11.0", "npm-check-updates": "^23.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e534bac..9b40c13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ importers: version: 10.6.0 '@hey-api/openapi-ts': specifier: 0.0.0-next-20260819153534 - version: 0.0.0-next-20260819153534 + version: 0.0.0-next-20260819153534(magicast@0.5.4) '@stoplight/prism-cli': specifier: ^5.16.0 version: 5.16.0(supports-color@7.2.0) @@ -96,6 +96,9 @@ importers: '@typespec/versioning': specifier: ^0.85.0 version: 0.85.0(@typespec/compiler@1.15.0(@types/node@26.2.0)) + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) drizzle-kit: specifier: ^0.31.10 version: 0.31.10 @@ -119,7 +122,7 @@ importers: version: 7.0.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -127,10 +130,27 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -804,9 +824,16 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} @@ -1517,6 +1544,15 @@ packages: peerDependencies: '@typespec/compiler': ^1.15.0 + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -1624,6 +1660,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2215,6 +2254,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2315,6 +2357,18 @@ packages: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2323,6 +2377,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2493,6 +2550,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -3339,8 +3403,21 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -3656,10 +3733,10 @@ snapshots: dependencies: citty: 0.2.2 - '@hey-api/codegen-core@0.0.0-next-20260819153534': + '@hey-api/codegen-core@0.0.0-next-20260819153534(magicast@0.5.4)': dependencies: '@hey-api/types': 0.1.4 - c12: 3.3.4 + c12: 3.3.4(magicast@0.5.4) transitivePeerDependencies: - magicast @@ -3669,12 +3746,12 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 5.2.0 - '@hey-api/openapi-ts@0.0.0-next-20260819153534': + '@hey-api/openapi-ts@0.0.0-next-20260819153534(magicast@0.5.4)': dependencies: '@hey-api/codegen-cli': 0.0.0-next-20260819153534 - '@hey-api/codegen-core': 0.0.0-next-20260819153534 + '@hey-api/codegen-core': 0.0.0-next-20260819153534(magicast@0.5.4) '@hey-api/json-schema-ref-parser': 0.0.0-next-20260819153534 - '@hey-api/shared': 0.0.0-next-20260819153534 + '@hey-api/shared': 0.0.0-next-20260819153534(magicast@0.5.4) '@hey-api/spec-types': 0.0.0-next-20260819153534 '@hey-api/types': 0.1.4 '@lukeed/ms': 2.0.2 @@ -3682,9 +3759,9 @@ snapshots: transitivePeerDependencies: - magicast - '@hey-api/shared@0.0.0-next-20260819153534': + '@hey-api/shared@0.0.0-next-20260819153534(magicast@0.5.4)': dependencies: - '@hey-api/codegen-core': 0.0.0-next-20260819153534 + '@hey-api/codegen-core': 0.0.0-next-20260819153534(magicast@0.5.4) '@hey-api/json-schema-ref-parser': 0.0.0-next-20260819153534 '@hey-api/spec-types': 0.0.0-next-20260819153534 '@hey-api/types': 0.1.4 @@ -3823,8 +3900,15 @@ snapshots: dependencies: minipass: 7.1.3 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': @@ -4401,6 +4485,20 @@ snapshots: dependencies: '@typespec/compiler': 1.15.0(@types/node@26.2.0) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -4506,6 +4604,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomic-sleep@1.0.0: {} avvio@9.3.0: @@ -4533,7 +4637,7 @@ snapshots: dependencies: run-applescript: 7.1.0 - c12@3.3.4: + c12@3.3.4(magicast@0.5.4): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -4547,6 +4651,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.4 call-bind-apply-helpers@1.0.2: dependencies: @@ -5079,6 +5185,8 @@ snapshots: help-me@5.0.0: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5163,10 +5271,25 @@ snapshots: isbinaryfile@4.0.10: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.15.1: @@ -5314,6 +5437,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -5978,7 +6111,7 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -6002,6 +6135,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.2.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw diff --git a/test/helper.ts b/test/helper.ts index 7843a21..45c8d73 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,51 +1,25 @@ import { onTestFinished } from "vitest"; import assert from "node:assert"; -import helper from "fastify-cli/helper.js"; -import path from "path"; -import * as schemas from "../db/schema.ts"; import { eq } from "drizzle-orm"; -import type { FastifyInstance } from "fastify"; - -const AppPath = path.join(import.meta.dirname, "..", "app.ts"); - -// Fill in this config with all the configurations -// needed for testing the application -function config() { - return { - skipOverride: true, - }; -} - -function serverConfig() { - return { - logger: { - level: "error", - transport: { - target: "pino-pretty", - options: { - colorize: true, - }, - }, - }, - }; -} +import Fastify, { type FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import app from "../app.ts"; +import * as schemas from "../db/schema.ts"; -// Тип возвращаемого значения проставлен руками: helper из fastify-cli — это -// нетипизированный JS, и без аннотации весь app в тестах становится any, а -// вместе с ним и всё, что из него читают. +// Приложение собирается напрямую, а не через helper из fastify-cli. Тот грузит +// app.ts сам, в обход трансформации vite: из-за этого весь app в тестах был +// any, а покрытие показывало по обработчикам единицы процентов при живых +// тестах на них. async function build(): Promise { - // you can set all the options supported by the fastify CLI command - const argv = [AppPath]; - - // fastify-plugin ensures that all decorators - // are exposed for testing purposes, this is - // different from the production setup - const app = await helper.build(argv, config(), serverConfig()); + const fastify = Fastify({ logger: { level: "error" } }); + // fp снимает инкапсуляцию, и декораторы приложения (db, jwt) видны снаружи. + // В бою так не нужно — это только чтобы тесты могли дотянуться до базы. + fastify.register(fp(app)); + await fastify.ready(); - // tear down our app after we are done - onTestFinished(() => app.close()); + onTestFinished(() => fastify.close()); - return app; + return fastify; } async function getAuthHeader(app: FastifyInstance, userId: number | null = null) { @@ -58,4 +32,4 @@ async function getAuthHeader(app: FastifyInstance, userId: number | null = null) }; } -export { config, build, getAuthHeader }; +export { build, getAuthHeader }; diff --git a/vitest.config.ts b/vitest.config.ts index cb2c94a..d086492 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,21 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + // Дефолтные 5 секунд малы: build() поднимает приложение целиком, с + // миграциями и сидами, и на загруженном раннере это не укладывается. + testTimeout: 30_000, include: ["test/**/*.test.{ts,js}"], + coverage: { + provider: "v8", + // Сгенерированное и конфиги считать незачем: покрытие должно говорить о + // коде, который писали руками. + // plugins/ не считаем: это обвязка из нескольких строк register(), и + // порог по ней говорил бы не о тестах, а о числе строк в обвязке. + include: ["routes/**", "lib/**", "validators/**", "rules/**", "policies/**"], + // Пороги чуть ниже фактических (100/85/100/97): гейт нужен как храповик + // против регресса, а не как повод подгонять цифры. + thresholds: { lines: 95, functions: 95, branches: 80, statements: 95 }, + }, env: { // Боевая цена scrypt — ~230 мс на хеш, а сиды прогоняются на каждый // build(). Стоимость лежит внутри дайджеста, так что проверка от этого From 670cb486e3065688c57c9ab0ecb200f440d493cd Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 20:52:27 -0400 Subject: [PATCH 09/14] =?UTF-8?q?ci:=20=D0=BB=D0=B8=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=BF=D0=B5=D0=BA=D1=83,=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BC=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B8=20=D0=B3=D0=BE=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redocly lint встроен в make lint. Он нашёл настоящий разрыв: пять защищённых операций (все users и coursesCreate) могут ответить 401, но в контракте этого не было — фикс авторизации сделал разрыв наблюдаемым. Добавлены UnauthorizedError, summary у всех четырнадцати операций, сервер и лицензия. Осталось 0 ошибок против 21. Отключения правил лежат поимённо в redocly.yaml с причинами, а не в ignore-файле: тот замораживает конкретные находки, и новые такие же проходят молча. security-defined снят — публичные эндпоинты здесь осознанный дизайн. oasdiff показывает ломающие правки контракта в PR, но сборку не роняет: v1 нигде не опубликован, и жёсткий гейт обещал бы совместимость, которой репозиторий не даёт. lefthook гоняет oxfmt и oxlint по staged-файлам до коммита и tsc перед пушем. Отдельный nano-staged не нужен — у lefthook есть {staged_files}. --- .github/workflows/openapi-diff.yml | 34 ++++++ Makefile | 8 +- lefthook.yml | 24 ++++ main.tsp | 31 ++++- package.json | 2 + pnpm-lock.yaml | 110 ++++++++++++++++++ pnpm-workspace.yaml | 1 + redocly.yaml | 22 ++++ tsp-output/@typespec/openapi3/openapi.v1.json | 77 +++++++++++- tsp-output/@typespec/openapi3/openapi.v2.json | 77 +++++++++++- types/handlers/fastify.gen.ts | 3 +- types/handlers/index.ts | 2 + types/handlers/types.gen.ts | 27 ++++- 13 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/openapi-diff.yml create mode 100644 lefthook.yml create mode 100644 redocly.yaml diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml new file mode 100644 index 0000000..0a81871 --- /dev/null +++ b/.github/workflows/openapi-diff.yml @@ -0,0 +1,34 @@ +name: OpenAPI Diff + +# Информационный шаг: показывает в логе PR, что изменилось в контракте и есть +# ли ломающие правки. Сборку намеренно не роняет — v1 нигде не опубликован, и +# жёсткий гейт обещал бы совместимость, которой репозиторий не даёт. Когда +# спека станет публичной, достаточно убрать continue-on-error. +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Extract the base version of the spec + run: | + git show "origin/${{ github.base_ref }}:tsp-output/@typespec/openapi3/openapi.v1.json" \ + > /tmp/openapi.base.json 2>/dev/null \ + || cp tsp-output/@typespec/openapi3/openapi.v1.json /tmp/openapi.base.json + + - name: Report breaking changes + continue-on-error: true + uses: oasdiff/oasdiff-action/breaking@main + with: + base: /tmp/openapi.base.json + revision: tsp-output/@typespec/openapi3/openapi.v1.json diff --git a/Makefile b/Makefile index b3a03ae..98efba5 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,12 @@ lint: pnpm --silent run lint pnpm exec tsc pnpm --silent run format:check + $(MAKE) lint-openapi + +# Линт контракта: правила и причины отключений — в redocly.yaml. Гоняется по +# сгенерированному, а не по main.tsp: проверять надо то, что видит клиент. +lint-openapi: + pnpm exec redocly lint tsp-output/@typespec/openapi3/openapi.v1.json lint-fix: pnpm --silent run lint:fix @@ -76,4 +82,4 @@ install: .PHONY: install test dev check-types deps-update routes migration-generate \ lint lint-fix generate-openapi generate-openapi-ts-types generate-types \ - generate-check migration-check mock test-coverage + generate-check migration-check mock test-coverage lint-openapi diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..0c92d09 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,24 @@ +# Формат и линт до коммита, а не только в CI: make lint падал на них уже после +# пуша, а generate-types в конце гоняет форматтер по всему дереву, и разъехаться +# легко. + +pre-commit: + parallel: true + jobs: + - name: format + glob: "*.{ts,js,json,md,yml,yaml}" + run: pnpm exec oxfmt --ignore-path=.oxfmtignore {staged_files} + # Отформатированное возвращается в индекс, иначе коммит уедет в старом + # виде и format:check в CI всё равно упадёт. + stage_fixed: true + + - name: lint + glob: "*.{ts,js}" + run: pnpm exec oxlint --config=.oxlintrc.json {staged_files} + +# Проверка типов только перед пушем: на каждый коммит это слишком долго, а +# файлов по отдельности tsc не проверяет. +pre-push: + jobs: + - name: types + run: pnpm exec tsc diff --git a/main.tsp b/main.tsp index 95df363..096eadb 100644 --- a/main.tsp +++ b/main.tsp @@ -9,6 +9,13 @@ using TypeSpec.Http; using TypeSpec.OpenAPI; @service(#{ title: "Rest API Example" }) +@info(#{ + license: #{ + name: "ISC", + url: "https://opensource.org/license/isc-license-txt", + }, +}) +@server("http://localhost:3000", "Локальная разработка") @versioned(Versions) namespace FastifyRestApiExample; @@ -138,6 +145,7 @@ model CourseLessonCreateDTO { @route("/users") namespace users { + @summary("Список пользователей") @operationId("usersIndex") @get @useAuth(BearerAuth) @@ -145,15 +153,17 @@ namespace users { @body users: { data: User[]; }; - }; + } | UnauthorizedError; + @summary("Пользователь по идентификатору") @get @useAuth(BearerAuth) @operationId("usersShow") op show(@path id: numeric): { @body _: User; - } | NotFoundError; + } | NotFoundError | UnauthorizedError; + @summary("Регистрация пользователя") @post @operationId("usersCreate") op create(@body _: UserCreateDTO): { @@ -161,23 +171,26 @@ namespace users { @statusCode statusCode: 201; } | UnprocessableEntityError; + @summary("Изменение пользователя") @put @useAuth(BearerAuth) @operationId("usersUpdate") op update(@path id: numeric, @body user: UserEditDTO): { @body user: User; - } | NotFoundError | UnprocessableEntityError; + } | NotFoundError | UnprocessableEntityError | UnauthorizedError; + @summary("Удаление пользователя") @delete @useAuth(BearerAuth) @operationId("usersDestroy") op destroy(@path id: numeric): { @statusCode statusCode: 204; - } | NotFoundError; + } | NotFoundError | UnauthorizedError; } @route("/tokens") namespace tokens { + @summary("Выдача токена по email и паролю") @post @operationId("tokensCreate") op create(@body auth_info: AuthInfo): { @@ -188,6 +201,7 @@ namespace tokens { @route("/courses") namespace courses { + @summary("Список курсов") @get @operationId("coursesIndex") op index(@query page?: numeric = 1): { @@ -196,20 +210,23 @@ namespace courses { }; }; + @summary("Курс по идентификатору") @get @operationId("coursesShow") op show(@path id: numeric): { @body course: Course; } | NotFoundError; + @summary("Создание курса") @post @useAuth(BearerAuth) @operationId("coursesCreate") op create(@body course: CourseCreateDTO): { @body course: Course; @statusCode statusCode: 201; - } | UnprocessableEntityError; + } | UnprocessableEntityError | UnauthorizedError; + @summary("Изменение курса") @put @useAuth(BearerAuth) @operationId("coursesUpdate") @@ -222,6 +239,7 @@ namespace courses { | UnauthorizedError | ForbiddenError; + @summary("Удаление курса") @delete @useAuth(BearerAuth) @operationId("coursesDestroy") @@ -232,6 +250,7 @@ namespace courses { @route("/courses/{courseId}/lessons") namespace courses_lessons { + @summary("Список уроков курса") @get @operationId("coursesLessonsIndex") op index(@path courseId: numeric, @query page?: numeric = 1): { @@ -240,12 +259,14 @@ namespace courses_lessons { }; }; + @summary("Урок курса") @get @operationId("coursesLessonsShow") op show(@path courseId: numeric, @path id: numeric): { @body lessons: CourseLesson; } | NotFoundError; + @summary("Добавление урока в курс") @post @useAuth(BearerAuth) @operationId("coursesLessonsCreate") diff --git a/package.json b/package.json index 7c40724..b9673e9 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@faker-js/faker": "^10.6.0", "@hey-api/openapi-ts": "0.0.0-next-20260819153534", + "@redocly/cli": "^2.47.0", "@stoplight/prism-cli": "^5.16.0", "@types/better-sqlite3": "^9.6.0", "@typespec/compiler": "^1.15.0", @@ -45,6 +46,7 @@ "@vitest/coverage-v8": "^4.1.11", "drizzle-kit": "^0.31.10", "globals": "^17.11.0", + "lefthook": "^2.1.10", "npm-check-updates": "^23.0.2", "oxfmt": "^0.64.0", "oxlint": "^1.79.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b40c13..0ab1a0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@hey-api/openapi-ts': specifier: 0.0.0-next-20260819153534 version: 0.0.0-next-20260819153534(magicast@0.5.4) + '@redocly/cli': + specifier: ^2.47.0 + version: 2.47.0 '@stoplight/prism-cli': specifier: ^5.16.0 version: 5.16.0(supports-color@7.2.0) @@ -105,6 +108,9 @@ importers: globals: specifier: ^17.11.0 version: 17.11.0 + lefthook: + specifier: ^2.1.10 + version: 2.1.10 npm-check-updates: specifier: ^23.0.2 version: 23.0.2 @@ -1106,6 +1112,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@redocly/cli@2.47.0': + resolution: {integrity: sha512-4sv638LZxiUSsNGqfWPzA2hrNM4KrNRc9jOaH9igqDJlZimiGCYliVpbdrWvfL/YlaSnIBpJE1O1CveXDYTTWw==} + engines: {node: '>=22.12.0 || >=20.19.0 <21.0.0', npm: '>=10'} + hasBin: true + '@rolldown/binding-android-arm64@1.2.3': resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2441,6 +2452,60 @@ packages: resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==} hasBin: true + lefthook-darwin-arm64@2.1.10: + resolution: {integrity: sha512-nw+X8wRNDoUUV6WSteyKBbcLySq+fsmZt5WV/s50ZJpysmsDKJOUMln6SllNfP+60dzUahAO7REco/2633BsLg==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.10: + resolution: {integrity: sha512-KQ/bHmvpkFdHMn4pZnUdTf+GuSC+aBBgBTxZT4GW+6cSf+qbErKZBhK7cH6BmILsvx43+VzEArvHYY7YOfRFOQ==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.10: + resolution: {integrity: sha512-8su6DwydP7+pv7kG0zCtjphqsw4ouOnfexRUErapy5GTxYBoUOhYz3RSHTSWNRsK6W4jva7FPUh2Lp5/PSn30w==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.10: + resolution: {integrity: sha512-GeAJEFxko3Lk+AsnS3NleAFrpyMLFUKOlgJvPKuU0xHwVEI/z+ZoCcmuO0BX+4CS0NLbZhC/YQAvBASqDvvVdQ==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.10: + resolution: {integrity: sha512-1sHTCmpTWjVMs+yKPBLRNT1kuuIr1yjietlk7rCB6wFPVOS6Ph3o2zPFH2AvW1UymHlqwyHXzBr9EtDpQ7j1mQ==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.10: + resolution: {integrity: sha512-z/VlRB3bh6mBvW3r1rwnJ5vP8z+Krx5gJzkZ4veDXh+6FlRTx8wtd3g3fllOv/yZMxkgmL3fQoFXv05Esa7vBQ==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.10: + resolution: {integrity: sha512-430zL8sSIKw5P0YXGG6PB+eAhHa06n0PXuaERaAQE4Ss3odfqwnl5Mq9hQmkEnOS1EGiQEKkd0UHv/i4PtMNIQ==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.10: + resolution: {integrity: sha512-bgkO8PphGZVDhQgCJ524aYYPI5491pVmCiLPGjBIo1AvOSlIyw4N1Y+1C3QfqwEmechzw+Aq16SNc8pqv6UuXg==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.10: + resolution: {integrity: sha512-5Q6etF0Fla2DDA4ilDySrdNgiR5+W7cJZwnZ69Je3kvWCaWm4wnkuc8FEdjp3kiL2x3ZXipdI00f5vpO8aWmog==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.10: + resolution: {integrity: sha512-c/XH8YZtylG4XaxzqFfXluvq2LXq2W/p54Bnzn3+Z7E5X2Fk3JlFJAibulMbIt2+w8T7UI/r97ok5GqE4kGaeA==} + cpu: [x64] + os: [win32] + + lefthook@2.1.10: + resolution: {integrity: sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w==} + hasBin: true + leven@4.1.0: resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4041,6 +4106,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@redocly/cli@2.47.0': {} + '@rolldown/binding-android-arm64@1.2.3': optional: true @@ -5351,6 +5418,49 @@ snapshots: jsonrepair@3.15.0: {} + lefthook-darwin-arm64@2.1.10: + optional: true + + lefthook-darwin-x64@2.1.10: + optional: true + + lefthook-freebsd-arm64@2.1.10: + optional: true + + lefthook-freebsd-x64@2.1.10: + optional: true + + lefthook-linux-arm64@2.1.10: + optional: true + + lefthook-linux-x64@2.1.10: + optional: true + + lefthook-openbsd-arm64@2.1.10: + optional: true + + lefthook-openbsd-x64@2.1.10: + optional: true + + lefthook-windows-arm64@2.1.10: + optional: true + + lefthook-windows-x64@2.1.10: + optional: true + + lefthook@2.1.10: + optionalDependencies: + lefthook-darwin-arm64: 2.1.10 + lefthook-darwin-x64: 2.1.10 + lefthook-freebsd-arm64: 2.1.10 + lefthook-freebsd-x64: 2.1.10 + lefthook-linux-arm64: 2.1.10 + lefthook-linux-x64: 2.1.10 + lefthook-openbsd-arm64: 2.1.10 + lefthook-openbsd-x64: 2.1.10 + lefthook-windows-arm64: 2.1.10 + lefthook-windows-x64: 2.1.10 + leven@4.1.0: {} light-my-request@6.6.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b853199..22b7874 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,5 +6,6 @@ # игнорирует молча. allowBuilds: '@scarf/scarf': true + 'lefthook': true 'better-sqlite3': true 'esbuild': true diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..c9205a6 --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,22 @@ +# Линт спеки. Правила отключаются поимённо и с причиной — ignore-файл +# (redocly lint --generate-ignore-file) сюда специально не заводится: он +# замораживает конкретные находки, и новые такие же проходят молча. +rules: + # Требует security у каждой операции. Здесь публичные эндпоинты — + # осознанный дизайн: список курсов, регистрация и выдача токена доступны без + # токена по определению. + security-defined: off + + # Считает неиспользуемыми ProblemDetails, Timestamps и Versions. Их + # порождает TypeSpec: ProblemDetails разворачивается в конкретные модели + # ошибок, Timestamps подмешивается в сущности, Versions описывает версии + # сервиса. В самой спеке чинить нечего. + no-unused-components: off + + # Остаётся у coursesIndex и coursesLessonsIndex: публичные списки, у которых + # нет ошибочного исхода. Уровень warn, чтобы новая операция без 4xx была + # видна, но сборку не роняла. + operation-4xx-response: warn + + # Описание у каждой операции — то, что видно на странице документации. + operation-summary: error diff --git a/tsp-output/@typespec/openapi3/openapi.v1.json b/tsp-output/@typespec/openapi3/openapi.v1.json index 7eabdaa..050aa94 100644 --- a/tsp-output/@typespec/openapi3/openapi.v1.json +++ b/tsp-output/@typespec/openapi3/openapi.v1.json @@ -2,6 +2,10 @@ "openapi": "3.0.0", "info": { "title": "Rest API Example", + "license": { + "name": "ISC", + "url": "https://opensource.org/license/isc-license-txt" + }, "version": "v1" }, "tags": [], @@ -9,6 +13,7 @@ "/courses": { "get": { "operationId": "coursesIndex", + "summary": "Список курсов", "parameters": [ { "name": "page", @@ -47,6 +52,7 @@ }, "post": { "operationId": "coursesCreate", + "summary": "Создание курса", "parameters": [], "responses": { "201": { @@ -59,6 +65,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -90,6 +106,7 @@ "/courses/{courseId}/lessons": { "get": { "operationId": "coursesLessonsIndex", + "summary": "Список уроков курса", "parameters": [ { "name": "courseId", @@ -136,6 +153,7 @@ }, "post": { "operationId": "coursesLessonsCreate", + "summary": "Добавление урока в курс", "parameters": [ { "name": "courseId", @@ -218,6 +236,7 @@ "/courses/{courseId}/lessons/{id}": { "get": { "operationId": "coursesLessonsShow", + "summary": "Урок курса", "parameters": [ { "name": "courseId", @@ -263,6 +282,7 @@ "/courses/{id}": { "get": { "operationId": "coursesShow", + "summary": "Курс по идентификатору", "parameters": [ { "name": "id", @@ -298,6 +318,7 @@ }, "put": { "operationId": "coursesUpdate", + "summary": "Изменение курса", "parameters": [ { "name": "id", @@ -378,6 +399,7 @@ }, "delete": { "operationId": "coursesDestroy", + "summary": "Удаление курса", "parameters": [ { "name": "id", @@ -433,6 +455,7 @@ "/tokens": { "post": { "operationId": "tokensCreate", + "summary": "Выдача токена по email и паролю", "parameters": [], "responses": { "201": { @@ -481,6 +504,7 @@ "/users": { "get": { "operationId": "usersIndex", + "summary": "Список пользователей", "parameters": [ { "name": "page", @@ -514,6 +538,16 @@ } } } + }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } } }, "security": [ @@ -524,6 +558,7 @@ }, "post": { "operationId": "usersCreate", + "summary": "Регистрация пользователя", "parameters": [], "responses": { "201": { @@ -562,6 +597,7 @@ "/users/{id}": { "get": { "operationId": "usersShow", + "summary": "Пользователь по идентификатору", "parameters": [ { "name": "id", @@ -583,6 +619,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -602,6 +648,7 @@ }, "put": { "operationId": "usersUpdate", + "summary": "Изменение пользователя", "parameters": [ { "name": "id", @@ -623,6 +670,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -662,6 +719,7 @@ }, "delete": { "operationId": "usersDestroy", + "summary": "Удаление пользователя", "parameters": [ { "name": "id", @@ -676,6 +734,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -1005,5 +1073,12 @@ "scheme": "Bearer" } } - } + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Локальная разработка", + "variables": {} + } + ] } diff --git a/tsp-output/@typespec/openapi3/openapi.v2.json b/tsp-output/@typespec/openapi3/openapi.v2.json index 39f0582..1da2673 100644 --- a/tsp-output/@typespec/openapi3/openapi.v2.json +++ b/tsp-output/@typespec/openapi3/openapi.v2.json @@ -2,6 +2,10 @@ "openapi": "3.0.0", "info": { "title": "Rest API Example", + "license": { + "name": "ISC", + "url": "https://opensource.org/license/isc-license-txt" + }, "version": "v2" }, "tags": [], @@ -9,6 +13,7 @@ "/courses": { "get": { "operationId": "coursesIndex", + "summary": "Список курсов", "parameters": [ { "name": "page", @@ -47,6 +52,7 @@ }, "post": { "operationId": "coursesCreate", + "summary": "Создание курса", "parameters": [], "responses": { "201": { @@ -59,6 +65,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -90,6 +106,7 @@ "/courses/{courseId}/lessons": { "get": { "operationId": "coursesLessonsIndex", + "summary": "Список уроков курса", "parameters": [ { "name": "courseId", @@ -136,6 +153,7 @@ }, "post": { "operationId": "coursesLessonsCreate", + "summary": "Добавление урока в курс", "parameters": [ { "name": "courseId", @@ -218,6 +236,7 @@ "/courses/{courseId}/lessons/{id}": { "get": { "operationId": "coursesLessonsShow", + "summary": "Урок курса", "parameters": [ { "name": "courseId", @@ -263,6 +282,7 @@ "/courses/{id}": { "get": { "operationId": "coursesShow", + "summary": "Курс по идентификатору", "parameters": [ { "name": "id", @@ -298,6 +318,7 @@ }, "put": { "operationId": "coursesUpdate", + "summary": "Изменение курса", "parameters": [ { "name": "id", @@ -378,6 +399,7 @@ }, "delete": { "operationId": "coursesDestroy", + "summary": "Удаление курса", "parameters": [ { "name": "id", @@ -433,6 +455,7 @@ "/tokens": { "post": { "operationId": "tokensCreate", + "summary": "Выдача токена по email и паролю", "parameters": [], "responses": { "201": { @@ -481,6 +504,7 @@ "/users": { "get": { "operationId": "usersIndex", + "summary": "Список пользователей", "parameters": [ { "name": "page", @@ -514,6 +538,16 @@ } } } + }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } } }, "security": [ @@ -524,6 +558,7 @@ }, "post": { "operationId": "usersCreate", + "summary": "Регистрация пользователя", "parameters": [], "responses": { "201": { @@ -562,6 +597,7 @@ "/users/{id}": { "get": { "operationId": "usersShow", + "summary": "Пользователь по идентификатору", "parameters": [ { "name": "id", @@ -583,6 +619,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -602,6 +648,7 @@ }, "put": { "operationId": "usersUpdate", + "summary": "Изменение пользователя", "parameters": [ { "name": "id", @@ -623,6 +670,16 @@ } } }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -662,6 +719,7 @@ }, "delete": { "operationId": "usersDestroy", + "summary": "Удаление пользователя", "parameters": [ { "name": "id", @@ -676,6 +734,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "401": { + "description": "Access is unauthorized.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -1009,5 +1077,12 @@ "scheme": "Bearer" } } - } + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Локальная разработка", + "variables": {} + } + ] } diff --git a/types/handlers/fastify.gen.ts b/types/handlers/fastify.gen.ts index 3e19e4b..82653e2 100644 --- a/types/handlers/fastify.gen.ts +++ b/types/handlers/fastify.gen.ts @@ -35,6 +35,7 @@ import type { UsersDestroyErrors, UsersDestroyResponses, UsersIndexData, + UsersIndexErrors, UsersIndexResponses, UsersShowData, UsersShowErrors, @@ -86,7 +87,7 @@ export type RouteHandlers = { }>; usersIndex: RouteHandler<{ Querystring?: UsersIndexData["query"]; - Reply: UsersIndexResponses; + Reply: UsersIndexErrors & UsersIndexResponses; }>; usersCreate: RouteHandler<{ Body: UsersCreateData["body"]; diff --git a/types/handlers/index.ts b/types/handlers/index.ts index 7590eb8..b91a4b5 100644 --- a/types/handlers/index.ts +++ b/types/handlers/index.ts @@ -70,6 +70,8 @@ export type { UsersDestroyResponse, UsersDestroyResponses, UsersIndexData, + UsersIndexError, + UsersIndexErrors, UsersIndexResponse, UsersIndexResponses, UsersShowData, diff --git a/types/handlers/types.gen.ts b/types/handlers/types.gen.ts index 4f5f4c9..eced220 100644 --- a/types/handlers/types.gen.ts +++ b/types/handlers/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); + baseUrl: "http://localhost:3000" | (string & {}); }; export type AuthInfo = { @@ -140,6 +140,10 @@ export type CoursesCreateData = { }; export type CoursesCreateErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; /** * Client error */ @@ -389,6 +393,15 @@ export type UsersIndexData = { url: "/users"; }; +export type UsersIndexErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; +}; + +export type UsersIndexError = UsersIndexErrors[keyof UsersIndexErrors]; + export type UsersIndexResponses = { /** * The request has succeeded. @@ -435,6 +448,10 @@ export type UsersDestroyData = { }; export type UsersDestroyErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; /** * The server cannot find the requested resource. */ @@ -462,6 +479,10 @@ export type UsersShowData = { }; export type UsersShowErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; /** * The server cannot find the requested resource. */ @@ -489,6 +510,10 @@ export type UsersUpdateData = { }; export type UsersUpdateErrors = { + /** + * Access is unauthorized. + */ + 401: UnauthorizedError; /** * The server cannot find the requested resource. */ From 8be1ea7655a60652912d0bf2e0fd8fd4caa22a70 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 21:05:35 -0400 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=8F=D1=82=D1=8C=205xx=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D1=8B=D1=80=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B0,=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make contract-test поднимает приложение и гоняет по нему schemathesis: тот сам генерирует запросы из OpenAPI и сверяет ответы со спекой. На первом же прогоне 23 падения, все настоящие. Пять 500 на краевых входах: - POST /users с fullName в один символ. Запрос такое принимал, а модель ответа требует от 2 до 100 символов, и валидатор ответа ронял запрос уже после записи в базу. Границы добавлены в UserCreateDTO и UserEditDTO. - GET /courses?page=-1.79e+308. page был numeric без границ, и (page - 1) * perPage переполнялся. Теперь int32 с minValue(1) — заодно перестали приниматься дробные страницы. Идентификаторы в путях тоже int32. - PUT с пустым телом: все поля EditDTO необязательные, а drizzle на пустом set бросает «No values to set». Теперь запись возвращается без изменений. - DELETE пользователя, у которого есть курсы, и курса, у которого есть уроки, — нарушение внешнего ключа. Добавлен каскад: контракт обещает у DELETE только 204. - POST /courses с валидным токеном удалённого пользователя. Токен остаётся подписанным и не истёкшим, запрос доходил до обработчика и падал на внешнем ключе. Проверка существования пользователя добавлена в securityHandlers — туда же, где применяется сама авторизация. Плюс 400 и 429 не были описаны ни у одной операции, хотя их отдают валидация запроса и ограничитель частоты. Добавлены как CommonErrors. На каждую находку есть тест. После правок четыре прогона подряд с разными seed — 1000+ кейсов, ноль падений. --- .github/workflows/contract.yml | 43 ++ .gitignore | 3 + Makefile | 6 +- app.ts | 14 + db/schema.ts | 6 +- drizzle/0003_common_sabra.sql | 28 ++ drizzle/meta/0003_snapshot.json | 210 ++++++++++ drizzle/meta/_journal.json | 7 + main.tsp | 75 ++-- routes/api/courses.ts | 6 + routes/api/users.ts | 16 +- scripts/contract-test.sh | 42 ++ test/routes/api/auth.test.ts | 22 + test/routes/api/edge-cases.test.ts | 106 +++++ tsp-output/@typespec/openapi3/openapi.v1.json | 380 +++++++++++++++++- tsp-output/@typespec/openapi3/openapi.v2.json | 380 +++++++++++++++++- types/handlers/fastify.gen.ts | 6 +- types/handlers/index.ts | 6 + types/handlers/types.gen.ts | 138 +++++++ types/handlers/zod.gen.ts | 91 ++++- 20 files changed, 1509 insertions(+), 76 deletions(-) create mode 100644 .github/workflows/contract.yml create mode 100644 drizzle/0003_common_sabra.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100755 scripts/contract-test.sh create mode 100644 test/routes/api/edge-cases.test.ts diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml new file mode 100644 index 0000000..f73e2b5 --- /dev/null +++ b/.github/workflows/contract.yml @@ -0,0 +1,43 @@ +name: Contract Tests + +# Отдельным workflow, а не шагом в Node CI: инструмент внешний по отношению к +# проекту (python-овый schemathesis через uv), и выкинуть его должно быть +# просто — удалением одного файла и цели contract-test в Makefile. +# +# Проверяет то, чего не видят ни tsc, ни валидаторы fastify: применена ли +# авторизация, не отдаёт ли API статусы, которых нет в контракте, и не падает +# ли он в 5xx на краевых входах. + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + schemathesis: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 26.x + cache: pnpm + + - uses: astral-sh/setup-uv@v7 + + - name: Install + run: make install + + - name: Run contract tests + run: make contract-test diff --git a/.gitignore b/.gitignore index 01849c9..aefa92a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ profile* *clinic* *flamegraph* .env + +# кеш schemathesis (make contract-test) +.schemathesis diff --git a/Makefile b/Makefile index 98efba5..bd68e64 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,10 @@ generate-types: generate-openapi generate-openapi-ts-types generate-check: generate-types git diff --exit-code -- tsp-output types/handlers +# Контрактные тесты поверх спеки: см. комментарий в самом скрипте. +contract-test: + ./scripts/contract-test.sh + mock: pnpm exec prism mock ./tsp-output/@typespec/openapi3/openapi.v1.json @@ -82,4 +86,4 @@ install: .PHONY: install test dev check-types deps-update routes migration-generate \ lint lint-fix generate-openapi generate-openapi-ts-types generate-types \ - generate-check migration-check mock test-coverage lint-openapi + generate-check migration-check mock test-coverage lint-openapi contract-test diff --git a/app.ts b/app.ts index 4b06618..26e4ecf 100644 --- a/app.ts +++ b/app.ts @@ -1,10 +1,13 @@ import { STATUS_CODES } from "node:http"; import path from "node:path"; +import { httpErrors } from "@fastify/sensible"; +import { eq } from "drizzle-orm"; import type { AutoloadPluginOptions } from "@fastify/autoload"; import AutoLoad from "@fastify/autoload"; import type { FastifyPluginAsync, FastifyRequest, FastifyServerOptions } from "fastify"; import glue from "fastify-openapi-glue"; import * as z from "zod"; +import * as schemas from "./db/schema.ts"; import serviceHandlers from "./routes/index.ts"; export interface AppOptions extends FastifyServerOptions, Partial {} @@ -71,6 +74,17 @@ const app: FastifyPluginAsync = async (fastify, opts): Promise securityHandlers: { BearerAuth: async (request: FastifyRequest) => { await request.jwtVerify(); + + // Токен живёт до истечения срока, а пользователя за это время могли + // удалить. Без проверки запрос шёл дальше с идентификатором, которого + // в базе нет, и падал на внешнем ключе уже в обработчике. + const user = await request.db.query.users.findFirst({ + columns: { id: true }, + where: eq(schemas.users.id, request.user.id), + }); + if (!user) { + throw httpErrors.unauthorized("Token refers to a user that no longer exists"); + } }, }, specification: "./tsp-output/@typespec/openapi3/openapi.v1.json", diff --git a/db/schema.ts b/db/schema.ts index 5aae325..77519c9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -25,8 +25,10 @@ export const users = sqliteTable("users", { export const courses = sqliteTable("courses", { id: integer("id").primaryKey(), name: text("name").notNull(), + // Каскад, а не запрет: контракт обещает у DELETE только 204, и удаление + // автора курсов иначе падало в 500 на нарушении внешнего ключа. creatorId: integer("creator_id") - .references(() => users.id) + .references(() => users.id, { onDelete: "cascade" }) .notNull(), description: text("description").notNull(), ...timestamps, @@ -36,7 +38,7 @@ export const courseLessons = sqliteTable("course_lessons", { id: integer("id").primaryKey(), name: text("name").notNull(), courseId: integer("courseId") - .references(() => courses.id) + .references(() => courses.id, { onDelete: "cascade" }) .notNull(), body: text("body").notNull(), ...timestamps, diff --git a/drizzle/0003_common_sabra.sql b/drizzle/0003_common_sabra.sql new file mode 100644 index 0000000..833bafd --- /dev/null +++ b/drizzle/0003_common_sabra.sql @@ -0,0 +1,28 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_course_lessons` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `courseId` integer NOT NULL, + `body` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`courseId`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_course_lessons`("id", "name", "courseId", "body", "created_at", "updated_at") SELECT "id", "name", "courseId", "body", "created_at", "updated_at" FROM `course_lessons`;--> statement-breakpoint +DROP TABLE `course_lessons`;--> statement-breakpoint +ALTER TABLE `__new_course_lessons` RENAME TO `course_lessons`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE TABLE `__new_courses` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `creator_id` integer NOT NULL, + `description` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_courses`("id", "name", "creator_id", "description", "created_at", "updated_at") SELECT "id", "name", "creator_id", "description", "created_at", "updated_at" FROM `courses`;--> statement-breakpoint +DROP TABLE `courses`;--> statement-breakpoint +ALTER TABLE `__new_courses` RENAME TO `courses`; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..a6bcc72 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,210 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e77b7827-d467-4a50-b814-bb8a0138c27b", + "prevId": "71e237eb-50bc-4f3c-a0ae-200bd03e64c7", + "tables": { + "course_lessons": { + "name": "course_lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "courseId": { + "name": "courseId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "course_lessons_courseId_courses_id_fk": { + "name": "course_lessons_courseId_courses_id_fk", + "tableFrom": "course_lessons", + "tableTo": "courses", + "columnsFrom": [ + "courseId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "courses": { + "name": "courses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "courses_creator_id_users_id_fk": { + "name": "courses_creator_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_digest": { + "name": "password_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 76c8bfb..507d15d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1787444748820, "tag": "0002_handy_paladin", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1787446852568, + "tag": "0003_common_sabra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/main.tsp b/main.tsp index 096eadb..ce2b48a 100644 --- a/main.tsp +++ b/main.tsp @@ -37,6 +37,23 @@ model ProblemDetails { instance?: string; } +@error +model BadRequestError { + @statusCode _: 400; + ...ProblemDetails; +} + +@error +model TooManyRequestsError { + @statusCode _: 429; + ...ProblemDetails; +} + +// Эти два исхода возможны почти везде: 400 отдаёт валидация запроса по этой же +// спеке, 429 — ограничитель частоты. Раньше их не было в контракте вовсе, и +// клиент по спеке не мог их предусмотреть. +alias CommonErrors = BadRequestError | TooManyRequestsError; + @error model NotFoundError { @statusCode _: 404; @@ -86,7 +103,12 @@ model User { } model UserCreateDTO { + // Границы те же, что у User: иначе запрос принимает имя, которое валидатор + // ответа потом забракует, и создание падает в 500. + @minLength(2) + @maxLength(100) fullName?: string; + email: string; @minLength(8) @@ -95,6 +117,8 @@ model UserCreateDTO { } model UserEditDTO { + @minLength(2) + @maxLength(100) fullName?: string; @minLength(8) @@ -149,19 +173,19 @@ namespace users { @operationId("usersIndex") @get @useAuth(BearerAuth) - op index(@query page?: numeric = 1): { + op index(@query @minValue(1) page?: int32 = 1): { @body users: { data: User[]; }; - } | UnauthorizedError; + } | UnauthorizedError | CommonErrors; @summary("Пользователь по идентификатору") @get @useAuth(BearerAuth) @operationId("usersShow") - op show(@path id: numeric): { + op show(@path @minValue(1) id: int32): { @body _: User; - } | NotFoundError | UnauthorizedError; + } | NotFoundError | UnauthorizedError | CommonErrors; @summary("Регистрация пользователя") @post @@ -169,23 +193,23 @@ namespace users { op create(@body _: UserCreateDTO): { @body _: User; @statusCode statusCode: 201; - } | UnprocessableEntityError; + } | UnprocessableEntityError | CommonErrors; @summary("Изменение пользователя") @put @useAuth(BearerAuth) @operationId("usersUpdate") - op update(@path id: numeric, @body user: UserEditDTO): { + op update(@path @minValue(1) id: int32, @body user: UserEditDTO): { @body user: User; - } | NotFoundError | UnprocessableEntityError | UnauthorizedError; + } | NotFoundError | UnprocessableEntityError | UnauthorizedError | CommonErrors; @summary("Удаление пользователя") @delete @useAuth(BearerAuth) @operationId("usersDestroy") - op destroy(@path id: numeric): { + op destroy(@path @minValue(1) id: int32): { @statusCode statusCode: 204; - } | NotFoundError | UnauthorizedError; + } | NotFoundError | UnauthorizedError | CommonErrors; } @route("/tokens") @@ -196,7 +220,7 @@ namespace tokens { op create(@body auth_info: AuthInfo): { @body token: TokenInfo; @statusCode statusCode: 201; - } | UnprocessableEntityError | UnauthorizedError; + } | UnprocessableEntityError | UnauthorizedError | CommonErrors; } @route("/courses") @@ -204,18 +228,18 @@ namespace courses { @summary("Список курсов") @get @operationId("coursesIndex") - op index(@query page?: numeric = 1): { + op index(@query @minValue(1) page?: int32 = 1): { @body courses: { data: Course[]; }; - }; + } | CommonErrors; @summary("Курс по идентификатору") @get @operationId("coursesShow") - op show(@path id: numeric): { + op show(@path @minValue(1) id: int32): { @body course: Course; - } | NotFoundError; + } | NotFoundError | CommonErrors; @summary("Создание курса") @post @@ -224,28 +248,29 @@ namespace courses { op create(@body course: CourseCreateDTO): { @body course: Course; @statusCode statusCode: 201; - } | UnprocessableEntityError | UnauthorizedError; + } | UnprocessableEntityError | UnauthorizedError | CommonErrors; @summary("Изменение курса") @put @useAuth(BearerAuth) @operationId("coursesUpdate") - op update(@path id: numeric, @body course: CourseEditDTO): + op update(@path @minValue(1) id: int32, @body course: CourseEditDTO): | { @body course: Course; } | NotFoundError | UnprocessableEntityError | UnauthorizedError - | ForbiddenError; + | ForbiddenError + | CommonErrors; @summary("Удаление курса") @delete @useAuth(BearerAuth) @operationId("coursesDestroy") - op destroy(@path id: numeric): { + op destroy(@path @minValue(1) id: int32): { @statusCode statusCode: 204; - } | NotFoundError | UnauthorizedError | ForbiddenError; + } | NotFoundError | UnauthorizedError | ForbiddenError | CommonErrors; } @route("/courses/{courseId}/lessons") @@ -253,26 +278,26 @@ namespace courses_lessons { @summary("Список уроков курса") @get @operationId("coursesLessonsIndex") - op index(@path courseId: numeric, @query page?: numeric = 1): { + op index(@path @minValue(1) courseId: int32, @query @minValue(1) page?: int32 = 1): { @body lessons: { data: CourseLesson[]; }; - }; + } | CommonErrors; @summary("Урок курса") @get @operationId("coursesLessonsShow") - op show(@path courseId: numeric, @path id: numeric): { + op show(@path @minValue(1) courseId: int32, @path @minValue(1) id: int32): { @body lessons: CourseLesson; - } | NotFoundError; + } | NotFoundError | CommonErrors; @summary("Добавление урока в курс") @post @useAuth(BearerAuth) @operationId("coursesLessonsCreate") - op create(@path courseId: numeric, @body course: CourseLessonCreateDTO): { + op create(@path @minValue(1) courseId: int32, @body course: CourseLessonCreateDTO): { @body lesson: CourseLesson; @statusCode statusCode: 201; - } | UnprocessableEntityError | UnauthorizedError | ForbiddenError | NotFoundError; + } | UnprocessableEntityError | UnauthorizedError | ForbiddenError | NotFoundError | CommonErrors; } diff --git a/routes/api/courses.ts b/routes/api/courses.ts index abadf27..0232e37 100644 --- a/routes/api/courses.ts +++ b/routes/api/courses.ts @@ -47,6 +47,12 @@ const handlers = defineHandlers({ } const validated = await CourseValidator.validateEdit(request.db, request.body); + // Все поля CourseEditDTO необязательные, поэтому тело может оказаться + // пустым. drizzle на пустом set бросает «No values to set» — это был 500. + if (Object.keys(validated).length === 0) { + return reply.code(200).send(course); + } + const [updated] = await request.db .update(schemas.courses) .set(validated) diff --git a/routes/api/users.ts b/routes/api/users.ts index 42cd0eb..19638fa 100644 --- a/routes/api/users.ts +++ b/routes/api/users.ts @@ -39,16 +39,30 @@ const handlers = defineHandlers({ }, async usersUpdate(request, reply) { + // Запись читается до правки: так 404 наступает раньше любой работы, и есть + // что вернуть, если менять нечего. + const existing = await request.db.query.users.findFirst({ + columns: publicUserColumns, + where: eq(schemas.users.id, request.params.id), + }); + ensure(existing, 404); + const { password, ...validated } = await UserValidator.validateEdit(request.db, request.body); const values = password ? { ...validated, passwordDigest: await hashPassword(password) } : validated; + + // Все поля UserEditDTO необязательные, поэтому тело может оказаться + // пустым. drizzle на пустом set бросает «No values to set» — это был 500. + if (Object.keys(values).length === 0) { + return reply.code(200).send(existing); + } + const [user] = await request.db .update(schemas.users) .set(values) .where(eq(schemas.users.id, request.params.id)) .returning(publicUserFields); - ensure(user, 404); return reply.code(200).send(user); }, diff --git a/scripts/contract-test.sh b/scripts/contract-test.sh new file mode 100755 index 0000000..de9cf18 --- /dev/null +++ b/scripts/contract-test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Контрактные тесты: schemathesis сам генерирует запросы из OpenAPI и сверяет +# ответы со спекой. Ловит то, что не ловят ни tsc, ни валидаторы fastify: +# незадокументированные статусы, 5xx на краевых входах и неприменённую +# авторизацию (ignored_auth дёргает защищённые операции без токена). +# +# Требует uv: https://docs.astral.sh/uv/ +set -euo pipefail + +PORT="${PORT:-3210}" +BASE="http://127.0.0.1:${PORT}" +EXAMPLES="${SCHEMATHESIS_EXAMPLES:-20}" + +export JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}" +# Лимитер в контрактном прогоне только мешает: schemathesis шлёт сотни запросов +# подряд и упирается в него, а не в поведение API. +export RATE_LIMIT_MAX=1000000 + +pnpm exec fastify start -l error -p "$PORT" app.ts & +APP_PID=$! +trap 'kill "$APP_PID" 2>/dev/null || true' EXIT + +for _ in $(seq 1 60); do + if curl -sf "$BASE/openapi.json" >/dev/null 2>&1; then break; fi + sleep 1 +done +curl -sf "$BASE/openapi.json" >/dev/null + +# Пользователь из сидов (db/seeds.ts) с паролем по умолчанию из lib/data.ts. +# Токен нужен, чтобы проверялись и защищённые операции, а не только 401 на них. +TOKEN=$( + curl -sf -X POST "$BASE/tokens" \ + -H 'content-type: application/json' \ + -d '{"email":"support@hexlet.io","password":"correct-horse-battery-staple"}' | + node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).token))' +) + +uvx --from schemathesis st run "$BASE/openapi.json" \ + --url "$BASE" \ + -H "Authorization: Bearer $TOKEN" \ + -c not_a_server_error,ignored_auth,status_code_conformance,content_type_conformance,response_schema_conformance \ + --max-examples "$EXAMPLES" diff --git a/test/routes/api/auth.test.ts b/test/routes/api/auth.test.ts index 4f47557..b5685de 100644 --- a/test/routes/api/auth.test.ts +++ b/test/routes/api/auth.test.ts @@ -67,6 +67,28 @@ test("public operations stay reachable without a token", async () => { assert.deepStrictEqual(failures, []); }); +// Токен остаётся подписанным и не истёкшим после удаления аккаунта. Раньше +// такой запрос доходил до обработчика и падал на внешнем ключе с 500. +test("a token for a deleted user no longer authenticates", async () => { + const app = await build(); + const user = await app.db.query.users.findFirst(); + assert.ok(user); + const authHeader = await getAuthHeader(app, user.id); + + const before = await app.inject({ url: "/users", headers: { ...authHeader } }); + assert.equal(before.statusCode, 200, before.body); + + const deleted = await app.inject({ + method: "delete", + url: `/users/${user.id}`, + headers: { ...authHeader }, + }); + assert.equal(deleted.statusCode, 204, deleted.body); + + const after = await app.inject({ url: "/users", headers: { ...authHeader } }); + assert.equal(after.statusCode, 401, after.body); +}); + test("protected operations accept a valid token", async () => { const app = await build(); const authHeader = await getAuthHeader(app); diff --git a/test/routes/api/edge-cases.test.ts b/test/routes/api/edge-cases.test.ts new file mode 100644 index 0000000..7be222a --- /dev/null +++ b/test/routes/api/edge-cases.test.ts @@ -0,0 +1,106 @@ +import { test } from "vitest"; +import * as assert from "node:assert"; +import { build, getAuthHeader } from "../../helper.ts"; + +// Всё в этом файле нашёл schemathesis, генерируя запросы из спеки. Обычные +// тесты на happy path эти входы не покрывали, и каждый из них давал 500. + +test("an empty update body leaves the record as it was", async () => { + const app = await build(); + const user = await app.db.query.users.findFirst(); + assert.ok(user); + const authHeader = await getAuthHeader(app); + + const res = await app.inject({ + method: "put", + url: `/users/${user.id}`, + headers: { ...authHeader }, + body: {}, + }); + + assert.equal(res.statusCode, 200, res.body); + assert.equal(JSON.parse(res.body).fullName, user.fullName); +}); + +test("an empty course update body leaves the record as it was", async () => { + const app = await build(); + const course = await app.db.query.courses.findFirst(); + assert.ok(course); + const authHeader = await getAuthHeader(app, course.creatorId); + + const res = await app.inject({ + method: "put", + url: `/courses/${course.id}`, + headers: { ...authHeader }, + body: {}, + }); + + assert.equal(res.statusCode, 200, res.body); + assert.equal(JSON.parse(res.body).name, course.name); +}); + +test("deleting a user cascades to the courses they created", async () => { + const app = await build(); + const course = await app.db.query.courses.findFirst(); + assert.ok(course); + const authHeader = await getAuthHeader(app, course.creatorId); + + const res = await app.inject({ + method: "delete", + url: `/users/${course.creatorId}`, + headers: { ...authHeader }, + }); + + assert.equal(res.statusCode, 204, res.body); + const left = await app.db.query.courses.findMany(); + assert.deepStrictEqual( + left.filter((item) => item.creatorId === course.creatorId), + [], + ); +}); + +test("deleting a course cascades to its lessons", async () => { + const app = await build(); + const lesson = await app.db.query.courseLessons.findFirst(); + assert.ok(lesson); + const course = await app.db.query.courses.findFirst(); + assert.ok(course); + const authHeader = await getAuthHeader(app, course.creatorId); + + const res = await app.inject({ + method: "delete", + url: `/courses/${lesson.courseId}`, + headers: { ...authHeader }, + }); + + assert.equal(res.statusCode, 204, res.body); + const left = await app.db.query.courseLessons.findMany(); + assert.deepStrictEqual( + left.filter((item) => item.courseId === lesson.courseId), + [], + ); +}); + +// Спека не ограничивала page, и (page - 1) * perPage переполнялся. +test("an out-of-range page is rejected by the contract", async () => { + const app = await build(); + + for (const page of ["0", "-1", "-1.79e+308", "1.5"]) { + const res = await app.inject({ url: `/courses?page=${encodeURIComponent(page)}` }); + assert.equal(res.statusCode, 400, `page=${page} -> ${res.statusCode}`); + } +}); + +// UserCreateDTO принимал имя, которое модель ответа потом браковала, и +// создание падало в 500 уже после записи в базу. +test("a full name shorter than the response model allows is rejected", async () => { + const app = await build(); + + const res = await app.inject({ + method: "post", + url: "/users", + body: { email: "shortname@hexlet.io", fullName: "A", password: "12345678" }, + }); + + assert.equal(res.statusCode, 400, res.body); +}); diff --git a/tsp-output/@typespec/openapi3/openapi.v1.json b/tsp-output/@typespec/openapi3/openapi.v1.json index 050aa94..33c32f4 100644 --- a/tsp-output/@typespec/openapi3/openapi.v1.json +++ b/tsp-output/@typespec/openapi3/openapi.v1.json @@ -20,7 +20,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -47,6 +49,26 @@ } } } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -65,6 +87,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -84,6 +116,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -113,7 +155,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } }, { @@ -121,7 +165,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -148,6 +194,26 @@ } } } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -160,7 +226,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -175,6 +243,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -214,6 +292,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -243,7 +331,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } }, { @@ -251,7 +341,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -266,6 +358,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -275,6 +377,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } } @@ -289,7 +401,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -304,6 +418,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -313,6 +437,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -325,7 +459,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -340,6 +476,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -379,6 +525,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -406,7 +562,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -414,6 +572,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -443,6 +611,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -468,6 +646,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -487,6 +675,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -511,7 +709,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -539,6 +739,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -548,6 +758,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -571,6 +791,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -580,6 +810,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -604,7 +844,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -619,6 +861,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -638,6 +890,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -655,7 +917,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -670,6 +934,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -699,6 +973,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -726,7 +1010,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -734,6 +1020,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -753,6 +1049,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -781,6 +1087,26 @@ } } }, + "BadRequestError": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + } + }, "Course": { "type": "object", "required": [ @@ -939,6 +1265,26 @@ } } }, + "TooManyRequestsError": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + } + }, "UnauthorizedError": { "type": "object", "properties": { @@ -1034,7 +1380,9 @@ ], "properties": { "fullName": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 100 }, "email": { "type": "string" @@ -1050,7 +1398,9 @@ "type": "object", "properties": { "fullName": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 100 }, "password": { "type": "string", diff --git a/tsp-output/@typespec/openapi3/openapi.v2.json b/tsp-output/@typespec/openapi3/openapi.v2.json index 1da2673..5aa612e 100644 --- a/tsp-output/@typespec/openapi3/openapi.v2.json +++ b/tsp-output/@typespec/openapi3/openapi.v2.json @@ -20,7 +20,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -47,6 +49,26 @@ } } } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -65,6 +87,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -84,6 +116,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -113,7 +155,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } }, { @@ -121,7 +165,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -148,6 +194,26 @@ } } } + }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -160,7 +226,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -175,6 +243,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -214,6 +292,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -243,7 +331,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } }, { @@ -251,7 +341,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -266,6 +358,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -275,6 +377,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } } @@ -289,7 +401,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -304,6 +418,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "The server cannot find the requested resource.", "content": { @@ -313,6 +437,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } } }, @@ -325,7 +459,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -340,6 +476,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -379,6 +525,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -406,7 +562,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -414,6 +572,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -443,6 +611,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -468,6 +646,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -487,6 +675,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -511,7 +709,9 @@ "in": "query", "required": false, "schema": { - "type": "number", + "type": "integer", + "format": "int32", + "minimum": 1, "default": 1 }, "explode": false @@ -539,6 +739,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -548,6 +758,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -571,6 +791,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "422": { "description": "Client error", "content": { @@ -580,6 +810,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -604,7 +844,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -619,6 +861,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -638,6 +890,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -655,7 +917,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -670,6 +934,16 @@ } } }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -699,6 +973,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "requestBody": { @@ -726,7 +1010,9 @@ "in": "path", "required": true, "schema": { - "type": "number" + "type": "integer", + "format": "int32", + "minimum": 1 } } ], @@ -734,6 +1020,16 @@ "204": { "description": "There is no content to send for this request, but the headers may be useful. " }, + "400": { + "description": "The server could not understand the request due to invalid syntax.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "401": { "description": "Access is unauthorized.", "content": { @@ -753,6 +1049,16 @@ } } } + }, + "429": { + "description": "Client error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/TooManyRequestsError" + } + } + } } }, "security": [ @@ -781,6 +1087,26 @@ } } }, + "BadRequestError": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + } + }, "Course": { "type": "object", "required": [ @@ -939,6 +1265,26 @@ } } }, + "TooManyRequestsError": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + } + }, "UnauthorizedError": { "type": "object", "properties": { @@ -1038,7 +1384,9 @@ ], "properties": { "fullName": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 100 }, "email": { "type": "string" @@ -1054,7 +1402,9 @@ "type": "object", "properties": { "fullName": { - "type": "string" + "type": "string", + "minLength": 2, + "maxLength": 100 }, "password": { "type": "string", diff --git a/types/handlers/fastify.gen.ts b/types/handlers/fastify.gen.ts index 82653e2..c6702a6 100644 --- a/types/handlers/fastify.gen.ts +++ b/types/handlers/fastify.gen.ts @@ -10,11 +10,13 @@ import type { CoursesDestroyErrors, CoursesDestroyResponses, CoursesIndexData, + CoursesIndexErrors, CoursesIndexResponses, CoursesLessonsCreateData, CoursesLessonsCreateErrors, CoursesLessonsCreateResponses, CoursesLessonsIndexData, + CoursesLessonsIndexErrors, CoursesLessonsIndexResponses, CoursesLessonsShowData, CoursesLessonsShowErrors, @@ -48,7 +50,7 @@ import type { export type RouteHandlers = { coursesIndex: RouteHandler<{ Querystring?: CoursesIndexData["query"]; - Reply: CoursesIndexResponses; + Reply: CoursesIndexErrors & CoursesIndexResponses; }>; coursesCreate: RouteHandler<{ Body: CoursesCreateData["body"]; @@ -57,7 +59,7 @@ export type RouteHandlers = { coursesLessonsIndex: RouteHandler<{ Params: CoursesLessonsIndexData["path"]; Querystring?: CoursesLessonsIndexData["query"]; - Reply: CoursesLessonsIndexResponses; + Reply: CoursesLessonsIndexErrors & CoursesLessonsIndexResponses; }>; coursesLessonsCreate: RouteHandler<{ Body: CoursesLessonsCreateData["body"]; diff --git a/types/handlers/index.ts b/types/handlers/index.ts index b91a4b5..1da1d4c 100644 --- a/types/handlers/index.ts +++ b/types/handlers/index.ts @@ -2,6 +2,7 @@ export type { AuthInfo, + BadRequestError, ClientOptions, Course, CourseCreateDto, @@ -19,6 +20,8 @@ export type { CoursesDestroyResponse, CoursesDestroyResponses, CoursesIndexData, + CoursesIndexError, + CoursesIndexErrors, CoursesIndexResponse, CoursesIndexResponses, CoursesLessonsCreateData, @@ -27,6 +30,8 @@ export type { CoursesLessonsCreateResponse, CoursesLessonsCreateResponses, CoursesLessonsIndexData, + CoursesLessonsIndexError, + CoursesLessonsIndexErrors, CoursesLessonsIndexResponse, CoursesLessonsIndexResponses, CoursesLessonsShowData, @@ -54,6 +59,7 @@ export type { TokensCreateErrors, TokensCreateResponse, TokensCreateResponses, + TooManyRequestsError, UnauthorizedError, UnprocessableEntityError, User, diff --git a/types/handlers/types.gen.ts b/types/handlers/types.gen.ts index eced220..f9c9175 100644 --- a/types/handlers/types.gen.ts +++ b/types/handlers/types.gen.ts @@ -9,6 +9,14 @@ export type AuthInfo = { password: string; }; +export type BadRequestError = { + type?: string; + title?: string; + status?: number; + detail?: string; + instance?: string; +}; + export type Course = { id: number; name: string; @@ -72,6 +80,14 @@ export type TokenInfo = { token: string; }; +export type TooManyRequestsError = { + type?: string; + title?: string; + status?: number; + detail?: string; + instance?: string; +}; + export type UnauthorizedError = { type?: string; title?: string; @@ -121,6 +137,19 @@ export type CoursesIndexData = { url: "/courses"; }; +export type CoursesIndexErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; + /** + * Client error + */ + 429: TooManyRequestsError; +}; + +export type CoursesIndexError = CoursesIndexErrors[keyof CoursesIndexErrors]; + export type CoursesIndexResponses = { /** * The request has succeeded. @@ -140,6 +169,10 @@ export type CoursesCreateData = { }; export type CoursesCreateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -148,6 +181,10 @@ export type CoursesCreateErrors = { * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesCreateError = CoursesCreateErrors[keyof CoursesCreateErrors]; @@ -172,6 +209,19 @@ export type CoursesLessonsIndexData = { url: "/courses/{courseId}/lessons"; }; +export type CoursesLessonsIndexErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; + /** + * Client error + */ + 429: TooManyRequestsError; +}; + +export type CoursesLessonsIndexError = CoursesLessonsIndexErrors[keyof CoursesLessonsIndexErrors]; + export type CoursesLessonsIndexResponses = { /** * The request has succeeded. @@ -194,6 +244,10 @@ export type CoursesLessonsCreateData = { }; export type CoursesLessonsCreateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -210,6 +264,10 @@ export type CoursesLessonsCreateErrors = { * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesLessonsCreateError = @@ -236,10 +294,18 @@ export type CoursesLessonsShowData = { }; export type CoursesLessonsShowErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * The server cannot find the requested resource. */ 404: NotFoundError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesLessonsShowError = CoursesLessonsShowErrors[keyof CoursesLessonsShowErrors]; @@ -264,6 +330,10 @@ export type CoursesDestroyData = { }; export type CoursesDestroyErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -276,6 +346,10 @@ export type CoursesDestroyErrors = { * The server cannot find the requested resource. */ 404: NotFoundError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesDestroyError = CoursesDestroyErrors[keyof CoursesDestroyErrors]; @@ -299,10 +373,18 @@ export type CoursesShowData = { }; export type CoursesShowErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * The server cannot find the requested resource. */ 404: NotFoundError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesShowError = CoursesShowErrors[keyof CoursesShowErrors]; @@ -326,6 +408,10 @@ export type CoursesUpdateData = { }; export type CoursesUpdateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -342,6 +428,10 @@ export type CoursesUpdateErrors = { * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type CoursesUpdateError = CoursesUpdateErrors[keyof CoursesUpdateErrors]; @@ -363,6 +453,10 @@ export type TokensCreateData = { }; export type TokensCreateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -371,6 +465,10 @@ export type TokensCreateErrors = { * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type TokensCreateError = TokensCreateErrors[keyof TokensCreateErrors]; @@ -394,10 +492,18 @@ export type UsersIndexData = { }; export type UsersIndexErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ 401: UnauthorizedError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type UsersIndexError = UsersIndexErrors[keyof UsersIndexErrors]; @@ -421,10 +527,18 @@ export type UsersCreateData = { }; export type UsersCreateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type UsersCreateError = UsersCreateErrors[keyof UsersCreateErrors]; @@ -448,6 +562,10 @@ export type UsersDestroyData = { }; export type UsersDestroyErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -456,6 +574,10 @@ export type UsersDestroyErrors = { * The server cannot find the requested resource. */ 404: NotFoundError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type UsersDestroyError = UsersDestroyErrors[keyof UsersDestroyErrors]; @@ -479,6 +601,10 @@ export type UsersShowData = { }; export type UsersShowErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -487,6 +613,10 @@ export type UsersShowErrors = { * The server cannot find the requested resource. */ 404: NotFoundError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type UsersShowError = UsersShowErrors[keyof UsersShowErrors]; @@ -510,6 +640,10 @@ export type UsersUpdateData = { }; export type UsersUpdateErrors = { + /** + * The server could not understand the request due to invalid syntax. + */ + 400: BadRequestError; /** * Access is unauthorized. */ @@ -522,6 +656,10 @@ export type UsersUpdateErrors = { * Client error */ 422: UnprocessableEntityError; + /** + * Client error + */ + 429: TooManyRequestsError; }; export type UsersUpdateError = UsersUpdateErrors[keyof UsersUpdateErrors]; diff --git a/types/handlers/zod.gen.ts b/types/handlers/zod.gen.ts index 89bcf52..abf8ae0 100644 --- a/types/handlers/zod.gen.ts +++ b/types/handlers/zod.gen.ts @@ -7,6 +7,14 @@ export const zAuthInfo = z.object({ password: z.string(), }); +export const zBadRequestError = z.object({ + type: z.string().optional(), + title: z.string().optional(), + status: z.int().optional(), + detail: z.string().optional(), + instance: z.string().optional(), +}); + export const zCourse = z.object({ id: z.number(), name: z.string(), @@ -68,6 +76,14 @@ export const zTokenInfo = z.object({ token: z.string(), }); +export const zTooManyRequestsError = z.object({ + type: z.string().optional(), + title: z.string().optional(), + status: z.int().optional(), + detail: z.string().optional(), + instance: z.string().optional(), +}); + export const zUnauthorizedError = z.object({ type: z.string().optional(), title: z.string().optional(), @@ -98,20 +114,25 @@ export const zUser = z.object({ }); export const zUserCreateDto = z.object({ - fullName: z.string().optional(), + fullName: z.string().min(2).max(100).optional(), email: z.string(), password: z.string().min(8), }); export const zUserEditDto = z.object({ - fullName: z.string().optional(), + fullName: z.string().min(2).max(100).optional(), password: z.string().min(8).optional(), }); export const zVersions = z.enum(["v1", "v2"]); export const zCoursesIndexQuery = z.object({ - page: z.number().optional().default(1), + page: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }) + .optional() + .default(1), }); /** @@ -129,11 +150,19 @@ export const zCoursesCreateBody = zCourseCreateDto; export const zCoursesCreateResponse = zCourse; export const zCoursesLessonsIndexPath = z.object({ - courseId: z.number(), + courseId: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); export const zCoursesLessonsIndexQuery = z.object({ - page: z.number().optional().default(1), + page: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }) + .optional() + .default(1), }); /** @@ -146,7 +175,10 @@ export const zCoursesLessonsIndexResponse = z.object({ export const zCoursesLessonsCreateBody = zCourseLessonCreateDto; export const zCoursesLessonsCreatePath = z.object({ - courseId: z.number(), + courseId: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -155,8 +187,14 @@ export const zCoursesLessonsCreatePath = z.object({ export const zCoursesLessonsCreateResponse = zCourseLesson; export const zCoursesLessonsShowPath = z.object({ - courseId: z.number(), - id: z.number(), + courseId: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -165,7 +203,10 @@ export const zCoursesLessonsShowPath = z.object({ export const zCoursesLessonsShowResponse = zCourseLesson; export const zCoursesDestroyPath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -174,7 +215,10 @@ export const zCoursesDestroyPath = z.object({ export const zCoursesDestroyResponse = z.void(); export const zCoursesShowPath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -185,7 +229,10 @@ export const zCoursesShowResponse = zCourse; export const zCoursesUpdateBody = zCourseEditDto; export const zCoursesUpdatePath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -201,7 +248,12 @@ export const zTokensCreateBody = zAuthInfo; export const zTokensCreateResponse = zTokenInfo; export const zUsersIndexQuery = z.object({ - page: z.number().optional().default(1), + page: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }) + .optional() + .default(1), }); /** @@ -219,7 +271,10 @@ export const zUsersCreateBody = zUserCreateDto; export const zUsersCreateResponse = zUser; export const zUsersDestroyPath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -228,7 +283,10 @@ export const zUsersDestroyPath = z.object({ export const zUsersDestroyResponse = z.void(); export const zUsersShowPath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** @@ -239,7 +297,10 @@ export const zUsersShowResponse = zUser; export const zUsersUpdateBody = zUserEditDto; export const zUsersUpdatePath = z.object({ - id: z.number(), + id: z + .int() + .gte(1) + .max(2147483647, { error: "Invalid value: Expected int32 to be <= 2147483647" }), }); /** From ee287429b872c05658681573430d993bcf8fdb81 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 21:10:31 -0400 Subject: [PATCH 11/14] =?UTF-8?q?test:=20=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=20API=20=D1=81=D0=B3=D0=B5=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openapi-ts из той же спеки генерирует не только типы обработчиков и zod-схемы, но и типизированный клиент. CRUD-тесты по курсам, пользователям, урокам и токенам переведены на него: URL, методы и формы тел больше не переписываются в тестах руками и не могут разойтись с контрактом. Клиент ходит через fetch, поэтому buildClient() поднимает настоящий сокет на случайном порту. Покрытие от этого не пострадало (100% строк, ветки даже чуть выросли — 86% против 85%), пять прогонов подряд стабильны и в один поток, и с параллелизмом. Тесты на нарушения контракта — страница вне диапазона, слишком короткое имя, запрос без токена — остались на app.inject(): клиент типизирован по спеке и выразить такие запросы не даёт, а проверять их надо. Правило записано в test/helper.ts. --- openapi-ts.config.ts | 4 + test/helper.ts | 30 +- test/routes/api/courses.test.ts | 78 ++--- test/routes/api/courses/lessons.test.ts | 45 +-- test/routes/api/tokens.test.ts | 51 ++- test/routes/api/users.test.ts | 105 +++--- types/handlers/client.gen.ts | 26 ++ types/handlers/client/client.gen.ts | 277 +++++++++++++++ types/handlers/client/index.ts | 27 ++ types/handlers/client/types.gen.ts | 218 ++++++++++++ types/handlers/client/utils.gen.ts | 316 ++++++++++++++++++ types/handlers/core/auth.gen.ts | 48 +++ types/handlers/core/bodySerializer.gen.ts | 82 +++++ types/handlers/core/params.gen.ts | 178 ++++++++++ types/handlers/core/pathSerializer.gen.ts | 171 ++++++++++ types/handlers/core/queryKeySerializer.gen.ts | 117 +++++++ types/handlers/core/serverSentEvents.gen.ts | 242 ++++++++++++++ types/handlers/core/types.gen.ts | 114 +++++++ types/handlers/core/utils.gen.ts | 140 ++++++++ types/handlers/index.ts | 17 + types/handlers/sdk.gen.ts | 264 +++++++++++++++ 21 files changed, 2393 insertions(+), 157 deletions(-) create mode 100644 types/handlers/client.gen.ts create mode 100644 types/handlers/client/client.gen.ts create mode 100644 types/handlers/client/index.ts create mode 100644 types/handlers/client/types.gen.ts create mode 100644 types/handlers/client/utils.gen.ts create mode 100644 types/handlers/core/auth.gen.ts create mode 100644 types/handlers/core/bodySerializer.gen.ts create mode 100644 types/handlers/core/params.gen.ts create mode 100644 types/handlers/core/pathSerializer.gen.ts create mode 100644 types/handlers/core/queryKeySerializer.gen.ts create mode 100644 types/handlers/core/serverSentEvents.gen.ts create mode 100644 types/handlers/core/types.gen.ts create mode 100644 types/handlers/core/utils.gen.ts create mode 100644 types/handlers/sdk.gen.ts diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index 5f5615c..823b610 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -8,5 +8,9 @@ export default defineConfig({ // валидаторы расширяют сгенерированную схему вместо своей копии контракта. "fastify", "zod", + // Клиент из той же спеки — им ходят тесты, чтобы не писать URL и методы + // руками. + "@hey-api/client-fetch", + "@hey-api/sdk", ], }); diff --git a/test/helper.ts b/test/helper.ts index 45c8d73..8b52ffc 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -5,6 +5,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import fp from "fastify-plugin"; import app from "../app.ts"; import * as schemas from "../db/schema.ts"; +import { createClient, createConfig } from "../types/handlers/client/index.js"; // Приложение собирается напрямую, а не через helper из fastify-cli. Тот грузит // app.ts сам, в обход трансформации vite: из-за этого весь app в тестах был @@ -22,6 +23,33 @@ async function build(): Promise { return fastify; } +// Клиент, сгенерированный из той же спеки. Нужен настоящий сокет: SDK ходит +// через fetch, а не через inject. +// +// Правило, по которому тесты разделены между buildClient() и build(): +// корректные запросы идут клиентом — URL, методы и формы тел тогда берутся из +// контракта, а не переписываются руками. Запросы, нарушающие контракт +// (страница вне диапазона, слишком короткое имя, обращение без токена), +// остаются на app.inject(): клиент типизирован по спеке и выразить их просто +// не даёт, а проверять их надо. +async function buildClient() { + const app = await build(); + await app.listen({ port: 0, host: "127.0.0.1" }); + + const address = app.server.address(); + assert.ok(address && typeof address === "object"); + const client = createClient(createConfig({ baseUrl: `http://127.0.0.1:${address.port}` })); + + return { app, client }; +} + +// У SDK response необязателен: при сетевой ошибке его не будет. Тесты про +// статусы, поэтому проверяем наличие один раз здесь. +function responseOf(result: { response?: Response }): Response { + assert.ok(result.response, "client returned no response"); + return result.response; +} + async function getAuthHeader(app: FastifyInstance, userId: number | null = null) { const from = app.db.select().from(schemas.users); const [client] = userId ? await from.where(eq(schemas.users.id, userId)) : await from.limit(1); @@ -32,4 +60,4 @@ async function getAuthHeader(app: FastifyInstance, userId: number | null = null) }; } -export { build, getAuthHeader }; +export { build, buildClient, getAuthHeader, responseOf }; diff --git a/test/routes/api/courses.test.ts b/test/routes/api/courses.test.ts index 6196541..b057189 100644 --- a/test/routes/api/courses.test.ts +++ b/test/routes/api/courses.test.ts @@ -1,78 +1,72 @@ import { test } from "vitest"; import * as assert from "node:assert"; -import { getAuthHeader, build } from "../../helper.ts"; -import { buildCourse } from "../../../lib/data.ts"; import { pick } from "es-toolkit"; +import { buildClient, getAuthHeader, responseOf } from "../../helper.ts"; +import { buildCourse } from "../../../lib/data.ts"; +import { + coursesCreate, + coursesDestroy, + coursesIndex, + coursesShow, + coursesUpdate, +} from "../../../types/handlers/sdk.gen.js"; + +// Тесты ходят сгенерированным из спеки клиентом: URL, методы и формы тел +// берутся из контракта, а не переписываются здесь руками. test("get courses", async () => { - const app = await build(); + const { client } = await buildClient(); - const res = await app.inject({ - url: "/courses", - }); - assert.equal(res.statusCode, 200, res.body); + const res = await coursesIndex({ client }); + assert.equal(responseOf(res).status, 200); }); test("get courses/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const course = await app.db.query.courses.findFirst(); assert.ok(course); - const res = await app.inject({ - url: `/courses/${course.id}`, - }); - assert.equal(res.statusCode, 200, res.body); + const res = await coursesShow({ client, path: { id: course.id } }); + assert.equal(responseOf(res).status, 200); }); test("post courses", async () => { - const app = await build(); - const body = buildCourse(); + const { app, client } = await buildClient(); - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - method: "post", - url: `/courses`, - headers: { - ...authHeader, - }, - body: body, + const res = await coursesCreate({ + client, + headers: await getAuthHeader(app), + body: pick(buildCourse(), ["name", "description"]), }); - assert.equal(res.statusCode, 201, res.body); + assert.equal(responseOf(res).status, 201, JSON.stringify(res.error)); }); test("put courses/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const course = await app.db.query.courses.findFirst(); assert.ok(course); - const authHeader = await getAuthHeader(app, course.creatorId); - const res = await app.inject({ - method: "put", - url: `/courses/${course.id}`, - headers: { - ...authHeader, - }, + const res = await coursesUpdate({ + client, + headers: await getAuthHeader(app, course.creatorId), + path: { id: course.id }, body: pick(buildCourse(), ["name", "description"]), }); - assert.equal(res.statusCode, 200, res.body); + assert.equal(responseOf(res).status, 200, JSON.stringify(res.error)); }); test("delete courses/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const course = await app.db.query.courses.findFirst(); assert.ok(course); - const authHeader = await getAuthHeader(app, course.creatorId); - const res = await app.inject({ - method: "delete", - headers: { - ...authHeader, - }, - url: `/courses/${course.id}`, + const res = await coursesDestroy({ + client, + headers: await getAuthHeader(app, course.creatorId), + path: { id: course.id }, }); - - assert.equal(res.statusCode, 204, res.body); + assert.equal(responseOf(res).status, 204, JSON.stringify(res.error)); }); diff --git a/test/routes/api/courses/lessons.test.ts b/test/routes/api/courses/lessons.test.ts index 351f3c1..0c952d9 100644 --- a/test/routes/api/courses/lessons.test.ts +++ b/test/routes/api/courses/lessons.test.ts @@ -1,47 +1,48 @@ import { test } from "vitest"; import * as assert from "node:assert"; -import { getAuthHeader, build } from "../../../helper.ts"; +import { buildClient, getAuthHeader, responseOf } from "../../../helper.ts"; import { buildCourseLesson } from "../../../../lib/data.ts"; +import { + coursesLessonsCreate, + coursesLessonsIndex, + coursesLessonsShow, +} from "../../../../types/handlers/sdk.gen.js"; test("get lessons", async () => { - const app = await build(); + const { app, client } = await buildClient(); const lesson = await app.db.query.courseLessons.findFirst(); assert.ok(lesson); - const res = await app.inject({ - url: `/courses/${lesson.courseId}/lessons`, - }); - assert.equal(res.statusCode, 200, res.body); + const res = await coursesLessonsIndex({ client, path: { courseId: lesson.courseId } }); + assert.equal(responseOf(res).status, 200); }); test("get lessons/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const lesson = await app.db.query.courseLessons.findFirst(); assert.ok(lesson); - const res = await app.inject({ - url: `/courses/${lesson.courseId}/lessons/${lesson.id}`, + const res = await coursesLessonsShow({ + client, + path: { courseId: lesson.courseId, id: lesson.id }, }); - assert.equal(res.statusCode, 200, res.body); + assert.equal(responseOf(res).status, 200); }); test("post lessons", async () => { - const app = await build(); + const { app, client } = await buildClient(); + const course = await app.db.query.courses.findFirst(); assert.ok(course); - const body = buildCourseLesson(); - - const authHeader = await getAuthHeader(app, course.creatorId); - const res = await app.inject({ - method: "post", - url: `/courses/${course.id}/lessons`, - headers: { - ...authHeader, - }, - body: body, + const { name, body } = buildCourseLesson(); + const res = await coursesLessonsCreate({ + client, + headers: await getAuthHeader(app, course.creatorId), + path: { courseId: course.id }, + body: { name, body }, }); - assert.equal(res.statusCode, 201, res.body); + assert.equal(responseOf(res).status, 201, JSON.stringify(res.error)); }); diff --git a/test/routes/api/tokens.test.ts b/test/routes/api/tokens.test.ts index b0840cf..d014deb 100644 --- a/test/routes/api/tokens.test.ts +++ b/test/routes/api/tokens.test.ts @@ -1,71 +1,64 @@ import { test } from "vitest"; import * as assert from "node:assert"; -import { build } from "../../helper.ts"; +import { buildClient, responseOf } from "../../helper.ts"; import { DEFAULT_PASSWORD } from "../../../lib/data.ts"; +import { tokensCreate } from "../../../types/handlers/sdk.gen.js"; test("post tokens", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const res = await app.inject({ - method: "post", - url: `/tokens`, - body: { - email: user.email, - password: DEFAULT_PASSWORD, - }, + const res = await tokensCreate({ + client, + body: { email: user.email, password: DEFAULT_PASSWORD }, }); - assert.equal(res.statusCode, 201, res.body); + assert.equal(responseOf(res).status, 201, JSON.stringify(res.error)); }); test("post tokens rejects a wrong password", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const res = await app.inject({ - method: "post", - url: `/tokens`, + const res = await tokensCreate({ + client, body: { email: user.email, password: "definitely-not-the-password" }, }); - assert.equal(res.statusCode, 401, res.body); + assert.equal(responseOf(res).status, 401); }); // Неизвестный email раньше давал 500: ensure() ошибку не бросал, и обработчик // шёл дальше читать поле у undefined. test("post tokens rejects an unknown email", async () => { - const app = await build(); + const { client } = await buildClient(); - const res = await app.inject({ - method: "post", - url: `/tokens`, + const res = await tokensCreate({ + client, body: { email: "nobody@hexlet.io", password: DEFAULT_PASSWORD }, }); - assert.equal(res.statusCode, 401, res.body); + assert.equal(responseOf(res).status, 401); }); // Ответ на неизвестный email и на неверный пароль обязан совпадать, иначе по // эндпоинту можно перебирать зарегистрированные адреса. test("post tokens does not reveal whether an email is registered", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const unknown = await app.inject({ - method: "post", - url: `/tokens`, + const unknown = await tokensCreate({ + client, body: { email: "nobody@hexlet.io", password: DEFAULT_PASSWORD }, }); - const wrongPassword = await app.inject({ - method: "post", - url: `/tokens`, + const wrongPassword = await tokensCreate({ + client, body: { email: user.email, password: "definitely-not-the-password" }, }); - assert.equal(unknown.statusCode, wrongPassword.statusCode); - assert.deepStrictEqual(JSON.parse(unknown.body), JSON.parse(wrongPassword.body)); + assert.equal(responseOf(unknown).status, responseOf(wrongPassword).status); + assert.deepStrictEqual(unknown.error, wrongPassword.error); }); diff --git a/test/routes/api/users.test.ts b/test/routes/api/users.test.ts index 5f25599..7392162 100644 --- a/test/routes/api/users.test.ts +++ b/test/routes/api/users.test.ts @@ -1,104 +1,83 @@ import { test } from "vitest"; import * as assert from "node:assert"; -import { build, getAuthHeader } from "../../helper.ts"; +import { buildClient, getAuthHeader, responseOf } from "../../helper.ts"; import { buildUser } from "../../../lib/data.ts"; +import { + usersCreate, + usersDestroy, + usersIndex, + usersShow, + usersUpdate, +} from "../../../types/handlers/sdk.gen.js"; test("get users", async () => { - const app = await build(); - - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - url: "/users", - headers: { - ...authHeader, - }, - }); - assert.equal(res.statusCode, 200, res.body); + const { app, client } = await buildClient(); + + const res = await usersIndex({ client, headers: await getAuthHeader(app) }); + assert.equal(responseOf(res).status, 200); }); test("get users/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - url: `/users/${user.id}`, - headers: { - ...authHeader, - }, + const res = await usersShow({ + client, + headers: await getAuthHeader(app), + path: { id: user.id }, }); - assert.equal(res.statusCode, 200, res.body); - // assert.deepStrictEqual(JSON.parse(res.payload), { id: user.id }) + assert.equal(responseOf(res).status, 200); }); test("post users", async () => { - const app = await build(); - const body = buildUser(); - - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - method: "post", - url: `/users`, - body: body, - headers: { - ...authHeader, - }, - }); - assert.equal(res.statusCode, 201, res.body); + const { client } = await buildClient(); + + const res = await usersCreate({ client, body: buildUser() }); + assert.equal(responseOf(res).status, 201, JSON.stringify(res.error)); }); test("post users (unique email)", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - method: "post", - url: `/users`, + // Регистр не должен позволять завести дубль: правило uniqueness приводит + // адрес к нижнему регистру перед проверкой. + const res = await usersCreate({ + client, body: buildUser({ email: user.email.toUpperCase() }), - headers: { - ...authHeader, - }, }); - assert.equal(res.statusCode, 422, res.body); + assert.equal(responseOf(res).status, 422); }); -test("patch users/:id", async () => { - const app = await build(); +test("put users/:id", async () => { + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - method: "put", - url: `/users/${user.id}`, - body: buildUser(), - headers: { - ...authHeader, - }, + const res = await usersUpdate({ + client, + headers: await getAuthHeader(app), + path: { id: user.id }, + body: { fullName: buildUser().fullName }, }); - assert.equal(res.statusCode, 200, res.body); + assert.equal(responseOf(res).status, 200, JSON.stringify(res.error)); }); test("delete users/:id", async () => { - const app = await build(); + const { app, client } = await buildClient(); const user = await app.db.query.users.findFirst(); assert.ok(user); - const authHeader = await getAuthHeader(app); - const res = await app.inject({ - method: "delete", - url: `/users/${user.id}`, - headers: { - ...authHeader, - }, + const res = await usersDestroy({ + client, + headers: await getAuthHeader(app), + path: { id: user.id }, }); - - assert.equal(res.statusCode, 204, res.body); + assert.equal(responseOf(res).status, 204); }); diff --git a/types/handlers/client.gen.ts b/types/handlers/client.gen.ts new file mode 100644 index 0000000..486addf --- /dev/null +++ b/types/handlers/client.gen.ts @@ -0,0 +1,26 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { + type Client, + type ClientOptions, + type Config, + createClient, + createConfig, +} from "./client/index.js"; +import type { ClientOptions as ClientOptions2 } from "./types.gen.js"; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export const client: Client = createClient( + createConfig({ baseUrl: "http://localhost:3000" }), +); diff --git a/types/handlers/client/client.gen.ts b/types/handlers/client/client.gen.ts new file mode 100644 index 0000000..8b85a5c --- /dev/null +++ b/types/handlers/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from "../core/serverSentEvents.gen.js"; +import type { HttpMethod } from "../core/types.gen.js"; +import { getValidRequestBody } from "../core/utils.gen.js"; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js"; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from "./utils.gen.js"; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends "data" | "fields" = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === "") { + opts.headers.delete("Content-Type"); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client["request"] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: "follow", + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === "auto" + ? getParseAs(response.headers.get("Content-Type")) + : opts.parseAs) ?? "json"; + + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + let emptyData: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "text": + emptyData = await response[parseAs](); + break; + case "formData": + emptyData = new FormData(); + break; + case "stream": + emptyData = response.body; + break; + case "json": + default: + emptyData = {}; + break; + } + return opts.responseStyle === "data" + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case "arrayBuffer": + case "blob": + case "formData": + case "text": + data = await response[parseAs](); + break; + case "json": { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case "stream": + return opts.responseStyle === "data" + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === "json") { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === "data" + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === "data" + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client["buildUrl"] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn("CONNECT"), + delete: makeMethodFn("DELETE"), + get: makeMethodFn("GET"), + getConfig, + head: makeMethodFn("HEAD"), + interceptors, + options: makeMethodFn("OPTIONS"), + patch: makeMethodFn("PATCH"), + post: makeMethodFn("POST"), + put: makeMethodFn("PUT"), + request, + setConfig, + sse: { + connect: makeSseFn("CONNECT"), + delete: makeSseFn("DELETE"), + get: makeSseFn("GET"), + head: makeSseFn("HEAD"), + options: makeSseFn("OPTIONS"), + patch: makeSseFn("PATCH"), + post: makeSseFn("POST"), + put: makeSseFn("PUT"), + trace: makeSseFn("TRACE"), + }, + trace: makeMethodFn("TRACE"), + } as Client; +}; diff --git a/types/handlers/client/index.ts b/types/handlers/client/index.ts new file mode 100644 index 0000000..b11852d --- /dev/null +++ b/types/handlers/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from "../core/auth.gen.js"; +export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from "../core/bodySerializer.gen.js"; +export { buildClientParams } from "../core/params.gen.js"; +export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js"; +export type { ServerSentEventsResult } from "../core/serverSentEvents.gen.js"; +export type { ClientMeta } from "../core/types.gen.js"; +export { createClient } from "./client.gen.js"; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from "./types.gen.js"; +export { createConfig, mergeHeaders } from "./utils.gen.js"; diff --git a/types/handlers/client/types.gen.ts b/types/handlers/client/types.gen.ts new file mode 100644 index 0000000..346caa0 --- /dev/null +++ b/types/handlers/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from "../core/auth.gen.js"; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from "../core/serverSentEvents.gen.js"; +import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js"; +import type { Middleware } from "./utils.gen.js"; + +export type ResponseStyle = "data" | "fields"; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T["baseUrl"]; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text"; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T["throwOnError"]; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | "onRequest" + | "onSseError" + | "onSseEvent" + | "sseDefaultRetryDelay" + | "sseMaxRetryAttempts" + | "sseMaxRetryDelay" + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = "fields", + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = "fields", +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends "data" + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends "data" + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method">, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = "fields", +>( + options: Omit, "method"> & + Pick>, "method">, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = "fields", +> = OmitKeys< + RequestOptions, + "body" | "path" | "query" | "url" +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/types/handlers/client/utils.gen.ts b/types/handlers/client/utils.gen.ts new file mode 100644 index 0000000..471a3cd --- /dev/null +++ b/types/handlers/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from "../core/auth.gen.js"; +import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"; +import { jsonBodySerializer } from "../core/bodySerializer.gen.js"; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "../core/pathSerializer.gen.js"; +import { getUrl } from "../core/utils.gen.js"; +import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js"; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === "object") { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "form", + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === "object") { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: "deepObject", + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join("&"); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return "stream"; + } + + const cleanContent = contentType.split(";")[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) { + return "json"; + } + + if (cleanContent === "multipart/form-data") { + return "formData"; + } + + if ( + ["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type)) + ) { + return "blob"; + } + + if (cleanContent.startsWith("text/")) { + return "text"; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get("Cookie")?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? "Authorization"; + + switch (auth.in) { + case "query": + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case "cookie": + options.headers.append("Cookie", `${name}=${token}`); + break; + case "header": + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client["buildUrl"] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === "function" + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith("/")) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array["headers"] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === "object" ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === "number") { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: "form", + }, + object: { + explode: true, + style: "deepObject", + }, +}); + +const defaultHeaders = { + "Content-Type": "application/json", +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: "auto", + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/types/handlers/core/auth.gen.ts b/types/handlers/core/auth.gen.ts new file mode 100644 index 0000000..9f3a7cd --- /dev/null +++ b/types/handlers/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie"; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: "basic" | "bearer"; + type: "apiKey" | "http"; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === "function" ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}`; + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/types/handlers/core/bodySerializer.gen.ts b/types/handlers/core/bodySerializer.gen.ts new file mode 100644 index 0000000..569a27f --- /dev/null +++ b/types/handlers/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js"; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === "string") { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/types/handlers/core/params.gen.ts b/types/handlers/core/params.gen.ts new file mode 100644 index 0000000..38d634c --- /dev/null +++ b/types/handlers/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query"; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ("key" in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === "body") continue; + if (value && typeof value === "object" && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ("allowExtra" in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/types/handlers/core/pathSerializer.gen.ts b/types/handlers/core/pathSerializer.gen.ts new file mode 100644 index 0000000..764a3ab --- /dev/null +++ b/types/handlers/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = "label" | "matrix" | "simple"; +export type ObjectStyle = "form" | "deepObject"; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): "." | ";" | "," | "&" => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): "," | "|" | "%20" => { + switch (style) { + case "form": + return ","; + case "pipeDelimited": + return "|"; + case "spaceDelimited": + return "%20"; + default: + return ","; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): "." | ";" | "," | "&" => { + switch (style) { + case "label": + return "."; + case "matrix": + return ";"; + case "simple": + return ","; + default: + return "&"; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + case "simple": + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ""; + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== "deepObject" && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(","); + switch (style) { + case "form": + return `${name}=${joinedValues}`; + case "label": + return `.${joinedValues}`; + case "matrix": + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues; +}; diff --git a/types/handlers/core/queryKeySerializer.gen.ts b/types/handlers/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..13bb9f6 --- /dev/null +++ b/types/handlers/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/types/handlers/core/serverSentEvents.gen.ts b/types/handlers/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..b0168e5 --- /dev/null +++ b/types/handlers/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js"; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit["body"]; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: "follow", + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error("No body in SSE response"); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ""; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener("abort", abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, "\n"); // normalize line endings + + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + + for (const chunk of chunks) { + const lines = chunk.split("\n"); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")); + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, ""); + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, ""); + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join("\n"); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/types/handlers/core/types.gen.ts b/types/handlers/core/types.gen.ts new file mode 100644 index 0000000..c73393a --- /dev/null +++ b/types/handlers/core/types.gen.ts @@ -0,0 +1,114 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js"; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from "./bodySerializer.gen.js"; + +export type HttpMethod = + | "connect" + | "delete" + | "get" + | "head" + | "options" + | "patch" + | "post" + | "put" + | "trace"; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/types/handlers/core/utils.gen.ts b/types/handlers/core/utils.gen.ts new file mode 100644 index 0000000..80cffa4 --- /dev/null +++ b/types/handlers/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js"; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js"; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = "simple"; + + if (name.endsWith("*")) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith(".")) { + name = name.substring(1); + style = "label"; + } else if (name.startsWith(";")) { + name = name.substring(1); + style = "matrix"; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === "label" ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}`; + let url = (baseUrl ?? "") + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ""; + if (search.startsWith("?")) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ("serializedBody" in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ""; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== "" ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/types/handlers/index.ts b/types/handlers/index.ts index 1da1d4c..1fe369a 100644 --- a/types/handlers/index.ts +++ b/types/handlers/index.ts @@ -1,5 +1,22 @@ // This file is auto-generated by @hey-api/openapi-ts +export { + coursesCreate, + coursesDestroy, + coursesIndex, + coursesLessonsCreate, + coursesLessonsIndex, + coursesLessonsShow, + coursesShow, + coursesUpdate, + type Options, + tokensCreate, + usersCreate, + usersDestroy, + usersIndex, + usersShow, + usersUpdate, +} from "./sdk.gen.js"; export type { AuthInfo, BadRequestError, diff --git a/types/handlers/sdk.gen.ts b/types/handlers/sdk.gen.ts new file mode 100644 index 0000000..88aeab5 --- /dev/null +++ b/types/handlers/sdk.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { client } from "./client.gen.js"; +import type { + Client, + ClientMeta, + Options as Options2, + RequestResult, + TDataShape, +} from "./client/index.js"; +import type { + CoursesCreateData, + CoursesCreateErrors, + CoursesCreateResponses, + CoursesDestroyData, + CoursesDestroyErrors, + CoursesDestroyResponses, + CoursesIndexData, + CoursesIndexErrors, + CoursesIndexResponses, + CoursesLessonsCreateData, + CoursesLessonsCreateErrors, + CoursesLessonsCreateResponses, + CoursesLessonsIndexData, + CoursesLessonsIndexErrors, + CoursesLessonsIndexResponses, + CoursesLessonsShowData, + CoursesLessonsShowErrors, + CoursesLessonsShowResponses, + CoursesShowData, + CoursesShowErrors, + CoursesShowResponses, + CoursesUpdateData, + CoursesUpdateErrors, + CoursesUpdateResponses, + TokensCreateData, + TokensCreateErrors, + TokensCreateResponses, + UsersCreateData, + UsersCreateErrors, + UsersCreateResponses, + UsersDestroyData, + UsersDestroyErrors, + UsersDestroyResponses, + UsersIndexData, + UsersIndexErrors, + UsersIndexResponses, + UsersShowData, + UsersShowErrors, + UsersShowResponses, + UsersUpdateData, + UsersUpdateErrors, + UsersUpdateResponses, +} from "./types.gen.js"; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, +> = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Список курсов + */ +export const coursesIndex = ( + options?: Options, +): RequestResult => + (options?.client ?? client).get({ + url: "/courses", + ...options, + }); + +/** + * Создание курса + */ +export const coursesCreate = ( + options: Options, +): RequestResult => + (options.client ?? client).post({ + security: [{ scheme: "bearer", type: "http" }], + url: "/courses", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Список уроков курса + */ +export const coursesLessonsIndex = ( + options: Options, +): RequestResult => + (options.client ?? client).get< + CoursesLessonsIndexResponses, + CoursesLessonsIndexErrors, + ThrowOnError + >({ url: "/courses/{courseId}/lessons", ...options }); + +/** + * Добавление урока в курс + */ +export const coursesLessonsCreate = ( + options: Options, +): RequestResult => + (options.client ?? client).post< + CoursesLessonsCreateResponses, + CoursesLessonsCreateErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/courses/{courseId}/lessons", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Урок курса + */ +export const coursesLessonsShow = ( + options: Options, +): RequestResult => + (options.client ?? client).get< + CoursesLessonsShowResponses, + CoursesLessonsShowErrors, + ThrowOnError + >({ url: "/courses/{courseId}/lessons/{id}", ...options }); + +/** + * Удаление курса + */ +export const coursesDestroy = ( + options: Options, +): RequestResult => + (options.client ?? client).delete({ + security: [{ scheme: "bearer", type: "http" }], + url: "/courses/{id}", + ...options, + }); + +/** + * Курс по идентификатору + */ +export const coursesShow = ( + options: Options, +): RequestResult => + (options.client ?? client).get({ + url: "/courses/{id}", + ...options, + }); + +/** + * Изменение курса + */ +export const coursesUpdate = ( + options: Options, +): RequestResult => + (options.client ?? client).put({ + security: [{ scheme: "bearer", type: "http" }], + url: "/courses/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Выдача токена по email и паролю + */ +export const tokensCreate = ( + options: Options, +): RequestResult => + (options.client ?? client).post({ + url: "/tokens", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Список пользователей + */ +export const usersIndex = ( + options?: Options, +): RequestResult => + (options?.client ?? client).get({ + security: [{ scheme: "bearer", type: "http" }], + url: "/users", + ...options, + }); + +/** + * Регистрация пользователя + */ +export const usersCreate = ( + options: Options, +): RequestResult => + (options.client ?? client).post({ + url: "/users", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + +/** + * Удаление пользователя + */ +export const usersDestroy = ( + options: Options, +): RequestResult => + (options.client ?? client).delete({ + security: [{ scheme: "bearer", type: "http" }], + url: "/users/{id}", + ...options, + }); + +/** + * Пользователь по идентификатору + */ +export const usersShow = ( + options: Options, +): RequestResult => + (options.client ?? client).get({ + security: [{ scheme: "bearer", type: "http" }], + url: "/users/{id}", + ...options, + }); + +/** + * Изменение пользователя + */ +export const usersUpdate = ( + options: Options, +): RequestResult => + (options.client ?? client).put({ + security: [{ scheme: "bearer", type: "http" }], + url: "/users/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); From 3c6252a6848063e28017c46d6b17f1e5a8ec37e9 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 21:12:14 -0400 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20README=20=D0=B8=20AGENTS.md=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба описывали состояние до правок: app.js вместо app.ts, node --test вместо vitest, совет вынести JWT-секрет в окружение, которого уже нет в коде. Не хватало половины целей Makefile, шага с .env и правила, по которому тесты разделены между сгенерированным клиентом и app.inject(). Заодно сужен glob форматтера в lefthook: oxfmt форматирует только ts/js и падает, если в наборе staged-файлов подходящих не осталось — коммит из одних markdown-файлов не проходил. --- AGENTS.md | 104 +++++++++++++++++++++++---------------------------- README.md | 15 ++++++++ lefthook.yml | 4 +- 3 files changed, 65 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e514666..685c8ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,75 +2,65 @@ ## Project Structure & Module Organization -- `app.ts`: Fastify entry. Autoloads `plugins/`, then registers - `fastify-openapi-glue` with the generated OpenAPI spec and the handler map - from `routes/index.ts`. Routes are **not** autoloaded — the route table comes - from the spec, so a route exists only if it is described in `main.tsp`. -- `main.tsp`, `tsp-output/`: TypeSpec contract (source of truth) and the OpenAPI - it emits. Versions `v1` and `v2` are declared; code and codegen use `v1`. -- `routes/api/`: handler modules grouped by resource (`users.ts`, `courses.ts`, - `courses/lessons.ts`, `tokens.ts`), each wrapped in `defineHandlers`. - `routes/index.ts` merges them into `RouteHandlers` — the full generated type, - not `Partial`, so a missing handler is a compile error. -- `plugins/`: JWT auth, Drizzle (in-memory SQLite, migrated and seeded on boot), - response validation, `@fastify/sensible`. -- `db/`: Drizzle schema and seeds; generated migrations live in `drizzle/`. -- `validators/`, `rules/`: business validation (zod, built on the generated - schemas), kept out of handlers. -- `serializers/`, `policies/`, `lib/`: response shaping, authorization, - shared helpers. -- `types/`: `fastify.d.ts` (hand-written decorator typings) and - `types/handlers/*.gen.ts` — **generated, never edit by hand**. -- `test/`: Vitest specs in `test/routes/`, mirroring `routes/`; server bootstrap - in `test/helper.ts`. +- `app.ts`: Fastify entry; автозагружает `plugins/`, а маршруты регистрирует + `fastify-openapi-glue` по спеке. Здесь же обработчик ошибок (RFC 9457) и + securityHandlers. +- `main.tsp`, `tsp-output/`: контракт на TypeSpec и сгенерированный из него + OpenAPI. Источник истины для всего остального. +- `types/handlers/`: сгенерированное из OpenAPI — типы обработчиков, zod-схемы + и клиент. Руками не правится. +- `routes/`: обработчики; `routes/index.ts` собирает их в полный + `RouteHandlers`, поэтому забытая операция — ошибка компиляции. +- `plugins/`: конфиг (`env`), база, JWT, security-плагины, документация. +- `db/`: схема Drizzle, сиды и публичные проекции; `drizzle/` — миграции. +- `validators/`, `rules/`, `policies/`: бизнес-валидация, правила уровня базы, + права доступа. +- `lib/`: утилиты, хеширование паролей, фабрики тестовых данных. +- `test/`: спеки; бутстрап в `test/helper.ts`. +- `scripts/`: contract-test.sh. ## Build, Test, and Development Commands -Package manager is pnpm (`packageManager` in `package.json`); Node >= 26. - -- `make install`: install dependencies. -- `make dev`: Fastify with watch on http://localhost:3000. -- `make test`: Vitest run. -- `make lint`: oxlint + `tsc` + format check. `make lint-fix` autofixes. -- `make check-types`: `tsc` only (same check `make lint` runs). -- `make routes`: print the route table registered from the spec. -- `make generate-types`: TypeSpec → OpenAPI → handler types and zod schemas, - then format. `make generate-check` (CI) fails if the result is not committed. -- `make migration-generate`: Drizzle migration from the changed schema. -- `make mock`: Prism mock server from the generated OpenAPI. +- `make install` — установка. +- `make dev` — запуск с перезагрузкой на http://localhost:3000. Требует `.env` + (см. `.env.example`). +- `make test` / `make test-coverage` — vitest, второй с порогами покрытия. +- `make lint` / `make lint-fix` — oxlint, tsc, формат и линт спеки. +- `make generate-types` — OpenAPI из TypeSpec и всё сгенерированное из него. +- `make generate-check` — сгенерированное не разошлось со спекой. +- `make migration-generate` / `make migration-check` — миграции и проверка, что + схема не менялась без миграции. +- `make contract-test` — schemathesis по спеке (нужен uv). +- `make mock` — мок-сервер по OpenAPI. ## Coding Style & Naming Conventions -- **TypeScript only**, ESM (`type: module`). Node executes `.ts` directly — no - build step, no bundler. -- **Local imports carry the explicit `.ts` extension** (`allowImportingTsExtensions` - with `NodeNext`): `import users from "./api/users.ts"`. -- `tsconfig.json` sets `noEmit: true` — `tsc` type-checks, it never emits. -- **Formatting** by oxfmt: 2-space indent, semicolons, double quotes. Linting by - oxlint (`.oxlintrc.json`). Run `make lint` before committing. -- Changing the API means editing `main.tsp` first, then `make generate-types`, - then the handler. Never patch `tsp-output/` or `types/handlers/` directly. +- **Модули**: ESM (`type: module`), TypeScript, импорты с расширением `.ts`. +- **Формат**: oxfmt, линт oxlint. `make lint` перед коммитом; lefthook гоняет + формат и линт по staged-файлам автоматически. +- **Контракт первичен**: новое поле, статус или операция сначала появляются в + `main.tsp`, потом `make generate-types`, потом код. Обратный порядок ловится + в CI. ## Testing Guidelines -- **Framework**: Vitest with `app.inject()`; see `test/helper.ts`. -- **Naming**: specs go to `test/routes/**/*.test.ts`. `vitest.config.ts` includes - only `*.test.ts` — a `.test.js` file is silently skipped. -- **Scope**: add success tests for each new/changed route. No coverage gate. +- **Фреймворк**: vitest. Спеки в `test/**/*.test.ts`. +- **Клиент**: корректные запросы — через сгенерированный клиент + (`buildClient()`), нарушающие контракт — через `app.inject()` (`build()`): + клиент типизирован по спеке и выразить их не даёт. +- **Покрытие**: пороги в `vitest.config.ts`, проверяются в CI. +- **Контрактные тесты**: `make contract-test` генерирует запросы из OpenAPI — + им найдены почти все 5xx, которые здесь исправлены. ## Commit & Pull Request Guidelines -- **Commits**: Conventional Commits, imperative mood (this repo writes them in - Russian). Reference issues when applicable. -- **PRs**: the PR title must be a Conventional Commit — it becomes the squash - commit message and CI checks it. Provide purpose, summary, test plan, and - example requests/responses. Keep diffs focused. +- **Коммиты**: conventional commits. +- **PR**: заголовок обязан быть conventional commit — по нему release-please + определяет разряд версии (проверяется в `pr-title.yml`). ## Security & Configuration Tips -- **Secrets**: the JWT secret is hardcoded in `plugins/jwt.ts` for teaching - purposes. Move it to an env var (`JWT_SECRET`) before any real deployment; - use `.env` locally and never commit it. -- **DB**: SQLite in memory (`plugins/drizzle.ts`) — the database is recreated, - migrated, and seeded on every boot. Switch to a file or a real server for - persistence. +- **Секреты**: конфиг проверяется схемой в `plugins/env.ts`. Без `JWT_SECRET` + от 32 символов приложение не поднимается. `.env` не коммитится. +- **База**: in-memory SQLite (`plugins/drizzle.ts`), пересоздаётся при каждом + запуске. Для постоянного хранения нужен файл или настоящая СУБД. diff --git a/README.md b/README.md index 55d5c39..a247ee4 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,37 @@ REST API на [Fastify](https://fastify.dev/), собранный «по-взр - **База через [Drizzle](https://orm.drizzle.team/)**: схема в `db/schema.ts`, миграции генерируются по ней. - **Валидация входа** отдельным слоем в `validators/`, а не внутри обработчика. +- **Авторизация тоже из спеки.** `@useAuth` в контракте применяет + `fastify-openapi-glue` через securityHandlers, а не `jwtVerify()` в каждом + обработчике — забыть его негде. +- **Спека проверяется снаружи.** `make contract-test` натравливает + [schemathesis](https://schemathesis.readthedocs.io/) на поднятое приложение: + тот генерирует запросы из OpenAPI и ловит то, что не видят ни tsc, ни + валидаторы — незадокументированные статусы, 5xx на краевых входах и + неприменённую авторизацию. ## Запуск ```bash make install +cp .env.example .env # и подставить JWT_SECRET make dev make test ``` +Документация — на http://localhost:3000/docs, сама спека — на `/openapi.json`. + Полезное: ```bash make routes # список маршрутов make migration-generate # миграция по изменённой схеме +make migration-check # схема не менялась без миграции make generate-types # OpenAPI и типы из TypeSpec make generate-check # проверить, что сгенерированное закоммичено +make lint-openapi # линт контракта +make contract-test # schemathesis по спеке (нужен uv) +make test-coverage # тесты с порогами покрытия make mock # поднять мок-сервер по OpenAPI ``` diff --git a/lefthook.yml b/lefthook.yml index 0c92d09..14764f1 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -5,8 +5,10 @@ pre-commit: parallel: true jobs: + # Только ts/js: oxfmt другого не форматирует и падает, если в наборе не + # осталось подходящих файлов. - name: format - glob: "*.{ts,js,json,md,yml,yaml}" + glob: "*.{ts,js}" run: pnpm exec oxfmt --ignore-path=.oxfmtignore {staged_files} # Отформатированное возвращается в индекс, иначе коммит уедет в старом # виде и format:check в CI всё равно упадёт. From f7ce1fddb18f08de3ce9d1169aed8983a5a5abd4 Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sat, 22 Aug 2026 21:14:02 -0400 Subject: [PATCH 13/14] =?UTF-8?q?docs:=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=BB=D0=B0=D0=B3=D0=B0=D1=82=D1=8C=20cp=20.env.example?= =?UTF-8?q?=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У всех, у кого .env уже есть, эта команда затирает его содержимое. Добавление строки безопаснее и решает ту же задачу. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a247ee4..1e78ad0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ REST API на [Fastify](https://fastify.dev/), собранный «по-взр ```bash make install -cp .env.example .env # и подставить JWT_SECRET +echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env # см. .env.example make dev make test ``` From e310b8bfb85c8038fc0713ed069a3becadf6507c Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sun, 23 Aug 2026 19:02:13 -0400 Subject: [PATCH 14/14] =?UTF-8?q?fix(ci):=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20oasdiff=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D1=83=D1=8E=20=D1=81=D0=BF=D0=B5=D0=BA=D1=83=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20workspace,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20/tmp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг был зелёным только из-за continue-on-error, а на деле падал с «failed to load base spec: /tmp/openapi.base.json: no such file or directory». oasdiff запускается docker-действием, и внутрь контейнера монтируется только workspace — файл из /tmp раннера там не виден. Заодно test -s: без него редирект создавал пустой файл даже при неудачном git show, и fallback не срабатывал. --- .github/workflows/openapi-diff.yml | 11 ++++++++--- .gitignore | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 0a81871..3f981ab 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -20,15 +20,20 @@ jobs: with: fetch-depth: 0 + # Файл кладётся в рабочую копию, а не в /tmp: oasdiff запускается + # docker-действием, и внутрь контейнера монтируется только workspace — + # из /tmp он базовую спеку не увидит. - name: Extract the base version of the spec run: | + mkdir -p .oasdiff git show "origin/${{ github.base_ref }}:tsp-output/@typespec/openapi3/openapi.v1.json" \ - > /tmp/openapi.base.json 2>/dev/null \ - || cp tsp-output/@typespec/openapi3/openapi.v1.json /tmp/openapi.base.json + > .oasdiff/base.json \ + || cp tsp-output/@typespec/openapi3/openapi.v1.json .oasdiff/base.json + test -s .oasdiff/base.json - name: Report breaking changes continue-on-error: true uses: oasdiff/oasdiff-action/breaking@main with: - base: /tmp/openapi.base.json + base: .oasdiff/base.json revision: tsp-output/@typespec/openapi3/openapi.v1.json diff --git a/.gitignore b/.gitignore index aefa92a..4b97b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ profile* # кеш schemathesis (make contract-test) .schemathesis + +# базовая спека для oasdiff в CI +.oasdiff