From 8eaf00690653b6f57adf1c38284aaba3b2588751 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 8 Apr 2026 11:59:46 +0300 Subject: [PATCH] refactor(api): unify /api contract across frontend, nginx, and backend --- .env.example | 3 +- Dockerfile.frontend | 2 -- README.md | 15 ++++---- backend/package.json | 2 +- backend/src/app.ts | 3 -- backend/test/app.test.ts | 38 ++++++--------------- docker-compose.stack.yml | 2 -- docker/nginx.frontend.conf | 3 +- docs/backend-api-for-frontend.md | 31 ++++++++--------- docs/backend.md | 10 +++--- frontend/.env.example | 4 +-- frontend/package.json | 2 +- frontend/src/api/errors.ts | 6 ++-- frontend/src/api/http.ts | 16 ++++++--- frontend/src/app/layouts/AppShellFooter.tsx | 18 ++++++++-- frontend/src/lib/backendVersionCache.ts | 21 ++++++++++++ frontend/vite.config.ts | 8 +++++ 17 files changed, 103 insertions(+), 81 deletions(-) create mode 100644 frontend/src/lib/backendVersionCache.ts diff --git a/.env.example b/.env.example index ec216bd..38cceb8 100644 --- a/.env.example +++ b/.env.example @@ -35,5 +35,4 @@ CORS_ORIGIN=http://localhost:5173 # APP_VERSION=1.0.0 # ─── Frontend (Vite, локально из каталога frontend/) ───────── -# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env. -VITE_API_BASE_URL=http://localhost:3001 +# Браузер всегда ходит на относительный /api; в dev это проксирует Vite. diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 526f68a..c614298 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -5,8 +5,6 @@ WORKDIR /app COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ -ARG VITE_API_BASE_URL=/api -ENV VITE_API_BASE_URL=$VITE_API_BASE_URL RUN npm run build FROM nginx:alpine diff --git a/README.md b/README.md index cf2f936..ee51aac 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,20 @@ ## Переменные окружения -Один шаблон для локальной разработки и для Docker-стека: **[`.env.example`](.env.example)** → скопируйте в **`.env`** в корне репозитория. +Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория. -Там же перечислены **`DB_HOST`**, **`DB_PORT`**, **`DB_NAME`**, **`DB_USER`**, **`DB_PASSWORD`** (подключение бэкенда к БД), **`PORT`** / **`API_PORT`**, опционально **`CALENDAR_RUN_MOCK_DB`**, **`CORS_ORIGIN`**, а для локального Vite — **`VITE_API_BASE_URL`**. +Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**`, опционально `**CALENDAR_RUN_MOCK_DB**` и `**CORS_ORIGIN**`. ## Backend — локально 1. `cd backend && npm install` 2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`). -3. Postgres: из корня `docker compose up -d` (см. [`docker-compose.yml`](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`. +3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`. 4. `cd backend && npm run db:migrate && npm run seed` 5. Dev-режим: `npm run dev` 6. Или production: `npm run build && npm start` -Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; **`db:migrate` и `seed` с mock не использовать**. +Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; `**db:migrate` и `seed` с mock не использовать**. ## Frontend — локально @@ -28,11 +28,11 @@ npm install npm run dev ``` -Значение `VITE_API_BASE_URL` см. в **корневом** [`.env.example`](.env.example); для dev по умолчанию `http://localhost:3001`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`). +Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на backend. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`). ## Docker: backend + frontend рядом с Postgres -Используйте [`docker-compose.stack.yml`](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть **`.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и **`CORS_ORIGIN=http://localhost:3033`**, если заходите на фронт с хоста на порту 3033. +Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033. ```bash docker compose -f docker-compose.stack.yml up -d --build @@ -40,9 +40,10 @@ docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js docker compose -f docker-compose.stack.yml exec backend node dist/seed.js ``` -Фронт в браузере обращается к API по префиксу **`/api`** (nginx в образе фронта проксирует на backend). +Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на backend). ## Документация API и бэкенда - [Шпаргалка API для фронта](docs/backend-api-for-frontend.md) - [Эксплуатация backend](docs/backend.md) + diff --git a/backend/package.json b/backend/package.json index 642ef78..d07769d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.1.0", + "version": "1.2.0", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/app.ts b/backend/src/app.ts index de1b070..484c433 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -12,9 +12,6 @@ export function createApp(): express.Express { ); app.use(express.json()); - app.use(healthRouter); - app.use(racesRouter); - // Тот же API под /api/* — если прокси не снимает префикс или запрос идёт напрямую на порт бэкенда с /api. app.use("/api", healthRouter); app.use("/api", racesRouter); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 694b1aa..6878936 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -5,59 +5,43 @@ import { createApp } from "../src/app"; const app = createApp(); -test("GET /health returns ok", async () => { - const res = await request(app).get("/health").expect(200); - assert.equal(res.body.status, "ok"); - assert.equal(typeof res.body.version, "string"); - assert.ok(res.body.version.length > 0); -}); - -test("GET /api/health returns ok (prefix without proxy strip)", async () => { +test("GET /api/health returns ok", async () => { const res = await request(app).get("/api/health").expect(200); assert.equal(res.body.status, "ok"); -}); - -test("GET /meta returns version for UI footer", async () => { - const res = await request(app).get("/meta").expect(200); assert.equal(typeof res.body.version, "string"); assert.ok(res.body.version.length > 0); }); -test("GET /api/meta mirrors GET /meta", async () => { +test("GET /api/meta returns version for UI footer", async () => { const res = await request(app).get("/api/meta").expect(200); assert.equal(typeof res.body.version, "string"); assert.ok(res.body.version.length > 0); }); -test("GET /ready succeeds with mock database", async () => { - const res = await request(app).get("/ready").expect(200); +test("GET /api/ready succeeds with mock database", async () => { + const res = await request(app).get("/api/ready").expect(200); assert.equal(res.body.status, "ready"); assert.equal(res.body.db, "connected"); }); -test("GET /races rejects invalid year", async () => { - const res = await request(app).get("/races?year=bad").expect(400); +test("GET /api/races rejects invalid year", async () => { + const res = await request(app).get("/api/races?year=bad").expect(400); assert.equal(res.body.error, "validation_error"); assert.ok(Array.isArray(res.body.details)); }); -test("GET /races rejects month out of range", async () => { - const res = await request(app).get("/races?month=13").expect(400); +test("GET /api/races rejects month out of range", async () => { + const res = await request(app).get("/api/races?month=13").expect(400); assert.equal(res.body.error, "validation_error"); }); -test("GET /races accepts year and month", async () => { - const res = await request(app).get("/races?year=2026&month=5").expect(200); - assert.ok(Array.isArray(res.body)); -}); - -test("GET /api/races mirrors GET /races", async () => { +test("GET /api/races accepts year and month", async () => { const res = await request(app).get("/api/races?year=2026&month=5").expect(200); assert.ok(Array.isArray(res.body)); }); -test("GET /races/:id returns not_found", async () => { - const res = await request(app).get("/races/does-not-exist").expect(404); +test("GET /api/races/:id returns not_found", async () => { + const res = await request(app).get("/api/races/does-not-exist").expect(404); assert.equal(res.body.error, "not_found"); assert.ok(Array.isArray(res.body.details)); }); diff --git a/docker-compose.stack.yml b/docker-compose.stack.yml index 2d4bd74..43d110c 100644 --- a/docker-compose.stack.yml +++ b/docker-compose.stack.yml @@ -38,8 +38,6 @@ services: build: context: . dockerfile: Dockerfile.frontend - args: - VITE_API_BASE_URL: /api container_name: runners-calendar-frontend depends_on: - backend diff --git a/docker/nginx.frontend.conf b/docker/nginx.frontend.conf index 9831e27..aff7c21 100644 --- a/docker/nginx.frontend.conf +++ b/docker/nginx.frontend.conf @@ -8,9 +8,8 @@ server { try_files $uri $uri/ /index.html; } - # Браузер ходит на тот же origin: /api/* → бэкенд без префикса /api + # Браузер ходит на тот же origin: /api/* → бэкенд с тем же префиксом /api location /api/ { - rewrite ^/api/(.*) /$1 break; proxy_pass http://backend:3000; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 2c25dee..92d7d96 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -2,13 +2,10 @@ ## 1. Base URL -``` -VITE_API_BASE_URL=http://localhost:3001 -``` +SPA всегда отправляет запросы на относительный префикс `/api` текущего origin. -В коде SPA: `import.meta.env.VITE_API_BASE_URL`. - -В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`). +- В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`. +- В Docker/проде: nginx фронта проксирует `/api/*` на backend в той же сети. ## 2. CORS @@ -22,7 +19,7 @@ CORS_ORIGIN=http://localhost:5173 ## 3. Эндпоинты -### `GET /health` +### `GET /api/health` Liveness-проверка (без обращения к БД). @@ -34,7 +31,7 @@ Liveness-проверка (без обращения к БД). --- -### `GET /ready` +### `GET /api/ready` Readiness-проверка (проверяет подключение к БД). @@ -52,7 +49,7 @@ Readiness-проверка (проверяет подключение к БД). --- -### `GET /races` +### `GET /api/races` Список забегов, отсортированных по дате. @@ -70,7 +67,7 @@ Readiness-проверка (проверяет подключение к БД). **Пример запроса:** ``` -GET /races?year=2026&month=5 +GET /api/races?year=2026&month=5 ``` **Ответ 200:** @@ -99,7 +96,7 @@ GET /races?year=2026&month=5 --- -### `GET /races/:id` +### `GET /api/races/:id` Одна запись по `id`. @@ -113,7 +110,7 @@ GET /races?year=2026&month=5 --- -### `POST /races` +### `POST /api/races` Создание забега. @@ -155,7 +152,7 @@ GET /races?year=2026&month=5 --- -### `PATCH /races/:id` +### `PATCH /api/races/:id` Частичное обновление — передавать **только** изменяемые поля. @@ -188,7 +185,7 @@ GET /races?year=2026&month=5 --- -### `DELETE /races/:id` +### `DELETE /api/races/:id` Удаление забега. @@ -220,7 +217,7 @@ GET /races?year=2026&month=5 | `createdAt` | string | — | — | ISO timestamp (read-only) | | `updatedAt` | string \| null | — | — | ISO timestamp (read-only) | -## 5. Фильтрация списка (`GET /races`) +## 5. Фильтрация списка (`GET /api/races`) - **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`. - **`month`** — целое число 1–12, фильтрует по `EXTRACT(MONTH FROM race_date)`. @@ -234,7 +231,7 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** ## 7. Поведение при недоступной БД -- `GET /health` — всегда `200` (не проверяет БД). -- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`. +- `GET /api/health` — всегда `200` (не проверяет БД). +- `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`. - Все остальные маршруты — `503 { "error": "database_unavailable" }`. - В логах сервера: строка ошибки с контекстом маршрута. diff --git a/docs/backend.md b/docs/backend.md index 709186a..f0799f3 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -78,7 +78,7 @@ API слушает порт: **`PORT`** (если задан), иначе **`API | `API_PORT` | Порт API-сервера | `3001` | | `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` | -Для локального Vite в корневом `.env.example` также указан **`VITE_API_BASE_URL`** (читает только фронт из `frontend/`). В Docker-стеке базовый URL API задаётся при **сборке** образа фронта (`/api`), не через этот файл. +Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на backend. **Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: `. @@ -87,8 +87,8 @@ API слушает порт: **`PORT`** (если задан), иначе **`API ## Поведение при недоступной БД - **Старт сервера** — проходит успешно (env валидирован, Express слушает порт). -- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД). -- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API). +- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД). +- **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API). - **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`. ## Структура каталога @@ -108,8 +108,8 @@ backend/ │ ├── mappers/ │ │ └── race.ts # snake_case ↔ camelCase │ └── routes/ -│ ├── health.ts # /health, /ready -│ └── races.ts # CRUD /races +│ ├── health.ts # /api/health, /api/ready, /api/meta +│ └── races.ts # CRUD /api/races ├── package.json └── tsconfig.json ``` diff --git a/frontend/.env.example b/frontend/.env.example index a241963..9fdf8ec 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,2 @@ -# Для локального npm run dev. Полный список переменных — в корневом .env.example репозитория. -VITE_API_BASE_URL=http://localhost:3001 +# Для локального npm run dev дополнительных VITE-переменных не требуется. +# Полный список переменных окружения — в корневом .env.example репозитория. diff --git a/frontend/package.json b/frontend/package.json index 2919e45..b739d94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index 69e3b85..a89a9eb 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -47,7 +47,7 @@ function isGatewayStatus(status: number): boolean { return status === 502 || status === 503 || status === 504; } -function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload { +export function isStructuredApiErrorPayload(payload: unknown): payload is ApiErrorPayload { if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { return false; } @@ -55,7 +55,7 @@ function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload { } export function toApiError(status: number, payload: unknown): ApiError { - if (isGatewayStatus(status) && !hasStructuredApiError(payload)) { + if (isGatewayStatus(status) && !isStructuredApiErrorPayload(payload)) { return new ApiError({ code: "network_error", status, @@ -63,7 +63,7 @@ export function toApiError(status: number, payload: unknown): ApiError { }); } - if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) { + if (!isStructuredApiErrorPayload(payload) && (status === 401 || status === 403 || status === 404)) { return new ApiError({ code: "network_error", status, diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 27b1b13..35bbe22 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,10 +1,10 @@ -import { ApiError, toApiError } from "./errors"; +import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors"; -const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001"; +const API_ROOT = "/api"; function buildUrl(path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; - return `${API_BASE_URL}${normalizedPath}`; + return `${API_ROOT}${normalizedPath}`; } async function parseResponseBody(response: Response): Promise { @@ -36,6 +36,14 @@ async function parseResponseBody(response: Response): Promise { const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]); +/** Повтор при «пустом» 404: часто бывает при нескольких инстансах/прокси до полного деплоя. */ +function shouldRetryIdempotentError(status: number, payload: unknown): boolean { + if (GATEWAY_RETRY_STATUSES.has(status)) { + return true; + } + return status === 404 && !isStructuredApiErrorPayload(payload); +} + function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -69,7 +77,7 @@ export async function requestJson(path: string, init?: RequestInit): Promise< const payload = await parseResponseBody(response); if (!response.ok) { - const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts; + const retryable = idempotent && attempt < maxAttempts && shouldRetryIdempotentError(response.status, payload); if (retryable) { await delay(80 * attempt); continue; diff --git a/frontend/src/app/layouts/AppShellFooter.tsx b/frontend/src/app/layouts/AppShellFooter.tsx index 38858e9..58c744f 100644 --- a/frontend/src/app/layouts/AppShellFooter.tsx +++ b/frontend/src/app/layouts/AppShellFooter.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { getBackendMeta } from "../../api"; import { FRONTEND_VERSION } from "../../frontendVersion"; +import { readCachedBackendVersion, writeCachedBackendVersion } from "../../lib/backendVersionCache"; function isAbortError(error: unknown): boolean { return ( @@ -10,7 +11,7 @@ function isAbortError(error: unknown): boolean { } export function AppShellFooter(): JSX.Element { - const [backendVersion, setBackendVersion] = useState(null); + const [backendVersion, setBackendVersion] = useState(() => readCachedBackendVersion()); useEffect(() => { const ac = new AbortController(); @@ -20,13 +21,24 @@ export function AppShellFooter(): JSX.Element { return; } const v = meta.version; - setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана"); + const label = typeof v === "string" && v.length > 0 ? v : "не указана"; + writeCachedBackendVersion(label); + setBackendVersion(label); }) .catch((err) => { if (ac.signal.aborted || isAbortError(err)) { return; } - setBackendVersion("недоступна"); + setBackendVersion((prev) => { + const cached = readCachedBackendVersion(); + if (cached) { + return cached; + } + if (prev !== null) { + return prev; + } + return "недоступна"; + }); }); return () => ac.abort(); }, []); diff --git a/frontend/src/lib/backendVersionCache.ts b/frontend/src/lib/backendVersionCache.ts new file mode 100644 index 0000000..a19efa3 --- /dev/null +++ b/frontend/src/lib/backendVersionCache.ts @@ -0,0 +1,21 @@ +const STORAGE_KEY = "calendar_run.backendVersion.v1"; + +export function readCachedBackendVersion(): string | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw !== null && raw.trim().length > 0 ? raw.trim() : null; + } catch { + return null; + } +} + +export function writeCachedBackendVersion(version: string): void { + try { + if (version === "недоступна" || version === "не указана") { + return; + } + sessionStorage.setItem(STORAGE_KEY, version); + } catch { + // private mode / quota + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 081c8d9..33c2cc4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,4 +3,12 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + }, + }, + }, });