From 83befff7f76e729f664a1df1cdd74e1c79dd6783 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Mon, 27 May 2024 13:15:55 +0800 Subject: [PATCH] refactor: typesafe api call (#16) --- eslint.config.mjs | 1 + src/renderer/src/hono.ts | 734 ++++++++++++++++++++++ src/renderer/src/lib/types.ts | 22 +- src/renderer/src/queries/api-fetch.ts | 26 + src/renderer/src/queries/subscriptions.ts | 14 +- 5 files changed, 779 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/hono.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index c1c22b643..bc4d8b46f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ export default defineConfig( arrowParens: true, braceStyle: "1tbs", }, + ignores: ["src/renderer/src/hono.ts"], }, { rules: { diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts new file mode 100644 index 000000000..72e38f4d0 --- /dev/null +++ b/src/renderer/src/hono.ts @@ -0,0 +1,734 @@ +import * as hono_hono_base from 'hono/hono-base'; +import * as hono_utils_http_status from 'hono/utils/http-status'; +import * as hono from 'hono'; + +declare const routes: hono_hono_base.HonoBase}`]: { + input: Partial; + output: any; + outputFormat: string; + status: hono_utils_http_status.StatusCode; + }; + }; +} & { + "/entries": { + $post: { + input: { + json: { + feedId?: string | undefined; + view?: number | undefined; + category?: string | undefined; + limit?: number | undefined; + offset?: number | undefined; + feedIdList?: string[] | undefined; + }; + }; + output: { + code: 0; + total: number; + data?: { + description: string | null; + title: string | null; + id: string; + feeds: { + description: string | null; + title: string | null; + id: string; + image: string | null; + url: string; + siteUrl: string | null; + checkedAt: Date; + nextCheckAt: Date; + lastModifiedHeader: string | null; + etagHeader: string | null; + ttl: number | null; + errorMessage: string | null; + errorAt: Date | null; + }; + url: string | null; + feedId: string; + guid: string; + author: string | null; + changedAt: Date; + publishedAt: Date; + images: string | null; + categories: string | null; + collected: boolean; + read: boolean; + }[] | undefined; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + json: { + feedId?: string | undefined; + view?: number | undefined; + category?: string | undefined; + limit?: number | undefined; + offset?: number | undefined; + feedIdList?: string[] | undefined; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + $get: { + input: { + query: { + id: string; + }; + }; + output: { + code: 0; + data?: { + description: string | null; + title: string | null; + content: string | null; + id: string; + feeds: { + description: string | null; + title: string | null; + id: string; + image: string | null; + url: string; + siteUrl: string | null; + checkedAt: Date; + nextCheckAt: Date; + lastModifiedHeader: string | null; + etagHeader: string | null; + ttl: number | null; + errorMessage: string | null; + errorAt: Date | null; + }; + url: string | null; + feedId: string; + guid: string; + author: string | null; + changedAt: Date; + publishedAt: Date; + images: string | null; + categories: string | null; + collected: boolean; + read: boolean; + } | undefined; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + query: { + id: string; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + }; +} & { + "/subscriptions": { + $get: { + input: { + query: { + view?: string | undefined; + }; + }; + output: { + code: 0; + data?: { + title: string | null; + userId: string; + feeds: { + description: string | null; + title: string | null; + id: string; + image: string | null; + url: string; + siteUrl: string | null; + checkedAt: Date; + nextCheckAt: Date; + lastModifiedHeader: string | null; + etagHeader: string | null; + ttl: number | null; + errorMessage: string | null; + errorAt: Date | null; + }; + feedId: string; + view: number; + category: string | null; + isPrivate: boolean | null; + }[] | undefined; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + query: { + view?: string | undefined; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + $post: { + input: { + json: { + url: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 0; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + json: { + url: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 2; + error: string; + }; + outputFormat: "json"; + status: 400; + } | { + input: { + json: { + url: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + $delete: { + input: { + json: { + csrfToken: string; + url?: string | undefined; + feedId?: string | undefined; + }; + }; + output: { + code: 0; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + json: { + csrfToken: string; + url?: string | undefined; + feedId?: string | undefined; + }; + }; + output: { + code: 2; + error: string; + }; + outputFormat: "json"; + status: 400; + } | { + input: { + json: { + csrfToken: string; + url?: string | undefined; + feedId?: string | undefined; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + $patch: { + input: { + json: { + feedId: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 0; + }; + outputFormat: "json"; + status: 200; + } | { + input: { + json: { + feedId: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 2; + error: string; + }; + outputFormat: "json"; + status: 400; + } | { + input: { + json: { + feedId: string; + view: number; + csrfToken: string; + category?: string | null | undefined; + isPrivate?: boolean | null | undefined; + }; + }; + output: { + code: 1; + error: "Unauthorized"; + }; + outputFormat: "json"; + status: 401; + }; + }; +}, "/">; +type AppType = typeof routes; + +export type { AppType }; diff --git a/src/renderer/src/lib/types.ts b/src/renderer/src/lib/types.ts index 233a4c885..b82271b4e 100644 --- a/src/renderer/src/lib/types.ts +++ b/src/renderer/src/lib/types.ts @@ -1,3 +1,6 @@ +import type { apiClient } from "@renderer/queries/api-fetch" +import type { InferResponseType } from "hono/client" + export type ActiveList = { level: string id: string | number @@ -22,16 +25,15 @@ export type FeedResponse = { errorMessage?: string } -export type SubscriptionResponse = { - userId: string - feedId: string - view: number - category?: string - title?: string - isPrivate?: boolean - unread?: number - feeds: FeedResponse -}[] +export type SubscriptionResponse = Array< + Exclude< + Extract< + InferResponseType, + { code: 0 } + >["data"], + undefined + >[number] & { unread?: number } +> export type EntriesResponse = { author?: string diff --git a/src/renderer/src/queries/api-fetch.ts b/src/renderer/src/queries/api-fetch.ts index 58213d6a8..ddec931f2 100644 --- a/src/renderer/src/queries/api-fetch.ts +++ b/src/renderer/src/queries/api-fetch.ts @@ -1,4 +1,6 @@ import { getCsrfToken } from "@hono/auth-js/react" +import type { AppType } from "@renderer/hono" +import { hc } from "hono/client" import { ofetch } from "ofetch" export const apiFetch = ofetch.create({ @@ -18,3 +20,27 @@ export const apiFetch = ofetch.create({ } }, }) + +export const apiClient = hc( + import.meta.env.VITE_API_URL, + { + fetch: async (...args) => { + const options = args[1] as RequestInit + options.credentials = "include" + if (options.method && options.method.toLowerCase() !== "get") { + const csrfToken = await getCsrfToken() + if (!options.body) { + options.body = JSON.stringify({}) + } + if (options.body instanceof FormData) { + options.body.append("csrfToken", csrfToken) + } else { + if (typeof options.body === "string") { + options.body = JSON.stringify({ csrfToken, ...JSON.parse(options.body) }) + } + } + } + return fetch(args[0], options) + }, + }, +) diff --git a/src/renderer/src/queries/subscriptions.ts b/src/renderer/src/queries/subscriptions.ts index 6bf4b2742..b1d615f4c 100644 --- a/src/renderer/src/queries/subscriptions.ts +++ b/src/renderer/src/queries/subscriptions.ts @@ -1,5 +1,5 @@ import type { SubscriptionResponse } from "@renderer/lib/types" -import { apiFetch } from "@renderer/queries/api-fetch" +import { apiClient, apiFetch } from "@renderer/queries/api-fetch" import { useQuery } from "@tanstack/react-query" import { parse } from "tldts" @@ -16,13 +16,11 @@ export const useSubscriptions = (view?: number) => useQuery({ queryKey: ["subscriptions", view], queryFn: async () => { - const { data: subscriptions } = await apiFetch<{ - data: SubscriptionResponse - }>("/subscriptions", { - query: { - view, - }, - }) + const res = await (await apiClient.subscriptions.$get({ query: { view: String(view) } })).json() + if (res.code === 1) { + throw new Error(res.error) + } + const subscriptions = res.data as SubscriptionResponse const categories = { list: {},