feat(frontend): add typed API contract layer

Made-with: Cursor
This commit is contained in:
Anton
2026-04-06 16:09:28 +03:00
parent d7fb5b71ef
commit d318828f73
6 changed files with 286 additions and 2 deletions

View File

@@ -0,0 +1,75 @@
export type ApiErrorCode =
| "validation_error"
| "not_found"
| "database_unavailable"
| "conflict"
| "network_error"
| "unknown_error";
export interface ApiErrorPayload {
error?: string;
details?: string[];
}
export class ApiError extends Error {
public readonly code: ApiErrorCode;
public readonly status: number | null;
public readonly details: string[];
constructor(params: {
code: ApiErrorCode;
message: string;
status?: number | null;
details?: string[];
}) {
super(params.message);
this.name = "ApiError";
this.code = params.code;
this.status = params.status ?? null;
this.details = params.details ?? [];
}
}
function normalizeApiCode(value: string | undefined): ApiErrorCode {
if (
value === "validation_error" ||
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict"
) {
return value;
}
return "unknown_error";
}
export function toApiError(status: number, payload: unknown): ApiError {
const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details)
? maybePayload.details.filter((item): item is string => typeof item === "string")
: [];
return new ApiError({
code,
status,
message: getApiErrorMessage(code),
details,
});
}
export function getApiErrorMessage(code: ApiErrorCode): string {
switch (code) {
case "validation_error":
return "Проверьте введённые данные и попробуйте снова.";
case "not_found":
return "Запись не найдена.";
case "database_unavailable":
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "network_error":
return "Не удалось связаться с сервером.";
default:
return "Произошла неизвестная ошибка.";
}
}