diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d76528..f65032d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525). - Job state sidebar: only highlight `Running` when the selected jobs state is actually running, even with retained search filters in the URL. [Fixes #526](https://github.com/riverqueue/riverui/issues/526). [PR #527](https://github.com/riverqueue/riverui/pull/527). - Job delete actions: require confirmation before deleting a single job or selected jobs in bulk. [Fixes #545](https://github.com/riverqueue/riverui/issues/545). [PR #546](https://github.com/riverqueue/riverui/pull/546). +- Workflow detail: show the backend's not-found message instead of crashing when a workflow ID does not exist. [PR #564](https://github.com/riverqueue/riverui/pull/564). ## [v0.15.0] - 2026-02-26 diff --git a/src/utils/api.test.ts b/src/utils/api.test.ts new file mode 100644 index 00000000..f5405a1c --- /dev/null +++ b/src/utils/api.test.ts @@ -0,0 +1,67 @@ +import { getWorkflow, getWorkflowKey } from "@services/workflows"; +import { NotFoundError } from "@utils/api"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("API 404 handling", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("surfaces workflow not found messages from the current API envelope", async () => { + document.body.innerHTML = + ''; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + message: "Workflow not found: missing-workflow.", + }), + { + headers: { "Content-Type": "application/json" }, + status: 404, + }, + ), + ); + + await expect( + getWorkflow({ + client: undefined as never, + direction: "forward", + meta: undefined, + pageParam: undefined, + queryKey: getWorkflowKey("missing-workflow"), + signal: new AbortController().signal, + }), + ).rejects.toEqual( + new NotFoundError("Workflow not found: missing-workflow."), + ); + }); + + it("rejects successful API responses that return the app shell", async () => { + document.body.innerHTML = + ''; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("River", { + headers: { "Content-Type": "text/html; charset=utf-8" }, + status: 200, + }), + ); + + await expect( + getWorkflow({ + client: undefined as never, + direction: "forward", + meta: undefined, + pageParam: undefined, + queryKey: getWorkflowKey("wf-with-missing-api-route"), + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ + message: + "Expected JSON response from http://example.test/api/pro/workflows/wf-with-missing-api-route, received text/html; charset=utf-8.", + status: 200, + }); + }); +}); diff --git a/src/utils/api.ts b/src/utils/api.ts index fc1eca88..240c91fa 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -28,13 +28,24 @@ export const API = { }; type APIError = { - msg: string; + msg?: string; }; type APIErrorResponse = { - error: APIError; + error?: APIError; + message?: string; }; +export class APIResponseError extends Error { + constructor( + message: string, + readonly status: number, + readonly statusText: string, + ) { + super(message); + } +} + export class NotFoundError extends Error {} export function APIUrl(path: string, query?: URLSearchParams) { @@ -48,17 +59,55 @@ export function APIUrl(path: string, query?: URLSearchParams) { return `${riverApiBaseUrl}${path}${query ? `?${query}` : ""}`; } +async function parseJSONResponse( + response: Response, + url: string, +): Promise { + const contentType = response.headers.get("Content-Type"); + if (!contentType?.includes("application/json")) { + const received = contentType || "unknown content type"; + throw new APIResponseError( + `Expected JSON response from ${url}, received ${received}.`, + response.status, + response.statusText, + ); + } + + return (await response.json()) as TResponse; +} + async function request( url: string, config: RequestInit, ): Promise { - const response = await fetch(url, config); + const headers = new Headers(config.headers); + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + + const response = await fetch(url, { ...config, headers }); if (response.ok) { - return await response.json(); - } else if (response.status == 404) { + return await parseJSONResponse(response, url); + } + + const message = await responseErrorMessage(response); + if (response.status == 404) { + throw new NotFoundError(message || "Resource not found."); + } + + throw new APIResponseError( + message || `Request failed with status ${response.status}.`, + response.status, + response.statusText, + ); +} + +async function responseErrorMessage(response: Response): Promise { + const contentType = response.headers.get("Content-Type"); + if (contentType?.includes("application/json")) { const json = (await response.json()) as APIErrorResponse; - throw new NotFoundError(json.error.msg); - } else { - throw new Error(`unhandled response with status ${response.status}`); + return json.message ?? json.error?.msg ?? ""; } + + return (await response.text()).trim(); }