refactor(api): type req<T>() and tighten the error layer (R7 S1)
req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:
- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown
Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.
No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Couldn't complete that",
|
||||
"generic": "The server couldn't carry out that action. Please try again.",
|
||||
"validation": "Invalid value for “{{field}}”. Please check it and try again.",
|
||||
"server": "Server error",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Nem sikerült",
|
||||
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
|
||||
"validation": "Érvénytelen érték: „{{field}}”. Ellenőrizd, majd próbáld újra.",
|
||||
"server": "Szerverhiba",
|
||||
"ok": "OK",
|
||||
"offline": {
|
||||
|
||||
+42
-15
@@ -212,8 +212,8 @@ export interface VersionInfo {
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||
constructor(status: number, detail?: string, detailData?: any) {
|
||||
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||
constructor(status: number, detail?: string, detailData?: unknown) {
|
||||
super(detail || `HTTP ${status}`);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
@@ -225,20 +225,31 @@ class HttpError extends Error {
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
function localizeDetail(d: any): string {
|
||||
function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
if (d?.code === "quota") {
|
||||
if (d.reason === "max_bytes") {
|
||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||
// well-formed requests, so this only fires on genuinely malformed input — surface the offending
|
||||
// field name (localized) rather than FastAPI's raw English `msg`, which reads wrong in a HU UI.
|
||||
if (Array.isArray(d)) {
|
||||
const loc = (d[0] as { loc?: unknown } | undefined)?.loc;
|
||||
const field = Array.isArray(loc)
|
||||
? loc.filter((s): s is string => typeof s === "string" && s !== "body").pop()
|
||||
: undefined;
|
||||
return field ? t("errors.validation", { field }) : t("errors.generic");
|
||||
}
|
||||
const o = d as { code?: string; reason?: string; limit?: number } | null | undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
|
||||
(d.limit || 0) / 1_073_741_824,
|
||||
(o.limit || 0) / 1_073_741_824,
|
||||
);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
|
||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (d?.code === "edit")
|
||||
return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "edit")
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
@@ -341,7 +352,11 @@ export function clearActiveAccount(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
||||
async function req<T = unknown>(
|
||||
url: string,
|
||||
opts: RequestInit = {},
|
||||
cfg: ReqConfig = {},
|
||||
): Promise<T> {
|
||||
const method = opts.method ?? "GET";
|
||||
const canRetry = cfg.idempotent ?? method === "GET";
|
||||
let attempt = 0;
|
||||
@@ -386,7 +401,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||
if (!r.ok) {
|
||||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||||
let detail: string | undefined;
|
||||
let detailData: any;
|
||||
let detailData: unknown;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
@@ -417,7 +432,9 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
||||
}
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
}
|
||||
return r.status === 204 ? null : r.json();
|
||||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||||
// every typed api.* method (T comes from its declared return type) is checked from here up.
|
||||
return (r.status === 204 ? null : await r.json()) as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,7 +1115,8 @@ export const api = {
|
||||
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
|
||||
// logs a failed request to the console). React Query treats the null as data, not an error.
|
||||
me: async (): Promise<Me | null> => {
|
||||
const r = await req("/api/me");
|
||||
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
|
||||
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
||||
return r && r.authenticated ? (r as Me) : null;
|
||||
},
|
||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||
@@ -1176,7 +1194,8 @@ export const api = {
|
||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
resetChannelBackfill: (id: string) =>
|
||||
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
||||
deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||
deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> =>
|
||||
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||
attachChannelTag: (id: string, tagId: number) =>
|
||||
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
||||
detachChannelTag: (id: string, tagId: number) =>
|
||||
@@ -1184,7 +1203,15 @@ export const api = {
|
||||
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
||||
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||
syncSubscriptions: (): Promise<{
|
||||
subscriptions: number;
|
||||
channels_new: number;
|
||||
channels_detailed: number;
|
||||
removed_stale: number;
|
||||
prune_skipped: boolean;
|
||||
quota_used_estimate: number;
|
||||
quota_remaining_today: number;
|
||||
}> => req("/api/sync/subscriptions", { method: "POST" }),
|
||||
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
||||
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
||||
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
||||
|
||||
Reference in New Issue
Block a user