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",
|
"title": "Couldn't complete that",
|
||||||
"generic": "The server couldn't carry out that action. Please try again.",
|
"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",
|
"server": "Server error",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"offline": {
|
"offline": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Nem sikerült",
|
"title": "Nem sikerült",
|
||||||
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
|
"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",
|
"server": "Szerverhiba",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"offline": {
|
"offline": {
|
||||||
|
|||||||
+42
-15
@@ -212,8 +212,8 @@ export interface VersionInfo {
|
|||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||||
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
|
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||||
constructor(status: number, detail?: string, detailData?: any) {
|
constructor(status: number, detail?: string, detailData?: unknown) {
|
||||||
super(detail || `HTTP ${status}`);
|
super(detail || `HTTP ${status}`);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.detail = detail;
|
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
|
// 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
|
// 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.
|
// 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);
|
const t = i18n.t.bind(i18n);
|
||||||
if (d?.code === "quota") {
|
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||||
if (d.reason === "max_bytes") {
|
// 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(
|
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 });
|
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");
|
return t("downloads.errors.quota.generic");
|
||||||
}
|
}
|
||||||
if (d?.code === "edit")
|
if (o?.code === "edit")
|
||||||
return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
|
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||||
return t("errors.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 method = opts.method ?? "GET";
|
||||||
const canRetry = cfg.idempotent ?? method === "GET";
|
const canRetry = cfg.idempotent ?? method === "GET";
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
@@ -386,7 +401,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||||||
let detail: string | undefined;
|
let detail: string | undefined;
|
||||||
let detailData: any;
|
let detailData: unknown;
|
||||||
try {
|
try {
|
||||||
const body = await r.json();
|
const body = await r.json();
|
||||||
if (body && typeof body.detail === "string") detail = body.detail;
|
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);
|
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
|
// (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.
|
// logs a failed request to the console). React Query treats the null as data, not an error.
|
||||||
me: async (): Promise<Me | null> => {
|
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;
|
return r && r.authenticated ? (r as Me) : null;
|
||||||
},
|
},
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
@@ -1176,7 +1194,8 @@ export const api = {
|
|||||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
resetChannelBackfill: (id: string) =>
|
resetChannelBackfill: (id: string) =>
|
||||||
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
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) =>
|
attachChannelTag: (id: string, tagId: number) =>
|
||||||
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
||||||
detachChannelTag: (id: string, tagId: number) =>
|
detachChannelTag: (id: string, tagId: number) =>
|
||||||
@@ -1184,7 +1203,15 @@ export const api = {
|
|||||||
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||||
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
||||||
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
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) ---
|
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
||||||
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
||||||
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
||||||
|
|||||||
Reference in New Issue
Block a user