use-api.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { useState, useEffect, useCallback } from "react";
  2. const BASE = "/api/v1";
  3. const API_INVALIDATE_EVENT = "inkos:api-invalidate";
  4. interface ApiInvalidateDetail {
  5. readonly paths: ReadonlyArray<string>;
  6. }
  7. export function buildApiUrl(path: string): string | null {
  8. const normalized = String(path ?? "").trim();
  9. if (!normalized) return null;
  10. if (normalized.startsWith(`${BASE}/`) || normalized === BASE) {
  11. return normalized;
  12. }
  13. return normalized.startsWith("/") ? `${BASE}${normalized}` : `${BASE}/${normalized}`;
  14. }
  15. export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
  16. const normalized = buildApiUrl(path);
  17. if (!normalized) return [];
  18. if (normalized === "/api/v1/books/create") {
  19. return ["/api/v1/books"];
  20. }
  21. if (normalized === "/api/v1/project") {
  22. return ["/api/v1/project"];
  23. }
  24. if (normalized.startsWith("/api/v1/project/")) {
  25. return ["/api/v1/project", normalized];
  26. }
  27. const bookAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/(write-next|draft)$/);
  28. if (bookAction) {
  29. return ["/api/v1/books", `/api/v1/books/${bookAction[1]}`];
  30. }
  31. const chapterAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/);
  32. if (chapterAction) {
  33. return ["/api/v1/books", `/api/v1/books/${chapterAction[1]}`];
  34. }
  35. if (/^\/api\/v1\/daemon\/(start|stop)$/.test(normalized)) {
  36. return ["/api/v1/daemon"];
  37. }
  38. return [];
  39. }
  40. export function invalidateApiPaths(paths: ReadonlyArray<string>): void {
  41. if (!paths.length || typeof window === "undefined") {
  42. return;
  43. }
  44. window.dispatchEvent(new CustomEvent<ApiInvalidateDetail>(API_INVALIDATE_EVENT, {
  45. detail: { paths: [...new Set(paths)] },
  46. }));
  47. }
  48. async function readErrorMessage(res: Response): Promise<string> {
  49. const contentType = res.headers.get("content-type") ?? "";
  50. if (contentType.includes("application/json")) {
  51. try {
  52. const json = await res.json() as { error?: unknown };
  53. if (typeof json.error === "string" && json.error.trim()) {
  54. return json.error;
  55. }
  56. if (
  57. json.error &&
  58. typeof json.error === "object" &&
  59. "message" in json.error &&
  60. typeof (json.error as { message?: unknown }).message === "string" &&
  61. (json.error as { message: string }).message.trim()
  62. ) {
  63. return (json.error as { message: string }).message;
  64. }
  65. } catch {
  66. // fall through
  67. }
  68. }
  69. return `${res.status} ${res.statusText}`.trim();
  70. }
  71. export async function fetchJson<T>(
  72. path: string,
  73. init: RequestInit = {},
  74. deps?: { readonly fetchImpl?: typeof fetch },
  75. ): Promise<T> {
  76. const url = buildApiUrl(path);
  77. if (!url) {
  78. throw new Error("API path is required");
  79. }
  80. const fetchImpl = deps?.fetchImpl ?? fetch;
  81. const res = await fetchImpl(url, init);
  82. if (!res.ok) {
  83. throw new Error(await readErrorMessage(res));
  84. }
  85. if (res.status === 204) {
  86. return undefined as T;
  87. }
  88. const contentType = res.headers.get("content-type") ?? "";
  89. if (!contentType.includes("application/json")) {
  90. const text = await res.text();
  91. if (!text.trim()) {
  92. return undefined as T;
  93. }
  94. return JSON.parse(text) as T;
  95. }
  96. return await res.json() as T;
  97. }
  98. export function useApi<T>(path: string) {
  99. const [data, setData] = useState<T | null>(null);
  100. const [loading, setLoading] = useState(true);
  101. const [error, setError] = useState<string | null>(null);
  102. const refetch = useCallback(async () => {
  103. const url = buildApiUrl(path);
  104. if (!url) {
  105. setData(null);
  106. setError(null);
  107. setLoading(false);
  108. return;
  109. }
  110. setLoading(true);
  111. setError(null);
  112. try {
  113. const json = await fetchJson<T>(url);
  114. setData(json);
  115. } catch (e) {
  116. setError(e instanceof Error ? e.message : String(e));
  117. } finally {
  118. setLoading(false);
  119. }
  120. }, [path]);
  121. useEffect(() => {
  122. refetch();
  123. }, [refetch]);
  124. useEffect(() => {
  125. const url = buildApiUrl(path);
  126. if (!url || typeof window === "undefined") {
  127. return;
  128. }
  129. const handleInvalidate = (event: Event) => {
  130. const detail = (event as CustomEvent<ApiInvalidateDetail>).detail;
  131. if (!detail?.paths.includes(url)) return;
  132. void refetch();
  133. };
  134. window.addEventListener(API_INVALIDATE_EVENT, handleInvalidate);
  135. return () => {
  136. window.removeEventListener(API_INVALIDATE_EVENT, handleInvalidate);
  137. };
  138. }, [path, refetch]);
  139. return { data, loading, error, refetch, mutate: setData };
  140. }
  141. export async function postApi<T>(path: string, body?: unknown): Promise<T> {
  142. const result = await fetchJson<T>(path, {
  143. method: "POST",
  144. headers: { "Content-Type": "application/json" },
  145. body: body ? JSON.stringify(body) : undefined,
  146. });
  147. invalidateApiPaths(deriveInvalidationPaths(path));
  148. return result;
  149. }
  150. export async function putApi<T>(path: string, body?: unknown): Promise<T> {
  151. const result = await fetchJson<T>(path, {
  152. method: "PUT",
  153. headers: { "Content-Type": "application/json" },
  154. body: body ? JSON.stringify(body) : undefined,
  155. });
  156. invalidateApiPaths(deriveInvalidationPaths(path));
  157. return result;
  158. }