Compare commits

...

2 Commits

Author SHA1 Message Date
Anton
83bc603b95 feat: /meta для версии в футере и устойчивый разбор JSON
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-08 10:32:52 +03:00
f8b4ce7111 Merge pull request 'fix(api): дублировать маршруты под /api и убрать Content-Type у GET' (#16) from fix/api-prefix-routing into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #16
2026-04-08 07:21:03 +00:00
9 changed files with 66 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.0.1", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -7,7 +7,9 @@ import racesRouter from "./routes/races";
export function createApp(): express.Express { export function createApp(): express.Express {
const app = express(); const app = express();
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] })); app.use(
cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }),
);
app.use(express.json()); app.use(express.json());
app.use(healthRouter); app.use(healthRouter);

View File

@@ -8,6 +8,11 @@ router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", version: getBackendVersion() }); res.json({ status: "ok", version: getBackendVersion() });
}); });
/** Версия для UI; путь без «health», чтобы реже резался фильтрами/прокси. */
router.get("/meta", (_req: Request, res: Response) => {
res.json({ version: getBackendVersion() });
});
router.get("/ready", async (_req: Request, res: Response) => { router.get("/ready", async (_req: Request, res: Response) => {
const dbOk = await checkDbConnection(); const dbOk = await checkDbConnection();
if (dbOk) { if (dbOk) {

View File

@@ -17,6 +17,18 @@ test("GET /api/health returns ok (prefix without proxy strip)", async () => {
assert.equal(res.body.status, "ok"); 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 () => {
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 () => { test("GET /ready succeeds with mock database", async () => {
const res = await request(app).get("/ready").expect(200); const res = await request(app).get("/ready").expect(200);
assert.equal(res.body.status, "ready"); assert.equal(res.body.status, "ready");

View File

@@ -1,7 +1,7 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"private": true, "private": true,
"version": "0.1.1", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -5,6 +5,15 @@ export type HealthResponse = {
version: string; version: string;
}; };
export type BackendMetaResponse = {
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> { export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init); return requestJson<HealthResponse>("/health", init);
} }
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
return requestJson<BackendMetaResponse>("/meta", init);
}

View File

@@ -8,16 +8,30 @@ function buildUrl(path: string): string {
} }
async function parseResponseBody(response: Response): Promise<unknown> { async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? ""; const text = await response.text();
if (!contentType.includes("application/json")) { if (!text.trim()) {
return null; return null;
} }
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
try { try {
return await response.json(); return JSON.parse(text) as unknown;
} catch { } catch {
return null; return null;
} }
}
const trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
return null;
} }
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]); const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);

View File

@@ -1,5 +1,5 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors"; export { ApiError, getApiErrorMessage } from "./errors";
export type { HealthResponse } from "./health"; export type { BackendMetaResponse, HealthResponse } from "./health";
export { getHealth } from "./health"; export { getBackendMeta, getHealth } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";

View File

@@ -1,22 +1,29 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getHealth } from "../../api"; import { getBackendMeta } from "../../api";
import { FRONTEND_VERSION } from "../../frontendVersion"; import { FRONTEND_VERSION } from "../../frontendVersion";
function isAbortError(error: unknown): boolean {
return (
(error instanceof DOMException && error.name === "AbortError") ||
(error instanceof Error && error.name === "AbortError")
);
}
export function AppShellFooter(): JSX.Element { export function AppShellFooter(): JSX.Element {
const [backendVersion, setBackendVersion] = useState<string | null>(null); const [backendVersion, setBackendVersion] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
void getHealth({ signal: ac.signal }) void getBackendMeta({ signal: ac.signal })
.then((h) => { .then((meta) => {
if (ac.signal.aborted) { if (ac.signal.aborted) {
return; return;
} }
const v = h.version; const v = meta.version;
setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана"); setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана");
}) })
.catch(() => { .catch((err) => {
if (ac.signal.aborted) { if (ac.signal.aborted || isAbortError(err)) {
return; return;
} }
setBackendVersion("недоступна"); setBackendVersion("недоступна");