diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 1c61c60..bbb10b1 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -7,132 +7,228 @@ import { baseUrl, sseBaseUrl } from "@utils"; import { AuthContext } from "@utils/Context"; import { GithubRelease } from "@app/types/Update"; -interface ConfigType { - body?: BodyInit | Record | unknown; - headers?: Record; +type RequestBody = BodyInit | object | Record | null; +type Primitive = string | number | boolean | symbol | undefined; + +interface HttpConfig { + method?: string; + body?: RequestBody; + queryString?: Record; } -type PostBody = BodyInit | Record | unknown; +// See https://stackoverflow.com/a/62969380 +function encodeRFC3986URIComponent(str: string): string { + return encodeURIComponent(str).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} -export async function HttpClient( +export async function HttpClient( endpoint: string, - method: string, - { body, ...customConfig }: ConfigType = {} + config: HttpConfig = {} ): Promise { - const config = { - method: method, - body: body ? JSON.stringify(body) : undefined, - headers: { - "Content-Type": "application/json" - }, - // NOTE: customConfig can override the above defined settings - ...customConfig - } as RequestInit; + const init: RequestInit = { + method: config.method, + headers: { "Accept": "*/*" } + }; - return window.fetch(`${baseUrl()}${endpoint}`, config) - .then(async response => { - if (!response.ok) { - // if 401 consider the session expired and force logout - if (response.status === 401) { - // Remove auth info from localStorage - AuthContext.reset(); + if (config.body) { + init.body = JSON.stringify(config.body); - // Show an error toast to notify the user what occurred - return Promise.reject(new Error("Unauthorized")); - } else if (response.status === 404) { - return Promise.reject(new Error("Not found")); + if (typeof(config.body) === "object") { + init.headers = { + ...init.headers, + "Content-Type": "application/json" + }; + } + } + + if (config.queryString) { + const params: string[] = []; + + for (const [key, value] of Object.entries(config.queryString)) { + const serializedKey = encodeRFC3986URIComponent(key); + + if (typeof(value) === undefined) { + // Skip case when the value is undefined. + // The solution in this case is to use the request body instead with JSON + continue; + } else if (Array.isArray(value)) { + // Append (don't set) each array member as a query parameter + // e.g. ?a=1&a=2&a=3 + value.forEach((child) => { + // Skip undefined member values + const v = typeof(child) !== undefined ? String(child) : ""; + if (v.length) { + params.push(`${serializedKey}=${encodeRFC3986URIComponent(v)}`); + } + }); + } else { + // This is a primitive value, just add as string + // e.g. ?a=1 + const v = String(value); + if (v.length) { + params.push(`${serializedKey}=${encodeRFC3986URIComponent(v)}`); } - - return Promise.reject(new Error(await response.text())); } + } - // Resolve immediately since 204 contains no data - if (response.status === 204) - return Promise.resolve(response); + if (params.length) { + endpoint += `?${params.join("&")}`; + } + } - return await response.json(); - }); + const response = await window.fetch(`${baseUrl()}${endpoint}`, init); + + switch (response.status) { + case 204: + // 204 contains no data, but indicates success + return Promise.resolve({} as T); + case 401: + // Remove auth info from localStorage + AuthContext.reset(); + + // Show an error toast to notify the user what occurred + return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`)); + case 404: + return Promise.reject(new Error(`[404] Not found: "${endpoint}"`)); + case 500: + const health = await window.fetch(`${baseUrl()}api/healthz/liveness`); + if (!health.ok) { + return Promise.reject( + new Error(`[500] Offline (Internal server error): "${endpoint}"`, { cause: "OFFLINE" }) + ); + } + break; + default: + break; + } + + const isJson = response.headers.get("Content-Type")?.includes("application/json"); + const json = isJson ? await response.json() : null; + + // Resolve on success + if (response.status >= 200 && response.status < 300) { + if (json) { + return Promise.resolve(json as T); + } else { + return Promise.resolve(response as T); + } + } + + // Otherwise reject, this is most likely an error + return Promise.reject(json as T); } const appClient = { - Get: (endpoint: string) => HttpClient(endpoint, "GET"), - Post: (endpoint: string, data: PostBody = undefined) => HttpClient(endpoint, "POST", { body: data }), - Put: (endpoint: string, data: PostBody) => HttpClient(endpoint, "PUT", { body: data }), - Patch: (endpoint: string, data: PostBody = undefined) => HttpClient(endpoint, "PATCH", { body: data }), - Delete: (endpoint: string) => HttpClient(endpoint, "DELETE") + Get: (endpoint: string, config: HttpConfig = {}) => HttpClient(endpoint, { + ...config, + method: "GET" + }), + Post: (endpoint: string, config: HttpConfig = {}) => HttpClient(endpoint, { + ...config, + method: "POST" + }), + Put: (endpoint: string, config: HttpConfig = {}) => HttpClient(endpoint, { + ...config, + method: "PUT" + }), + Patch: (endpoint: string, config: HttpConfig = {}) => HttpClient(endpoint, { + ...config, + method: "PATCH" + }), + Delete: (endpoint: string, config: HttpConfig = {}) => HttpClient(endpoint, { + ...config, + method: "DELETE" + }) }; export const APIClient = { auth: { login: (username: string, password: string) => appClient.Post("api/auth/login", { - username: username, - password: password + body: { username, password } }), logout: () => appClient.Post("api/auth/logout"), validate: () => appClient.Get("api/auth/validate"), onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", { - username: username, - password: password + body: { username, password } }), canOnboard: () => appClient.Get("api/auth/onboard") }, actions: { - create: (action: Action) => appClient.Post("api/actions", action), - update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action), + create: (action: Action) => appClient.Post("api/actions", { + body: action + }), + update: (action: Action) => appClient.Put(`api/actions/${action.id}`, { + body: action + }), delete: (id: number) => appClient.Delete(`api/actions/${id}`), toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`) }, apikeys: { getAll: () => appClient.Get("api/keys"), - create: (key: APIKey) => appClient.Post("api/keys", key), + create: (key: APIKey) => appClient.Post("api/keys", { + body: key + }), delete: (key: string) => appClient.Delete(`api/keys/${key}`) }, config: { get: () => appClient.Get("api/config"), - update: (config: ConfigUpdate) => appClient.Patch("api/config", config) + update: (config: ConfigUpdate) => appClient.Patch("api/config", { + body: config + }) }, download_clients: { getAll: () => appClient.Get("api/download_clients"), - create: (dc: DownloadClient) => appClient.Post("api/download_clients", dc), - update: (dc: DownloadClient) => appClient.Put("api/download_clients", dc), + create: (dc: DownloadClient) => appClient.Post("api/download_clients", { + body: dc + }), + update: (dc: DownloadClient) => appClient.Put("api/download_clients", { + body: dc + }), delete: (id: number) => appClient.Delete(`api/download_clients/${id}`), - test: (dc: DownloadClient) => appClient.Post("api/download_clients/test", dc) + test: (dc: DownloadClient) => appClient.Post("api/download_clients/test", { + body: dc + }) }, filters: { getAll: () => appClient.Get("api/filters"), - find: (indexers: string[], sortOrder: string) => { - const params = new URLSearchParams(); - - if (sortOrder.length > 0) { - params.append("sort", sortOrder); + find: (indexers: string[], sortOrder: string) => appClient.Get("api/filters", { + queryString: { + sort: sortOrder, + indexer: indexers } - - indexers?.forEach((i) => { - if (i !== undefined || i !== "") { - params.append("indexer", i); - } - }); - - const p = params.toString(); - const q = p ? `?${p}` : ""; - - return appClient.Get(`api/filters${q}`); - }, + }), getByID: (id: number) => appClient.Get(`api/filters/${id}`), - create: (filter: Filter) => appClient.Post("api/filters", filter), - update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter), + create: (filter: Filter) => appClient.Post("api/filters", { + body: filter + }), + update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, { + body: filter + }), duplicate: (id: number) => appClient.Get(`api/filters/${id}/duplicate`), - toggleEnable: (id: number, enabled: boolean) => appClient.Put(`api/filters/${id}/enabled`, { enabled }), + toggleEnable: (id: number, enabled: boolean) => appClient.Put(`api/filters/${id}/enabled`, { + body: { enabled } + }), delete: (id: number) => appClient.Delete(`api/filters/${id}`) }, feeds: { find: () => appClient.Get("api/feeds"), - create: (feed: FeedCreate) => appClient.Post("api/feeds", feed), - toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }), - update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, feed), + create: (feed: FeedCreate) => appClient.Post("api/feeds", { + body: feed + }), + toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { + body: { enabled } + }), + update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, { + body: feed + }), delete: (id: number) => appClient.Delete(`api/feeds/${id}`), deleteCache: (id: number) => appClient.Delete(`api/feeds/${id}/cache`), - test: (feed: Feed) => appClient.Post("api/feeds/test", feed) + test: (feed: Feed) => appClient.Post("api/feeds/test", { + body: feed + }) }, indexers: { // returns indexer options for all currently present/enabled indexers @@ -141,19 +237,34 @@ export const APIClient = { getAll: () => appClient.Get("api/indexer"), // returns all possible indexer definitions getSchema: () => appClient.Get("api/indexer/schema"), - create: (indexer: Indexer) => appClient.Post("api/indexer", indexer), - update: (indexer: Indexer) => appClient.Put("api/indexer", indexer), + create: (indexer: Indexer) => appClient.Post("api/indexer", { + body: indexer + }), + update: (indexer: Indexer) => appClient.Put("api/indexer", { + body: indexer + }), delete: (id: number) => appClient.Delete(`api/indexer/${id}`), - testApi: (req: IndexerTestApiReq) => appClient.Post(`api/indexer/${req.id}/api/test`, req) + testApi: (req: IndexerTestApiReq) => appClient.Post(`api/indexer/${req.id}/api/test`, { + body: req + }) }, irc: { getNetworks: () => appClient.Get("api/irc"), - createNetwork: (network: IrcNetworkCreate) => appClient.Post("api/irc", network), - updateNetwork: (network: IrcNetwork) => appClient.Put(`api/irc/network/${network.id}`, network), + createNetwork: (network: IrcNetworkCreate) => appClient.Post("api/irc", { + body: network + }), + updateNetwork: (network: IrcNetwork) => appClient.Put(`api/irc/network/${network.id}`, { + body: network + }), deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`), restartNetwork: (id: number) => appClient.Get(`api/irc/network/${id}/restart`), - sendCmd: (cmd: SendIrcCmdRequest) => appClient.Post(`api/irc/network/${cmd.network_id}/cmd`, cmd), - events: (network: string) => new EventSource(`${sseBaseUrl()}api/irc/events?stream=${network}`, { withCredentials: true }) + sendCmd: (cmd: SendIrcCmdRequest) => appClient.Post(`api/irc/network/${cmd.network_id}/cmd`, { + body: cmd + }), + events: (network: string) => new EventSource( + `${sseBaseUrl()}api/irc/events?stream=${encodeRFC3986URIComponent(network)}`, + { withCredentials: true } + ) }, logs: { files: () => appClient.Get("api/logs/files"), @@ -163,51 +274,61 @@ export const APIClient = { logs: () => new EventSource(`${sseBaseUrl()}api/events?stream=logs`, { withCredentials: true }) }, notifications: { - getAll: () => appClient.Get("api/notification"), - create: (notification: Notification) => appClient.Post("api/notification", notification), - update: (notification: Notification) => appClient.Put(`api/notification/${notification.id}`, notification), + getAll: () => appClient.Get("api/notification"), + create: (notification: ServiceNotification) => appClient.Post("api/notification", { + body: notification + }), + update: (notification: ServiceNotification) => appClient.Put( + `api/notification/${notification.id}`, + { body: notification } + ), delete: (id: number) => appClient.Delete(`api/notification/${id}`), - test: (n: Notification) => appClient.Post("api/notification/test", n) + test: (notification: ServiceNotification) => appClient.Post("api/notification/test", { + body: notification + }) }, release: { find: (query?: string) => appClient.Get(`api/release${query}`), findRecent: () => appClient.Get("api/release/recent"), - findQuery: (offset?: number, limit?: number, filters?: Array) => { - const params = new URLSearchParams(); - if (offset !== undefined && offset > 0) - params.append("offset", offset.toString()); - - if (limit !== undefined) - params.append("limit", limit.toString()); + findQuery: (offset?: number, limit?: number, filters?: ReleaseFilter[]) => { + const params: Record = { + indexer: [], + push_status: [], + q: [] + }; filters?.forEach((filter) => { if (!filter.value) return; - if (filter.id == "indexer") - params.append("indexer", filter.value); - else if (filter.id === "action_status") - params.append("push_status", filter.value); - else if (filter.id == "torrent_name") - params.append("q", filter.value); + if (filter.id == "indexer") { + params["indexer"].push(filter.value); + } else if (filter.id === "action_status") { + params["push_status"].push(filter.value); + } else if (filter.id == "torrent_name") { + params["q"].push(filter.value); + } }); - return appClient.Get(`api/release?${params.toString()}`); + return appClient.Get("api/release", { + queryString: { + offset, + limit, + ...params + } + }); }, indexerOptions: () => appClient.Get("api/release/indexers"), stats: () => appClient.Get("api/release/stats"), - delete: (olderThan: number) => { - const params = new URLSearchParams(); - if (olderThan !== undefined && olderThan > 0) { - params.append("olderThan", olderThan.toString()); - } - - return appClient.Delete(`api/release?${params.toString()}`) - }, - replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`) + delete: (olderThan: number) => appClient.Delete("api/release", { + queryString: { olderThan } + }), + replayAction: (releaseId: number, actionId: number) => appClient.Post( + `api/release/${releaseId}/actions/${actionId}/retry` + ) }, updates: { check: () => appClient.Get("api/updates/check"), - getLatestRelease: () => appClient.Get("api/updates/latest") + getLatestRelease: () => appClient.Get("api/updates/latest") } }; diff --git a/web/src/forms/settings/NotificationForms.tsx b/web/src/forms/settings/NotificationForms.tsx index 38ab441..7f3edec 100644 --- a/web/src/forms/settings/NotificationForms.tsx +++ b/web/src/forms/settings/NotificationForms.tsx @@ -181,7 +181,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) { const queryClient = useQueryClient(); const createMutation = useMutation({ - mutationFn: (notification: Notification) => APIClient.notifications.create(notification), + mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification), onSuccess: () => { queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); @@ -193,16 +193,16 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) { } }); - const onSubmit = (formData: unknown) => createMutation.mutate(formData as Notification); + const onSubmit = (formData: unknown) => createMutation.mutate(formData as ServiceNotification); const testMutation = useMutation({ - mutationFn: (n: Notification) => APIClient.notifications.test(n), + mutationFn: (n: ServiceNotification) => APIClient.notifications.test(n), onError: (err) => { console.error(err); } }); - const testNotification = (data: unknown) => testMutation.mutate(data as Notification); + const testNotification = (data: unknown) => testMutation.mutate(data as ServiceNotification); const validate = (values: NotificationAddFormValues) => { const errors = {} as FormikErrors; @@ -428,7 +428,7 @@ const EventCheckBoxes = () => ( interface UpdateProps { isOpen: boolean; toggle: () => void; - notification: Notification; + notification: ServiceNotification; } interface InitialValues { @@ -449,7 +449,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: (notification: Notification) => APIClient.notifications.update(notification), + mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification), onSuccess: () => { queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); @@ -458,7 +458,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP } }); - const onSubmit = (formData: unknown) => mutation.mutate(formData as Notification); + const onSubmit = (formData: unknown) => mutation.mutate(formData as ServiceNotification); const deleteMutation = useMutation({ mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID), @@ -472,13 +472,13 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP const deleteAction = () => deleteMutation.mutate(notification.id); const testMutation = useMutation({ - mutationFn: (n: Notification) => APIClient.notifications.test(n), + mutationFn: (n: ServiceNotification) => APIClient.notifications.test(n), onError: (err) => { console.error(err); } }); - const testNotification = (data: unknown) => testMutation.mutate(data as Notification); + const testNotification = (data: unknown) => testMutation.mutate(data as ServiceNotification); const initialValues: InitialValues = { id: notification.id, diff --git a/web/src/screens/settings/Notifications.tsx b/web/src/screens/settings/Notifications.tsx index 81e7493..f1dfbb3 100644 --- a/web/src/screens/settings/Notifications.tsx +++ b/web/src/screens/settings/Notifications.tsx @@ -64,7 +64,7 @@ function NotificationSettings() {
Events
- {data && data.map((n: Notification) => ( + {data && data.map((n: ServiceNotification) => ( ))} @@ -108,7 +108,7 @@ const iconComponentMap: componentMapType = { }; interface ListItemProps { - notification: Notification; + notification: ServiceNotification; } function ListItem({ notification }: ListItemProps) { @@ -117,8 +117,8 @@ function ListItem({ notification }: ListItemProps) { const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: (notification: Notification) => APIClient.notifications.update(notification).then(() => notification), - onSuccess: (notification: Notification) => { + mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification), + onSuccess: (notification: ServiceNotification) => { toast.custom(t => ); queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); } diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index 5e89075..e29d2dd 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -12,7 +12,7 @@ type NotificationEvent = | "IRC_RECONNECTED" | "APP_UPDATE_AVAILABLE"; -interface Notification { +interface ServiceNotification { id: number; name: string; enabled: boolean;