feat: /meta для версии в футере и устойчивый разбор JSON #17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -7,7 +7,9 @@ import racesRouter from "./routes/races";
|
||||
export function createApp(): express.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(healthRouter);
|
||||
|
||||
@@ -8,6 +8,11 @@ router.get("/health", (_req: Request, res: Response) => {
|
||||
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) => {
|
||||
const dbOk = await checkDbConnection();
|
||||
if (dbOk) {
|
||||
|
||||
@@ -17,6 +17,18 @@ test("GET /api/health returns ok (prefix without proxy strip)", async () => {
|
||||
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 () => {
|
||||
const res = await request(app).get("/ready").expect(200);
|
||||
assert.equal(res.body.status, "ready");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,6 +5,15 @@ export type HealthResponse = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type BackendMetaResponse = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
|
||||
return requestJson<HealthResponse>("/health", init);
|
||||
}
|
||||
|
||||
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
|
||||
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
|
||||
return requestJson<BackendMetaResponse>("/meta", init);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,30 @@ function buildUrl(path: string): string {
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
return await response.json();
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
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]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
||||
export { ApiError, getApiErrorMessage } from "./errors";
|
||||
export type { HealthResponse } from "./health";
|
||||
export { getHealth } from "./health";
|
||||
export type { BackendMetaResponse, HealthResponse } from "./health";
|
||||
export { getBackendMeta, getHealth } from "./health";
|
||||
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getHealth } from "../../api";
|
||||
import { getBackendMeta } from "../../api";
|
||||
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 {
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
void getHealth({ signal: ac.signal })
|
||||
.then((h) => {
|
||||
void getBackendMeta({ signal: ac.signal })
|
||||
.then((meta) => {
|
||||
if (ac.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const v = h.version;
|
||||
const v = meta.version;
|
||||
setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана");
|
||||
})
|
||||
.catch(() => {
|
||||
if (ac.signal.aborted) {
|
||||
.catch((err) => {
|
||||
if (ac.signal.aborted || isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
setBackendVersion("недоступна");
|
||||
|
||||
Reference in New Issue
Block a user