From 8e19ff80571d07b03dd6982c4238a18b862c6e3a Mon Sep 17 00:00:00 2001 From: Scr4tch587 Date: Tue, 7 Jul 2026 22:25:52 -0400 Subject: [PATCH] feat: typed api client and api key handling Co-Authored-By: Claude Fable 5 --- frontend/eslint.config.js | 5 +- frontend/src/App.tsx | 19 +++- frontend/src/components/api-key-dialog.tsx | 107 +++++++++++++++++++++ frontend/src/hooks/use-api-key.tsx | 68 +++++++++++++ frontend/src/lib/api-key.ts | 18 ++++ frontend/src/lib/api.ts | 68 +++++++++++++ frontend/src/lib/format.ts | 22 +++++ frontend/src/main.tsx | 41 +++++++- 8 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/api-key-dialog.tsx create mode 100644 frontend/src/hooks/use-api-key.tsx create mode 100644 frontend/src/lib/api-key.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/format.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 03310a3..7f19fb3 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -20,8 +20,9 @@ export default tseslint.config( }, }, { - // shadcn-generated components export variants/hooks next to components - files: ["src/components/ui/**/*.tsx"], + // shadcn-generated components export variants/hooks next to components; + // context provider files export the provider component + its hook + files: ["src/components/ui/**/*.tsx", "src/hooks/**/*.tsx"], rules: { "react-refresh/only-export-components": "off", }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae4c49a..187e0a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,27 @@ +import { KeyRoundIcon } from "lucide-react"; + +import { ApiKeyDialog } from "@/components/api-key-dialog"; +import { Button } from "@/components/ui/button"; +import { useApiKey } from "@/hooks/use-api-key"; + function App() { + const { setDialogOpen } = useApiKey(); + return (
-
+

Datastream

+
+
); } diff --git a/frontend/src/components/api-key-dialog.tsx b/frontend/src/components/api-key-dialog.tsx new file mode 100644 index 0000000..b999d17 --- /dev/null +++ b/frontend/src/components/api-key-dialog.tsx @@ -0,0 +1,107 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useApiKey } from "@/hooks/use-api-key"; +import { maskApiKey } from "@/lib/api-key"; + +const formSchema = z.object({ + apiKey: z + .string() + .trim() + .regex(/^dsk_/, "keys start with dsk_") + .min(8, "key looks too short"), +}); + +type FormValues = z.infer; + +export function ApiKeyDialog() { + const { apiKey, saveApiKey, clearApiKey, dialogOpen, setDialogOpen } = + useApiKey(); + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { apiKey: "" }, + }); + + function onSubmit(values: FormValues) { + saveApiKey(values.apiKey.trim()); + form.reset(); + setDialogOpen(false); + // refetch everything that failed without a key + void queryClient.invalidateQueries(); + } + + return ( + + + + API key + + Requests are authenticated with a bearer key. Paste your{" "} + dsk_ key — it is stored only in + this browser. + + + {apiKey && ( +
+ {maskApiKey(apiKey)} + +
+ )} +
+ + ( + + {apiKey ? "Replace key" : "Key"} + + + + + + )} + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/hooks/use-api-key.tsx b/frontend/src/hooks/use-api-key.tsx new file mode 100644 index 0000000..da9d834 --- /dev/null +++ b/frontend/src/hooks/use-api-key.tsx @@ -0,0 +1,68 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; + +import { clearStoredApiKey, getApiKey, storeApiKey } from "@/lib/api-key"; + +interface ApiKeyContextValue { + apiKey: string | null; + saveApiKey: (key: string) => void; + clearApiKey: () => void; + dialogOpen: boolean; + setDialogOpen: (open: boolean) => void; +} + +const ApiKeyContext = createContext(null); + +// module-level hook so non-react code (the query client's error handler) +// can pop the key dialog +let openDialogListener: (() => void) | null = null; + +export function requestApiKey(): void { + openDialogListener?.(); +} + +export function ApiKeyProvider({ children }: { children: ReactNode }) { + const [apiKey, setApiKey] = useState(() => getApiKey()); + // auto-open on first load when no key is stored: the backend rejects + // everything except /status without one + const [dialogOpen, setDialogOpen] = useState(() => !getApiKey()); + + useEffect(() => { + openDialogListener = () => setDialogOpen(true); + return () => { + openDialogListener = null; + }; + }, []); + + const saveApiKey = useCallback((key: string) => { + storeApiKey(key); + setApiKey(key); + }, []); + + const clearApiKey = useCallback(() => { + clearStoredApiKey(); + setApiKey(null); + }, []); + + return ( + + {children} + + ); +} + +export function useApiKey(): ApiKeyContextValue { + const ctx = useContext(ApiKeyContext); + if (!ctx) { + throw new Error("useApiKey must be used inside ApiKeyProvider"); + } + return ctx; +} diff --git a/frontend/src/lib/api-key.ts b/frontend/src/lib/api-key.ts new file mode 100644 index 0000000..15f2afb --- /dev/null +++ b/frontend/src/lib/api-key.ts @@ -0,0 +1,18 @@ +const STORAGE_KEY = "datastream.api_key"; + +export function getApiKey(): string | null { + return localStorage.getItem(STORAGE_KEY); +} + +export function storeApiKey(key: string): void { + localStorage.setItem(STORAGE_KEY, key); +} + +export function clearStoredApiKey(): void { + localStorage.removeItem(STORAGE_KEY); +} + +/** masked display form of a stored key, e.g. "dsk_****cd12" */ +export function maskApiKey(key: string): string { + return `${key.slice(0, 4)}****${key.slice(-4)}`; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ac78629 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,68 @@ +import { getApiKey } from "@/lib/api-key"; + +// dev: unset → relative /api/v1 → vite proxy. prod: absolute url baked at build time +const BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/v1`; + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +export interface DatasetSummary { + name: string; + version: string; + has_data: boolean; +} + +export interface DataRow { + timestamp: string; + data: Record[]; +} + +export interface DataResponse { + dataset_name: string; + dataset_version: string; + total_timestamps: number; + returned_timestamps: number; + rows: DataRow[]; +} + +async function apiFetch( + path: string, + params?: Record, + okStatuses: number[] = [200], +): Promise { + const query = params ? `?${new URLSearchParams(params)}` : ""; + const key = getApiKey(); + const res = await fetch(`${BASE}${path}${query}`, { + headers: key ? { Authorization: `Bearer ${key}` } : {}, + }); + if (!okStatuses.includes(res.status)) { + throw new ApiError(res.status, `request to ${path} failed: ${res.status}`); + } + return res.json() as Promise; +} + +export async function fetchDatasets(): Promise { + const body = await apiFetch<{ datasets: DatasetSummary[] }>("/datasets"); + return body.datasets; +} + +// 206 = partial data, still a valid read; build-data=false so browsing never triggers builds +export function fetchData( + name: string, + version: string, + start: string, + end: string, +): Promise { + return apiFetch( + `/data/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, + { start, end, "build-data": "false" }, + [200, 206], + ); +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..072c18b --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,22 @@ +export function formatTimestamp(isoString: string): string { + return new Date(isoString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function toISODate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** default fetch window: today minus 5 years → today */ +export function defaultDateRange(): { start: string; end: string } { + const end = new Date(); + const start = new Date(end); + start.setFullYear(start.getFullYear() - 5); + return { start: toISODate(start), end: toISODate(end) }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 604c29d..981014d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,12 +1,51 @@ +import { + QueryCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { toast } from "sonner"; import App from "@/App"; +import { Toaster } from "@/components/ui/sonner"; +import { ApiKeyProvider, requestApiKey } from "@/hooks/use-api-key"; +import { ApiError } from "@/lib/api"; import "@/index.css"; +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiError && error.status === 401) { + toast.error("invalid or missing API key"); + requestApiKey(); + } + }, + }), + defaultOptions: { + queries: { + // client errors won't fix themselves on retry + retry: (failureCount, error) => { + if ( + error instanceof ApiError && + [401, 403, 404].includes(error.status) + ) { + return false; + } + return failureCount < 2; + }, + }, + }, +}); + createRoot(document.getElementById("root")!).render( - + + + + + + , );