feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
|
|
@ -1,85 +0,0 @@
|
|||
import { getCsrfToken } from "$lib/auth";
|
||||
|
||||
export const API_BASE_URL = "http://localhost:8000/api";
|
||||
|
||||
export async function fetchAPI<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
let csrfToken = await getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
console.warn("CSRF token not found, using empty string.");
|
||||
csrfToken = "";
|
||||
}
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
options.credentials = "include"; // Include cookies in the request
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken,
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
let errorText = await response.json();
|
||||
if (
|
||||
errorText &&
|
||||
typeof errorText === "object" &&
|
||||
("detail" in errorText || "field_errors" in errorText || "non_field_errors" in errorText)
|
||||
) {
|
||||
// Handle structured error responses
|
||||
if ("detail" in errorText) {
|
||||
errorText = errorText.detail;
|
||||
} else if ("field_errors" in errorText) {
|
||||
errorText = Object.values(errorText.field_errors).join(", ");
|
||||
} else if ("non_field_errors" in errorText) {
|
||||
errorText = errorText.non_field_errors.join(", ");
|
||||
}
|
||||
} else {
|
||||
errorText = `Unexpected error: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(`${errorText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
// No content response
|
||||
return {} as T; // Return an empty object for 204 responses
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${url}:`, error);
|
||||
if (error instanceof Error) {
|
||||
// If the error is an instance of Error, rethrow it
|
||||
return Promise.reject(new Error(`${error.message}`));
|
||||
}
|
||||
return Promise.reject(new Error(`${error}`));
|
||||
}
|
||||
}
|
||||
|
||||
export function postAPI<T>(endpoint: string, data: any): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAPI<T>(endpoint: string): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export function putAPI<T>(endpoint: string, data: any): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAPI<T>(endpoint: string): Promise<T> {
|
||||
return fetchAPI<T>(endpoint, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
123
src/lib/api/client.ts
Normal file
123
src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import Cookies from 'js-cookie';
|
||||
|
||||
/**
|
||||
* Thin wrapper around fetch that:
|
||||
* - prepends the configured API base URL
|
||||
* - includes Django session cookies
|
||||
* - attaches the CSRF token from cookies
|
||||
* - parses structured DRF errors into a single ApiError
|
||||
*
|
||||
* The 401 handler is pluggable (see `setUnauthorizedHandler`) so the auth
|
||||
* layer can react (clear session, route to /login) without this module
|
||||
* importing from $auth (which would create a cycle).
|
||||
*
|
||||
* Default base URL is the relative path `/api` so the app works out of the
|
||||
* box against either the Vite dev proxy, the mock plugin, or a same-origin
|
||||
* production deployment. Set VITE_API_BASE_URL to point directly at a
|
||||
* cross-origin backend if needed.
|
||||
*/
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type UnauthorizedHandler = () => void;
|
||||
let onUnauthorized: UnauthorizedHandler | null = null;
|
||||
export function setUnauthorizedHandler(fn: UnauthorizedHandler | null) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
async function ensureCsrf(): Promise<string> {
|
||||
const existing = Cookies.get('csrftoken');
|
||||
if (existing) return existing;
|
||||
|
||||
await fetch(`${API_BASE_URL}/csrf/`, { method: 'GET', credentials: 'include' });
|
||||
return Cookies.get('csrftoken') ?? '';
|
||||
}
|
||||
|
||||
function extractError(body: unknown, fallback: string): string {
|
||||
if (body && typeof body === 'object') {
|
||||
const b = body as Record<string, unknown>;
|
||||
if (typeof b.detail === 'string') return b.detail;
|
||||
if (b.non_field_errors && Array.isArray(b.non_field_errors)) {
|
||||
return b.non_field_errors.join(', ');
|
||||
}
|
||||
if (b.field_errors && typeof b.field_errors === 'object') {
|
||||
return Object.values(b.field_errors as Record<string, unknown>)
|
||||
.flat()
|
||||
.join(', ');
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
let url = `${API_BASE_URL}${path}`;
|
||||
if (!query) return url;
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined) params.set(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const csrf = await ensureCsrf();
|
||||
|
||||
const init: RequestInit = {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrf,
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
};
|
||||
|
||||
const res = await fetch(buildUrl(path, options.query), init);
|
||||
|
||||
if (res.status === 401) onUnauthorized?.();
|
||||
|
||||
if (!res.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(res.status, extractError(body, res.statusText), body);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'POST', body }),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'PUT', body }),
|
||||
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>(path, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
5
src/lib/api/index.ts
Normal file
5
src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { api, ApiError, API_BASE_URL, setUnauthorizedHandler } from './client';
|
||||
export { pointsApi } from './points';
|
||||
export { profilesApi } from './profiles';
|
||||
export { scenariosApi } from './scenarios';
|
||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
||||
|
|
@ -1,19 +1,11 @@
|
|||
/* API functions for Saved Points */
|
||||
import type { SavedPoint } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
import { api } from './client';
|
||||
import type { SavedPoint } from '$domain';
|
||||
|
||||
export function getSavedPoints(): Promise<SavedPoint[]> {
|
||||
return getAPI<SavedPoint[]>("/saved-points/");
|
||||
}
|
||||
const base = '/saved-points/';
|
||||
|
||||
export function savePoint(point: SavedPoint): Promise<SavedPoint> {
|
||||
return postAPI<SavedPoint>("/saved-points/", point);
|
||||
}
|
||||
|
||||
export function updatePoint(point: SavedPoint): Promise<SavedPoint> {
|
||||
return putAPI<SavedPoint>(`/saved-points/${point.id}/`, point);
|
||||
}
|
||||
|
||||
export function deletePoint(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-points/${id}/`);
|
||||
}
|
||||
export const pointsApi = {
|
||||
list: () => api.get<SavedPoint[]>(base),
|
||||
create: (p: SavedPoint) => api.post<SavedPoint>(base, p),
|
||||
update: (p: SavedPoint) => api.put<SavedPoint>(`${base}${p.id}/`, p),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
|
|
|
|||
34
src/lib/api/predictions.ts
Normal file
34
src/lib/api/predictions.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { api } from './client';
|
||||
import type { FlightParameters, RawPrediction } from '$domain';
|
||||
|
||||
/**
|
||||
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
|
||||
* Round down to the most recent available slot.
|
||||
*/
|
||||
export function getLatestDataset(now: Date = new Date()): string {
|
||||
const rounded = new Date(now);
|
||||
rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
||||
rounded.setUTCHours(rounded.getUTCHours() - 6);
|
||||
return rounded.toISOString();
|
||||
}
|
||||
|
||||
export function buildLaunchDateTime(date: string, time: string): string {
|
||||
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
||||
return new Date(`${date}T${fullTime}Z`).toISOString();
|
||||
}
|
||||
|
||||
export interface PredictionResponse {
|
||||
result: RawPrediction;
|
||||
}
|
||||
|
||||
export const predictionsApi = {
|
||||
run: (params: FlightParameters, launchDateTime: string) => {
|
||||
const payload: FlightParameters & { launch_datetime: string } = {
|
||||
...params,
|
||||
dataset: params.dataset || getLatestDataset(),
|
||||
launch_datetime: launchDateTime,
|
||||
};
|
||||
if (payload.start_point === -1) delete payload.start_point;
|
||||
return api.post<PredictionResponse>('/predictions/', payload);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,19 +1,11 @@
|
|||
/* API functions for SavedFlightProfile */
|
||||
import type {SavedFlightProfile } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
import { api } from './client';
|
||||
import type { SavedFlightProfile } from '$domain';
|
||||
|
||||
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> {
|
||||
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
|
||||
}
|
||||
const base = '/saved-profiles/';
|
||||
|
||||
export function saveFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
|
||||
return postAPI<SavedFlightProfile>("/saved-profiles/", profile);
|
||||
}
|
||||
|
||||
export function updateFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
|
||||
return putAPI<SavedFlightProfile>(`/saved-profiles/${profile.id}/`, profile);
|
||||
}
|
||||
|
||||
export function deleteFlightProfile(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-profiles/${id}/`);
|
||||
}
|
||||
export const profilesApi = {
|
||||
list: () => api.get<SavedFlightProfile[]>(base),
|
||||
create: (p: SavedFlightProfile) => api.post<SavedFlightProfile>(base, p),
|
||||
update: (p: SavedFlightProfile) => api.put<SavedFlightProfile>(`${base}${p.id}/`, p),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
/* API functions for SavedScenario */
|
||||
import type { SavedScenario } from "$lib/types";
|
||||
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
|
||||
import { api } from './client';
|
||||
import type { SavedScenario } from '$domain';
|
||||
|
||||
export function getSavedScenarios(): Promise<SavedScenario[]> {
|
||||
return getAPI<SavedScenario[]>("/saved-templates/");
|
||||
}
|
||||
const base = '/saved-templates/';
|
||||
|
||||
export function saveScenario(template: SavedScenario): Promise<SavedScenario> {
|
||||
return postAPI<SavedScenario>("/saved-templates/", template);
|
||||
}
|
||||
|
||||
export function updateScenario(template: SavedScenario): Promise<SavedScenario> {
|
||||
return putAPI<SavedScenario>(`/saved-templates/${template.id}/`, template);
|
||||
}
|
||||
|
||||
export function deleteScenario(id: number): Promise<void> {
|
||||
return deleteAPI<void>(`/saved-templates/${id}/`);
|
||||
}
|
||||
export const scenariosApi = {
|
||||
list: () => api.get<SavedScenario[]>(base),
|
||||
create: (s: SavedScenario) => api.post<SavedScenario>(base, s),
|
||||
update: (s: SavedScenario) => api.put<SavedScenario>(`${base}${s.id}/`, s),
|
||||
delete: (id: number) => api.delete<void>(`${base}${id}/`),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue