The route editor called fetch() directly for /api/v1/routes/compute without the bearer token. Now uses the API client which injects auth headers and handles 401 auto-refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
149 lines
4 KiB
TypeScript
149 lines
4 KiB
TypeScript
import {
|
|
RouteListResponseSchema,
|
|
RouteDetailSchema,
|
|
ActivityListResponseSchema,
|
|
type RouteSummary,
|
|
type RouteDetail,
|
|
type RouteListResponse,
|
|
type ActivitySummary,
|
|
type ActivityListResponse,
|
|
} from "@trails-cool/api";
|
|
import { getServerUrl } from "./server-config";
|
|
|
|
export type { RouteSummary, RouteDetail, RouteListResponse, ActivitySummary, ActivityListResponse };
|
|
import { getAccessToken, refreshTokens, clearTokens } from "./auth";
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export class NetworkError extends Error {
|
|
constructor(message = "Network request failed") {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base API client with bearer token injection and automatic 401 refresh.
|
|
*/
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
const serverUrl = await getServerUrl();
|
|
const baseUrl = `${serverUrl}/api/v1`;
|
|
const url = `${baseUrl}${path}`;
|
|
|
|
const token = await getAccessToken();
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers as Record<string, string> ?? {}),
|
|
};
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
|
|
let resp: Response;
|
|
try {
|
|
resp = await fetch(url, { ...options, headers });
|
|
} catch (err) {
|
|
throw new NetworkError((err as Error).message);
|
|
}
|
|
|
|
// Auto-refresh on 401
|
|
if (resp.status === 401 && token) {
|
|
const refreshed = await refreshTokens();
|
|
if (refreshed) {
|
|
const newToken = await getAccessToken();
|
|
headers["Authorization"] = `Bearer ${newToken}`;
|
|
try {
|
|
resp = await fetch(url, { ...options, headers });
|
|
} catch (err) {
|
|
throw new NetworkError((err as Error).message);
|
|
}
|
|
} else {
|
|
await clearTokens();
|
|
throw new ApiError(401, "UNAUTHORIZED", "Session expired. Please log in again.");
|
|
}
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
const body = await resp.json().catch(() => ({}));
|
|
throw new ApiError(
|
|
resp.status,
|
|
body.code ?? "UNKNOWN",
|
|
body.error ?? `Request failed with status ${resp.status}`,
|
|
);
|
|
}
|
|
|
|
return resp.json();
|
|
}
|
|
|
|
// --- Routes ---
|
|
|
|
export async function listRoutes(cursor?: string, limit = 20) {
|
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
if (cursor) params.set("cursor", cursor);
|
|
const data = await request<unknown>(`/routes?${params}`);
|
|
return RouteListResponseSchema.parse(data);
|
|
}
|
|
|
|
export async function getRoute(id: string) {
|
|
const data = await request<unknown>(`/routes/${id}`);
|
|
return RouteDetailSchema.parse(data);
|
|
}
|
|
|
|
export function createRoute(data: { name: string; description?: string; gpx?: string }) {
|
|
return request<{ id: string }>("/routes", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export function updateRoute(id: string, data: { name?: string; description?: string; gpx?: string }) {
|
|
return request<{ ok: boolean }>(`/routes/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
export function computeRoute(waypoints: Array<{ lat: number; lon: number }>, profile = "fastbike") {
|
|
return request<unknown>("/routes/compute", {
|
|
method: "POST",
|
|
body: JSON.stringify({ waypoints, profile }),
|
|
});
|
|
}
|
|
|
|
// --- Activities ---
|
|
|
|
export async function listActivities(cursor?: string, limit = 20) {
|
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
if (cursor) params.set("cursor", cursor);
|
|
const data = await request<unknown>(`/activities?${params}`);
|
|
return ActivityListResponseSchema.parse(data);
|
|
}
|
|
|
|
export async function getActivity(id: string) {
|
|
return request<ActivitySummary & { gpx: string | null; geojson: string | null }>(`/activities/${id}`);
|
|
}
|
|
|
|
export function createActivity(data: {
|
|
name: string;
|
|
description?: string;
|
|
gpx?: string;
|
|
routeId?: string;
|
|
startedAt?: string;
|
|
duration?: number;
|
|
distance?: number;
|
|
}) {
|
|
return request<{ id: string }>("/activities", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|