● Zion Boggan
repos/Drophawk/frontend/lib/api.ts
zionboggan.com ↗
119 lines · typescript
History for this file →
1
 
2
import type {
3
  ApiCollection,
4
  ApiResource,
5
  Drop,
6
  Source,
7
  Stats,
8
  TicketEvent,
9
  WatchlistCreate,
10
  WatchlistRule,
11
} from "./types";
12
 
13
const BASE = "";
14
 
15
export class ApiError extends Error {
16
  readonly code: string;
17
  readonly status: number;
18
  readonly details?: unknown;
19
 
20
  constructor(status: number, code: string, message: string, details?: unknown) {
21
    super(message);
22
    this.name = "ApiError";
23
    this.status = status;
24
    this.code = code;
25
    this.details = details;
26
  }
27
}
28
 
29
async function request<T>(path: string, init?: RequestInit): Promise<T> {
30
  const res = await fetch(`${BASE}/api${path}`, {
31
    ...init,
32
    headers: {
33
      "Content-Type": "application/json; charset=utf-8",
34
      ...init?.headers,
35
    },
36
    cache: "no-store",
37
  });
38
 
39
  if (res.status === 204) return undefined as T;
40
 
41
  const body = (await res.json().catch(() => null)) as unknown;
42
 
43
  if (!res.ok) {
44
    const err = (body as { error?: { code?: string; message?: string; details?: unknown } } | null)?.error;
45
    throw new ApiError(
46
      res.status,
47
      err?.code ?? "INTERNAL",
48
      err?.message ?? `Request failed (${res.status})`,
49
      err?.details,
50
    );
51
  }
52
  return body as T;
53
}
54
 
55
function qs(params: Record<string, string | number | undefined | null>): string;
56
function qs(params: object): string;
57
function qs(params: object): string {
58
  const sp = new URLSearchParams();
59
  for (const [k, v] of Object.entries(params)) {
60
    if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
61
  }
62
  const s = sp.toString();
63
  return s ? `?${s}` : "";
64
}
65
 
66
export interface EventQuery {
67
  page?: number;
68
  per_page?: number;
69
  category?: string;
70
  status?: string;
71
  source_id?: string;
72
  q?: string;
73
  sort?: string;
74
}
75
 
76
export const api = {
77
  stats: () => request<ApiResource<Stats>>("/stats").then((r) => r.data),
78
 
79
  events: (params: EventQuery = {}) =>
80
    request<ApiCollection<TicketEvent>>(`/events${qs(params)}`),
81
 
82
  drops: (params: { page?: number; per_page?: number; drop_type?: string; source_id?: string } = {}) =>
83
    request<ApiCollection<Drop>>(`/drops${qs(params)}`),
84
 
85
  sources: () => request<ApiCollection<Source>>("/sources").then((r) => r.data),
86
 
87
  watchlist: () => request<ApiCollection<WatchlistRule>>("/watchlist").then((r) => r.data),
88
 
89
  createWatch: (body: WatchlistCreate) =>
90
    request<ApiResource<WatchlistRule>>("/watchlist", {
91
      method: "POST",
92
      body: JSON.stringify(body),
93
    }).then((r) => r.data),
94
 
95
  patchWatch: (id: number, body: Partial<Pick<WatchlistRule, "active" | "priority" | "label">>) =>
96
    request<ApiResource<WatchlistRule>>(`/watchlist/${id}`, {
97
      method: "PATCH",
98
      body: JSON.stringify(body),
99
    }).then((r) => r.data),
100
 
101
  deleteWatch: (id: number) => request<void>(`/watchlist/${id}`, { method: "DELETE" }),
102
 
103
  bulkWatch: (labels: string[]) =>
104
    request<ApiResource<{ created: number; skipped_duplicates: number }>>("/watchlist/bulk", {
105
      method: "POST",
106
      body: JSON.stringify({ rules: labels.map((label) => ({ kind: "keyword", label })) }),
107
    }).then((r) => r.data),
108
 
109
  patchSource: (id: string, enabled: boolean) =>
110
    request<ApiResource<Source>>(`/sources/${id}`, {
111
      method: "PATCH",
112
      body: JSON.stringify({ enabled }),
113
    }).then((r) => r.data),
114
 
115
  testNotification: () =>
116
    request<ApiResource<{ sent: boolean; topic: string }>>("/notifications/test", {
117
      method: "POST",
118
    }).then((r) => r.data),
119
};