diff --git a/package.json b/package.json index 26927397d..06d0e3cf6 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "sync": "svelte-kit sync", "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", - "test": "npm run test:unit && npm run test:browser", + "test": "TZ=UTC vitest run", "test:browser": "playwright test", "test:unit": "TZ=UTC vitest run", "test:watch": " TZ=UTC vitest watch", diff --git a/src/lib/api/addons.ts b/src/lib/api/addons.ts index 9f36b65b2..1a2cd5658 100644 --- a/src/lib/api/addons.ts +++ b/src/lib/api/addons.ts @@ -1,11 +1,12 @@ import type { Page } from "@/api/types/common"; import type { AddOnParams } from "@/api/types/addons"; import type { AddOnListItem, Event, Run, AddOnPayload } from "@/addons/types"; +import type { APIResponse, ValidationError } from "./types"; import Ajv, { type DefinedError } from "ajv"; import addFormats from "ajv-formats"; import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js"; -import { isErrorCode } from "../utils/api"; +import { getApiResponse, isErrorCode } from "../utils/api"; // todo i18n export const CATEGORIES = [ @@ -28,31 +29,28 @@ export const eventValues = { upload: 4, }; +/** + * List add-ons, with optional filters + */ export async function getAddons( params: AddOnParams = {}, fetch = globalThis.fetch, -): Promise> { +): Promise, unknown>> { const endpoint = new URL("addons/", BASE_API_URL); Object.entries(params).forEach(([key, value]) => { endpoint.searchParams.set(key, String(value)); }); - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); - - if (!resp) { - throw new Error("API error"); - } + const resp = await fetch(endpoint, { credentials: "include" }); - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - return resp.json(); + return getApiResponse>(resp); } +/** + * List pinned add-ons + */ export async function getPinnedAddons( fetch = globalThis.fetch, -): Promise> { +): Promise, unknown>> { return getAddons({ active: true }, fetch); } @@ -62,9 +60,9 @@ export async function getAddon( fetch = globalThis.fetch, ): Promise { const repository = [owner, repo].join("/"); - const addons = await getAddons({ repository }, fetch); + const { data: addons, error } = await getAddons({ repository }, fetch); // there should only be one result, if the addon exists - if (addons.results.length < 1) { + if (error || addons.results.length < 1) { return null; } return addons.results[0]; @@ -73,22 +71,12 @@ export async function getAddon( export async function getEvent( id: number, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`addon_events/${id}/?expand=addon`, BASE_API_URL); - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); - - if (!resp) { - throw new Error("API error"); - } - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } + const resp = await fetch(endpoint, { credentials: "include" }); - return resp.json(); + return getApiResponse(resp); } /** @@ -102,25 +90,15 @@ export async function history( per_page?: number; } = {}, fetch = globalThis.fetch, -): Promise> { +): Promise, unknown>> { const endpoint = new URL("addon_runs/?expand=addon", BASE_API_URL); for (const [k, v] of Object.entries(params)) { endpoint.searchParams.set(k, String(v)); } - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); + const resp = await fetch(endpoint, { credentials: "include" }); - if (!resp) { - throw new Error("API error"); - } - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse>(resp); } /** @@ -129,40 +107,30 @@ export async function history( export async function scheduled( params: { cursor?: string; addon?: number; per_page?: number } = {}, fetch = globalThis.fetch, -): Promise> { +): Promise, unknown>> { const endpoint = new URL("addon_events/?expand=addon", BASE_API_URL); for (const [k, v] of Object.entries(params)) { endpoint.searchParams.set(k, String(v)); } - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); - - if (!resp) { - throw new Error("API error"); - } - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } + const resp = await fetch(endpoint, { credentials: "include" }); - return resp.json(); + return getApiResponse>(resp); } // dispatching /** - * Create or schedule a single add-on run, returning the HTTP response + * Create or schedule a single add-on run */ export async function dispatch( payload: AddOnPayload, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const path = payload.event ? "addon_events/" : "addon_runs/"; const endpoint = new URL(path, BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "POST", headers: { @@ -172,6 +140,8 @@ export async function dispatch( }, body: JSON.stringify(payload), }); + + return getApiResponse(resp); } /** @@ -182,9 +152,9 @@ export async function update( payload: AddOnPayload, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`addon_events/${event_id}/`, BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "PUT", headers: { @@ -194,6 +164,8 @@ export async function update( }, body: JSON.stringify(payload), }); + + return getApiResponse(resp); } /** diff --git a/src/lib/api/collaborators.ts b/src/lib/api/collaborators.ts index 6f6b43de1..a4849714c 100644 --- a/src/lib/api/collaborators.ts +++ b/src/lib/api/collaborators.ts @@ -1,8 +1,13 @@ // manage users in a project -import type { ProjectAccess, ProjectUser } from "./types"; +import type { + APIResponse, + ProjectAccess, + ProjectUser, + ValidationError, +} from "./types"; import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js"; -import { getAll, isErrorCode } from "$lib/utils/api"; +import { getAll, getApiResponse } from "$lib/utils/api"; export async function list(project_id: number, fetch = globalThis.fetch) { const endpoint = new URL( @@ -13,12 +18,15 @@ export async function list(project_id: number, fetch = globalThis.fetch) { return getAll(endpoint, undefined, fetch); } +/** + * Add a collaborator to a project + */ export async function add( project_id: number, user: { email: string; access: ProjectAccess }, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`projects/${project_id}/users/`, BASE_API_URL); const resp = await fetch(endpoint, { @@ -32,26 +40,19 @@ export async function add( method: "POST", }).catch(console.error); - if (!resp) { - throw new Error("API unavailable"); - } - - if (isErrorCode(resp.status)) { - const data = await resp.json(); - console.error(data); - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } +/** + * Update a user's permissions within a project + */ export async function update( project_id: number, user_id: number, access: ProjectAccess, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL( `projects/${project_id}/users/${user_id}/`, BASE_API_URL, @@ -68,17 +69,7 @@ export async function update( method: "PATCH", }).catch(console.error); - if (!resp) { - throw new Error("API unavailable"); - } - - if (isErrorCode(resp.status)) { - const data = await resp.json(); - console.error(data); - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } export async function remove( @@ -86,13 +77,13 @@ export async function remove( user_id: number, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL( `projects/${project_id}/users/${user_id}/`, BASE_API_URL, ); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", headers: { "Content-type": "application/json", @@ -100,5 +91,7 @@ export async function remove( Referer: APP_URL, }, method: "DELETE", - }); + }).catch(console.error); + + return getApiResponse(resp); } diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index d9a989b34..de962bc39 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -2,6 +2,7 @@ * Lots of duplicated code here that should get consolidated at some point. */ import type { + APIResponse, Data, DataUpdate, Document, @@ -17,10 +18,10 @@ import type { ReadMode, WriteMode, ViewerMode, + ValidationError, } from "./types"; import { writable, type Writable } from "svelte/store"; -import { error } from "@sveltejs/kit"; import { DEFAULT_EXPAND } from "@/api/common.js"; import { isOrg } from "./accounts"; import { @@ -30,7 +31,7 @@ import { DC_BASE, EMBED_URL, } from "@/config/config.js"; -import { isErrorCode, getPrivateAsset } from "../utils/index"; +import { isErrorCode, getPrivateAsset, getApiResponse } from "../utils/api"; export const READING_MODES = new Set([ "document", @@ -60,7 +61,7 @@ export async function search( version: "2.0", }, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL("documents/search/", BASE_API_URL); endpoint.searchParams.set("expand", DEFAULT_EXPAND); @@ -72,14 +73,11 @@ export async function search( } } - const resp = await fetch(endpoint, { credentials: "include" }); - - if (isErrorCode(resp.status)) { - console.error(await resp.json()); - error(resp.status, resp.statusText); - } + const resp = await fetch(endpoint, { credentials: "include" }).catch( + console.error, + ); - return resp.json(); + return getApiResponse(resp); } /** @@ -89,7 +87,7 @@ export async function search( export async function get( id: string | number, fetch: typeof globalThis.fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`documents/${id}.json`, BASE_API_URL); const expand = [ "user", @@ -105,20 +103,12 @@ export async function get( console.error, ); - // backend error, not much we can do - if (!resp) { - error(500); - } - - if (isErrorCode(resp.status)) { - error(resp.status, resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } /** * Get text for a document. It may be a private asset, which requires a two-step fetch. + * Errors will produce an empty response. */ export async function text( document: Document, @@ -177,15 +167,12 @@ export async function textPositions( * If documents contain a `file_url` property, the server will attempt to fetch and upload that file. * Otherwise, the response will contain all documents fields plus a `presigned_url` field, which should * be passed to `upload` to store the actual file. - * - * @async - * @export */ export async function create( documents: DocumentUpload[], csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL("documents/", BASE_API_URL); const resp = await fetch(endpoint, { @@ -197,22 +184,15 @@ export async function create( Referer: APP_URL, }, body: JSON.stringify(documents), - }); - - if (isErrorCode(resp.status)) { - throw new Error(await resp.text()); - } + }).catch(console.error); - return resp.json() as Promise; + return getApiResponse(resp); } /** * Upload file data to a presigned_url on cloud storage. * Use this after running `create` to add documents to the database. * This function is a very thin wrapper around fetch. - * - * @async - * @export */ export async function upload( presigned_url: URL, @@ -230,9 +210,7 @@ export async function upload( /** * Tell the backend to begin processing a batch of documents. - * - * @async - * @export + * Only errors are returned here. A null response means success. */ export async function process( documents: { @@ -242,10 +220,10 @@ export async function process( }[], csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL("documents/process/", BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "POST", headers: { @@ -254,7 +232,9 @@ export async function process( Referer: APP_URL, }, body: JSON.stringify(documents), - }); + }).catch(console.error); + + return getApiResponse(resp); } /** @@ -264,46 +244,46 @@ export async function cancel( document: Document, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const processing: Set = new Set(["pending", "readable"]); // non-processing status is a no-op - if (!processing.has(document.status)) return; + if (!processing.has(document.status)) return {}; const endpoint = new URL(`documents/${document.id}/process/`, BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "DELETE", headers: { [CSRF_HEADER_NAME]: csrf_token, Referer: APP_URL, }, - }); + }).catch(console.error); + + return getApiResponse(resp); } /** * Delete a document. There is no undo. - * - * @param id - * @param csrf_token - * @param fetch */ export async function destroy( id: string | number, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`documents/${id}/`, BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "DELETE", headers: { [CSRF_HEADER_NAME]: csrf_token, Referer: APP_URL, }, - }); + }).catch(console.log); + + return getApiResponse(resp); } /** @@ -317,18 +297,20 @@ export async function destroy_many( ids: (string | number)[], csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`documents/`, BASE_API_URL); endpoint.searchParams.set("id__in", ids.join(",")); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "DELETE", headers: { [CSRF_HEADER_NAME]: csrf_token, Referer: APP_URL, }, - }); + }).catch(console.log); + + return getApiResponse(resp); } /** @@ -345,7 +327,7 @@ export async function edit( data: Partial, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`documents/${id}/`, BASE_API_URL); const resp = await fetch(endpoint, { @@ -359,38 +341,25 @@ export async function edit( body: JSON.stringify(data), }).catch(console.error); - if (!resp) { - throw new Error("API unavailable"); - } - - if (isErrorCode(resp.status)) { - const { data } = await resp.json(); - throw new Error(data); - } - - return resp.json(); + return getApiResponse(resp); } /** * Bulk edit top-level fields of an array of documents. * Each document *must* have an `id` property. * - * This returns the response directly, since a successful update - * will result in invalidation and refetching data. - * * @param documents * @param csrf_token * @param fetch - * @returns {Promise} */ export async function edit_many( documents: Partial[], csrf_token: string, fetch = globalThis.fetch, -): Promise { +) { const endpoint = new URL("documents/", BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "PATCH", headers: { @@ -399,7 +368,9 @@ export async function edit_many( Referer: APP_URL, }, body: JSON.stringify(documents), - }); + }).catch(console.error); + + return getApiResponse(resp); } /** @@ -411,7 +382,7 @@ export async function add_tags( values: string[], csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`documents/${doc_id}/data/${key}/`, BASE_API_URL); const data: DataUpdate = { values }; @@ -426,23 +397,18 @@ export async function add_tags( body: JSON.stringify(data), }).catch(console.error); - if (!resp) { - throw new Error("API error"); - } - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } +/** + * Redact a document. This is a fire-and-forget operation, so we return the response directly. + */ export async function redact( id: number | string, redactions: Redaction[], csrf_token: string, fetch = globalThis.fetch, -) { +): Promise { const endpoint = new URL(`documents/${id}/redactions/`, BASE_API_URL); // redaction is a fire-and-reprocess method, so all we have to go on is a response @@ -460,9 +426,6 @@ export async function redact( /** * Get pending documents. This returns an empty array for any error. - * - * @param {fetch} fetch - * @returns {Promise} */ export async function pending(fetch = globalThis.fetch): Promise { const endpoint = new URL("documents/pending/", BASE_API_URL); diff --git a/src/lib/api/flatpages.ts b/src/lib/api/flatpages.ts index 3f23c884a..199a8c2b7 100644 --- a/src/lib/api/flatpages.ts +++ b/src/lib/api/flatpages.ts @@ -1,7 +1,9 @@ -import { BASE_API_URL } from "@/config/config"; import type { Flatpage } from "./types"; +import { BASE_API_URL } from "@/config/config"; -export async function getTipOfDay(fetch = globalThis.fetch): Promise { +export async function getTipOfDay( + fetch = globalThis.fetch, +): Promise { const endpoint = new URL("flatpages/tipofday/", BASE_API_URL); try { const resp = await fetch(endpoint, { credentials: "include" }); diff --git a/src/lib/api/notes.ts b/src/lib/api/notes.ts index 60bd802f6..c8b17c380 100644 --- a/src/lib/api/notes.ts +++ b/src/lib/api/notes.ts @@ -1,4 +1,11 @@ -import type { BBox, Document, Note, NoteResults } from "./types"; +import type { + APIResponse, + BBox, + Document, + Note, + NoteResults, + ValidationError, +} from "./types"; import { APP_URL, @@ -8,16 +15,14 @@ import { } from "@/config/config.js"; import { DEFAULT_EXPAND } from "@/api/common.js"; import { canonicalUrl } from "./documents"; -import { isErrorCode } from "../utils"; +import { getApiResponse, isErrorCode } from "../utils"; /** * Load notes from a single document from the API * @example https://api.www.documentcloud.org/api/documents/2622/notes/ + * @deprecated */ -export async function list( - doc_id: number, - fetch = globalThis.fetch, -): Promise { +export async function list(doc_id: number, fetch = globalThis.fetch) { const endpoint = new URL(`documents/${doc_id}/notes/`, BASE_API_URL); endpoint.searchParams.set("expand", DEFAULT_EXPAND); @@ -39,7 +44,7 @@ export async function get( doc_id: number, note_id: number, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL( `documents/${doc_id}/notes/${note_id}/`, BASE_API_URL, @@ -49,30 +54,20 @@ export async function get( const resp = await fetch(endpoint, { credentials: "include" }); - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } // writing methods /** * Create a new note - * - * @param doc_id Document ID - * @param note Note data - * @param csrf_token - * @param fetch - * @returns {Note} */ export async function create( doc_id: string | number, note: Partial, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`documents/${doc_id}/notes/`, BASE_API_URL); const resp = await fetch(endpoint, { @@ -84,28 +79,13 @@ export async function create( Referer: APP_URL, }, method: "POST", - }).catch(console.error); - - if (!resp) { - throw new Error("API error"); - } - - if (!resp.ok) { - throw new Error(resp.statusText); - } + }); - return resp.json(); + return getApiResponse(resp); } /** * Update a note and return the updated version - * - * @param doc_id Document ID - * @param note_id Note ID - * @param note Data to update (partial is fine) - * @param csrf_token - * @param fetch - * @returns {Note} */ export async function update( doc_id: string | number, @@ -113,7 +93,7 @@ export async function update( note: Partial, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL( `documents/${doc_id}/notes/${note_id}/`, BASE_API_URL, @@ -128,40 +108,26 @@ export async function update( Referer: APP_URL, }, method: "PATCH", - }).catch(console.error); - - if (!resp) { - throw new Error("API error"); - } - - if (!resp.ok) { - throw new Error(resp.statusText); - } + }); - return resp.json(); + return getApiResponse(resp); } /** - * Delete a note and return the HTTP response - * - * @param doc_id Document ID - * @param note_id Note ID - * @param csrf_token - * @param fetch - * @returns {Response} + * Delete a note */ export async function remove( doc_id: string | number, note_id: string | number, csrf_token: string, fetch = globalThis.fetch, -): Promise { +) { const endpoint = new URL( `documents/${doc_id}/notes/${note_id}/`, BASE_API_URL, ); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", headers: { "Content-type": "application/json", @@ -170,6 +136,8 @@ export async function remove( }, method: "DELETE", }); + + return getApiResponse(resp); } /** diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 7fdcaefe1..f15883113 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -1,77 +1,57 @@ // api methods for projects import type { Page } from "@/api/types"; import type { - APIError, + APIResponse, Document, Project, ProjectMembershipItem, ProjectResults, + ValidationError, } from "./types"; import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js"; -import { getAll, isErrorCode } from "$lib/utils/api"; +import { getAll, getApiResponse, isErrorCode } from "$lib/utils/api"; /** * Get a single project - * - * @export */ export async function get( id: number, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`projects/${id}/`, BASE_API_URL); - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); - - if (!resp) { - throw new Error("API error"); - } + const resp = await fetch(endpoint, { credentials: "include" }); - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } /** * Get a page of projects - * - * @export */ export async function list( params: Record = {}, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL("projects/", BASE_API_URL); for (const [k, v] of Object.entries(params)) { endpoint.searchParams.set(k, String(v)); } - const resp = await fetch(endpoint, { credentials: "include" }).catch( - console.error, - ); - - if (!resp) { - throw new Error("API error"); - } + const resp = await fetch(endpoint, { credentials: "include" }); - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } - - return resp.json(); + return getApiResponse(resp); } +/** + * Get all projects a user has access to -- owned or shared + */ export async function getForUser( userId: number, query?: string, fetch = globalThis.fetch, -) { +): Promise { const endpoint = new URL("projects/", BASE_API_URL); endpoint.searchParams.set("user", String(userId)); if (query) { @@ -114,7 +94,7 @@ export async function pinProject( pinned = true, csrf_token: string, fetch = globalThis.fetch, -): Promise { +) { const endpoint = new URL(`projects/${id}/`, BASE_API_URL); const options: RequestInit = { credentials: "include", @@ -129,26 +109,15 @@ export async function pinProject( const resp = await fetch(endpoint, { ...options, body: JSON.stringify({ pinned }), - }).catch(console.error); - - if (!resp) { - throw new Error("API error"); - } - - if (isErrorCode(resp.status)) { - throw new Error(resp.statusText); - } + }); - return resp.json(); + return getApiResponse(resp); } // writable methods + /** * Create a new project - * - * @param project - * @param csrf_token - * @param fetch */ export async function create( project: { @@ -159,7 +128,7 @@ export async function create( }, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL("projects/", BASE_API_URL); const resp = await fetch(endpoint, { body: JSON.stringify(project), @@ -170,18 +139,9 @@ export async function create( Referer: APP_URL, }, method: "POST", - }).catch(console.error); - - if (!resp) { - throw new Error("API unavailable"); - } - - if (isErrorCode(resp.status)) { - const { data } = await resp.json(); - throw new Error(data); - } + }); - return resp.json(); + return getApiResponse(resp); } export async function edit( @@ -189,7 +149,7 @@ export async function edit( data: Partial, csrf_token: string, fetch = globalThis.fetch, -) { +): Promise> { const endpoint = new URL(`projects/${project_id}/`, BASE_API_URL); const resp = await fetch(endpoint, { @@ -201,26 +161,13 @@ export async function edit( Referer: APP_URL, }, method: "PATCH", - }).catch(console.error); - - if (!resp) { - throw new Error("API unavailable"); - } - - if (isErrorCode(resp.status)) { - const { data } = await resp.json(); - throw new Error(data); - } + }); - return resp.json(); + return getApiResponse(resp); } /** * Delete a project. There is no undo. - * - * @param project_id - * @param csrf_token - * @param fetch */ export async function destroy( project_id: number, @@ -229,7 +176,7 @@ export async function destroy( ) { const endpoint = new URL(`projects/${project_id}/`, BASE_API_URL); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", headers: { "Content-type": "application/json", @@ -238,6 +185,8 @@ export async function destroy( }, method: "DELETE", }); + + return getApiResponse(resp); } /** @@ -253,7 +202,7 @@ export async function add( documents: (string | number)[], csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL(`projects/${project_id}/documents/`, BASE_API_URL); const data = documents.map((document) => ({ document })); const resp = await fetch(endpoint, { @@ -265,32 +214,20 @@ export async function add( Referer: APP_URL, }, method: "POST", - }).catch(console.error); - - if (!resp) { - throw new Error("API unavailable"); - } - - // trying out some new error handling - if (isErrorCode(resp.status)) { - return { - error: { - status: resp.status, - message: resp.statusText, - ...(await resp.json()), - }, - }; - } + }); - return resp.json(); + return getApiResponse(resp); } +/** + * Remove documents from a project + */ export async function remove( project_id: number, documents: (string | number)[], csrf_token: string, fetch = globalThis.fetch, -): Promise { +) { const endpoint = new URL(`projects/${project_id}/documents/`, BASE_API_URL); endpoint.searchParams.set("document_id__in", documents.join(",")); @@ -302,24 +239,9 @@ export async function remove( Referer: APP_URL, }, method: "DELETE", - }).catch(console.error); - - if (!resp) { - throw new Error("API unavailable"); - } - - // trying out some new error handling - if (isErrorCode(resp.status)) { - return { - error: { - status: resp.status, - message: resp.statusText, - ...(await resp.json()), - }, - }; - } + }); - return null; + return getApiResponse(resp); } /** diff --git a/src/lib/api/sections.ts b/src/lib/api/sections.ts index 1abe30cf2..25a593713 100644 --- a/src/lib/api/sections.ts +++ b/src/lib/api/sections.ts @@ -1,13 +1,18 @@ -import type { Section, SectionResults } from "./types"; +import type { + APIResponse, + Section, + SectionResults, + ValidationError, +} from "./types"; import { APP_URL, BASE_API_URL, CSRF_HEADER_NAME } from "@/config/config.js"; +import { getApiResponse } from "../utils"; /** * Load sections from a single document from the API * Example: https://api.www.documentcloud.org/api/documents/24028981/sections/ * - * @async - * @export + * @deprecated */ export async function list( doc_id: string | number, @@ -34,14 +39,13 @@ export async function list( * Load a single section from a single document from the API * Example: https://api.www.documentcloud.org/api/documents/24028981/sections/33933/ * - * @async - * @export + * @deprecated */ export async function get( doc_id: string | number, section_id: number, fetch = globalThis.fetch, -): Promise
{ +) { const endpoint = new URL( `documents/${doc_id}/sections/${section_id}.json`, BASE_API_URL, @@ -66,19 +70,13 @@ export async function get( /** * Add a section to a document - * - * @param doc_id Document ID - * @param section data - * @param csrf_token - * @param fetch - * @returns {Section} */ export async function create( doc_id: string | number, section: { page_number: number; title: string }, csrf_token: string, fetch = globalThis.fetch, -): Promise
{ +): Promise> { const endpoint = new URL(`documents/${doc_id}/sections/`, BASE_API_URL); const resp = await fetch(endpoint, { @@ -90,28 +88,13 @@ export async function create( Referer: APP_URL, }, method: "POST", - }).catch(console.error); - - if (!resp) { - throw new Error("API error"); - } - - if (!resp.ok) { - throw new Error(resp.statusText); - } + }); - return resp.json(); + return getApiResponse(resp); } /** * Update a section on a document - * - * @param doc_id Document ID - * @param section_id Section ID - * @param section data - * @param csrf_token - * @param fetch - * @returns {Section} */ export async function update( doc_id: string | number, @@ -119,7 +102,7 @@ export async function update( section: { page_number?: number; title?: string }, csrf_token: string, fetch = globalThis.fetch, -): Promise
{ +): Promise> { const endpoint = new URL( `documents/${doc_id}/sections/${section_id}/`, BASE_API_URL, @@ -134,39 +117,26 @@ export async function update( Referer: APP_URL, }, method: "PATCH", - }).catch(console.error); - - if (!resp) { - throw new Error("API error"); - } - - if (!resp.ok) { - throw new Error(resp.statusText); - } + }); - return resp.json(); + return getApiResponse(resp); } /** * Delete a section from a document. - * - * @param doc_id Document ID - * @param section_id Section ID - * @param csrf_token - * @param fetch */ export async function remove( doc_id: string | number, section_id: string | number, csrf_token: string, fetch = globalThis.fetch, -): Promise { +): Promise> { const endpoint = new URL( `documents/${doc_id}/sections/${section_id}/`, BASE_API_URL, ); - return fetch(endpoint, { + const resp = await fetch(endpoint, { credentials: "include", method: "DELETE", headers: { @@ -175,4 +145,6 @@ export async function remove( Referer: APP_URL, }, }); + + return getApiResponse(resp); } diff --git a/src/lib/api/tests/addons.test.ts b/src/lib/api/tests/addons.test.ts index ba893e7f8..021d13e14 100644 --- a/src/lib/api/tests/addons.test.ts +++ b/src/lib/api/tests/addons.test.ts @@ -38,7 +38,8 @@ describe("getAddons", () => { ); }); it("returns the full list", async () => { - const response = await addons.getAddons({}, mockFetch); + const { data: response, error } = await addons.getAddons({}, mockFetch); + expect(error).toBeUndefined(); expect(response).toBe(addonsList); }); it("calls SvelteKit's error fn given a response error", async () => { diff --git a/src/lib/api/tests/collaborators.test.ts b/src/lib/api/tests/collaborators.test.ts index 6e354a2ca..f4622c78f 100644 --- a/src/lib/api/tests/collaborators.test.ts +++ b/src/lib/api/tests/collaborators.test.ts @@ -72,17 +72,18 @@ describe("manage project users", () => { }; }); - const result = await collaborators.add( + const { data } = await collaborators.add( project.id, { email: me.email, access: "admin" }, "token", mockFetch, ); - expect(result.results).toMatchObject([ - ...users.results, - { user: me, access: "admin" }, - ]); + expect(data).toMatchObject({ + previous: null, + next: null, + results: [...users.results, { user: me, access: "admin" }], + }); expect(mockFetch).toHaveBeenCalledWith( new URL(`projects/${project.id}/users/`, BASE_API_URL), @@ -120,7 +121,7 @@ describe("manage project users", () => { const me = users.results.find((u) => u.user.id === 1020); - const updated = await collaborators.update( + const { data: updated } = await collaborators.update( project.id, me.user.id, "edit", @@ -149,18 +150,19 @@ describe("manage project users", () => { return { ok: true, status: 204, + async json() {}, }; }); const me = users.results.find((u) => u.user.id === 1020); - const resp = await collaborators.remove( + const { data } = await collaborators.remove( project.id, me.user.id, "token", mockFetch, ); - expect(resp.status).toStrictEqual(204); + expect(data).toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith( new URL(`projects/${project.id}/users/${me.user.id}/`, BASE_API_URL), { diff --git a/src/lib/api/tests/documents.test.ts b/src/lib/api/tests/documents.test.ts index 5057fd3db..8d87f964e 100644 --- a/src/lib/api/tests/documents.test.ts +++ b/src/lib/api/tests/documents.test.ts @@ -109,8 +109,12 @@ describe("document fetching", () => { }; }); - const result = await documents.get(+document.id, mockFetch); + const { data: result, error } = await documents.get( + +document.id, + mockFetch, + ); + expect(error).toBeUndefined(); expect(result).toStrictEqual(document); expect(mockFetch).toBeCalledWith( new URL( @@ -134,8 +138,13 @@ describe("document fetching", () => { }; }); - const results = await documents.search("boston", { hl: true }, mockFetch); + const { data: results, error } = await documents.search( + "boston", + { hl: true }, + mockFetch, + ); + expect(error).toBeUndefined(); expect(results).toStrictEqual(search); expect(mockFetch).toBeCalledWith( new URL( @@ -240,9 +249,8 @@ describe("document uploads and processing", () => { original_extension: d.original_extension, })); - documents.create(docs, "token", mockFetch).then((d) => { - expect(d).toMatchObject(created); - }); + const { data } = await documents.create(docs, "token", mockFetch); + expect(data).toMatchObject(created); expect(mockFetch).toHaveBeenCalledWith( new URL("/api/documents/", DC_BASE), @@ -291,18 +299,18 @@ describe("document uploads and processing", () => { test("documents.process", async ({ created }) => { const mockFetch = vi.fn().mockImplementation(async () => ({ ok: true, - async text() { + async json() { return "OK"; }, })); - const resp = await documents.process( + const { data } = await documents.process( created.map((d) => ({ id: d.id })), "csrf_token", mockFetch, ); - expect(resp.ok).toBeTruthy(); + expect(data).toEqual("OK"); expect(mockFetch).toHaveBeenCalledWith( new URL("/api/documents/process/", DC_BASE), { @@ -324,20 +332,21 @@ describe("document uploads and processing", () => { return { ok: true, status: 200, + async json() {}, }; }); // cancelling a finished document is a noop - let result = await documents.cancel(document, csrf_token, mockFetch); + let { data } = await documents.cancel(document, csrf_token, mockFetch); - expect(result).toBeUndefined(); + expect(data).toBeUndefined(); - result = await documents.cancel( + ({ data } = await documents.cancel( { ...document, status: "pending" }, csrf_token, mockFetch, - ); - expect(result.status).toEqual(200); + )); + expect(data).toBeUndefined(); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith( @@ -404,12 +413,18 @@ describe("document write methods", () => { return { ok: true, status: 204, + json() {}, }; }); - const resp = await documents.destroy(document.id, "token", mockFetch); + const { data, error } = await documents.destroy( + document.id, + "token", + mockFetch, + ); - expect(resp.status).toStrictEqual(204); + expect(data).toBeUndefined(); + expect(error).toBeUndefined(); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/`, BASE_API_URL), { @@ -428,6 +443,7 @@ describe("document write methods", () => { return { ok: true, status: 204, + async json() {}, }; }); @@ -435,9 +451,14 @@ describe("document write methods", () => { const endpoint = new URL("documents/", BASE_API_URL); endpoint.searchParams.set("id__in", ids.join(",")); - const resp = await documents.destroy_many(ids, "token", mockFetch); + const { data, error } = await documents.destroy_many( + ids, + "token", + mockFetch, + ); - expect(resp.status).toStrictEqual(204); + expect(data).toBeUndefined(); + expect(error).toBeUndefined(); expect(mockFetch).toBeCalledWith(endpoint, { credentials: "include", method: "DELETE", @@ -460,7 +481,7 @@ describe("document write methods", () => { }; }); - let updated = await documents.edit( + const { data: updated, error } = await documents.edit( document.id, { title: "Updated title" }, "token", @@ -483,9 +504,9 @@ describe("document write methods", () => { }); const update = docs.results.map((d) => ({ ...d, source: "New source" })); - const resp = await documents.edit_many(update, "token", mockFetch); + const { error } = await documents.edit_many(update, "token", mockFetch); - expect(resp.status).toEqual(200); + expect(error).toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith( new URL("documents/", BASE_API_URL), @@ -521,7 +542,7 @@ describe("document write methods", () => { }; }); - const data = await documents.add_tags( + const { data } = await documents.add_tags( document.id, "_tag", ["one", "two"], diff --git a/src/lib/api/tests/notes.test.ts b/src/lib/api/tests/notes.test.ts index 939a77224..37bba9d83 100644 --- a/src/lib/api/tests/notes.test.ts +++ b/src/lib/api/tests/notes.test.ts @@ -52,8 +52,14 @@ describe("writing notes", () => { }; }); - const result = await notes.create(document.id, note, "token", mockFetch); + const { data: result, error } = await notes.create( + document.id, + note, + "token", + mockFetch, + ); + expect(error).toBeUndefined(); expect(result).toEqual(note); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/notes/`, BASE_API_URL), @@ -85,7 +91,7 @@ describe("writing notes", () => { const update: Partial = { title: "New title" }; - const result = await notes.update( + const { data: result, error } = await notes.update( document.id, note.id, update, @@ -93,6 +99,7 @@ describe("writing notes", () => { mockFetch, ); + expect(error).toBeUndefined(); expect(result).toMatchObject({ ...note, ...update }); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/notes/${note.id}/`, BASE_API_URL), @@ -114,17 +121,19 @@ describe("writing notes", () => { return { ok: true, status: 204, + async json() {}, }; }); - const resp = await notes.remove( + const { data, error } = await notes.remove( document.id, note.id, csrf_token, mockFetch, ); - expect(resp.status).toStrictEqual(204); + expect(data).toBeUndefined(); + expect(error).toBeUndefined(); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/notes/${note.id}/`, BASE_API_URL), { diff --git a/src/lib/api/tests/projects.test.ts b/src/lib/api/tests/projects.test.ts index 259db5556..a398e2a89 100644 --- a/src/lib/api/tests/projects.test.ts +++ b/src/lib/api/tests/projects.test.ts @@ -39,13 +39,14 @@ describe("projects.get", () => { vi.restoreAllMocks(); }); it("fetches a single project by ID", async () => { - let res = await projects.get(1, mockFetch); + const { data: res, error } = await projects.get(1, mockFetch); expect(mockFetch).toHaveBeenCalledWith( new URL(`${BASE_API_URL}projects/1/`), { credentials: "include", }, ); + expect(error).toBeUndefined(); expect(res).toBe(project); }); it("throws a 500 error if fetch fails", async () => { @@ -74,11 +75,13 @@ describe("projects.list", () => { vi.restoreAllMocks(); }); it("fetches a list of projects", async () => { - const res = await projects.list({}, mockFetch); + const { data: res, error } = await projects.list({}, mockFetch); + expect(mockFetch).toHaveBeenCalledWith( new URL("projects/", BASE_API_URL), expect.any(Object), ); + expect(error).toBeUndefined(); expect(res).toBe(projectList); }); it("attaches any params to the URL as searchParams", async () => { @@ -255,8 +258,13 @@ describe("project lifecycle", () => { pinned: project.pinned, }; - const created = await projects.create(data, "token", mockFetch); + const { data: created, error } = await projects.create( + data, + "token", + mockFetch, + ); + expect(error).toBeUndefined(); expect(created).toMatchObject(project); expect(mockFetch).toBeCalledWith(new URL("projects/", BASE_API_URL), { body: JSON.stringify(data), @@ -284,8 +292,14 @@ describe("project lifecycle", () => { const update: Partial = { title: "New title" }; - const updated = await projects.edit(project.id, update, "token", mockFetch); + const { data: updated, error } = await projects.edit( + project.id, + update, + "token", + mockFetch, + ); + expect(error).toBeUndefined(); expect(updated).toMatchObject({ ...project, ...update }); expect(mockFetch).toHaveBeenCalledWith( new URL(`projects/${project.id}/`, BASE_API_URL), @@ -310,9 +324,9 @@ describe("project lifecycle", () => { }; }); - const resp = await projects.destroy(project.id, "token", mockFetch); + const { error } = await projects.destroy(project.id, "token", mockFetch); - expect(resp.status).toEqual(204); + expect(error).toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith( new URL(`projects/${project.id}/`, BASE_API_URL), { @@ -359,8 +373,14 @@ describe("manage project documents", () => { }); const ids = documents.results.map((d) => d.document as number); - const docs = await projects.add(1, ids, "token", mockFetch); + const { data: docs, error } = await projects.add( + 1, + ids, + "token", + mockFetch, + ); + expect(error).toBeUndefined(); expect(docs).toMatchObject(documents.results); expect(mockFetch).toBeCalledWith( new URL("projects/1/documents/", BASE_API_URL), diff --git a/src/lib/api/tests/sections.test.ts b/src/lib/api/tests/sections.test.ts index ac68600d5..5d4d3718b 100644 --- a/src/lib/api/tests/sections.test.ts +++ b/src/lib/api/tests/sections.test.ts @@ -50,13 +50,14 @@ describe("sections: writing", () => { }; }); - const result = await sections.create( + const { data: result, error } = await sections.create( document.id, { page_number: 1, title: "Title" }, "token", mockFetch, ); + expect(error).toBeUndefined(); expect(result).toStrictEqual(section); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/sections/`, BASE_API_URL), @@ -91,7 +92,7 @@ describe("sections: writing", () => { title: "New title", }; - const result = await sections.update( + const { data: result, error } = await sections.update( document.id, section.id, update, @@ -99,6 +100,7 @@ describe("sections: writing", () => { mockFetch, ); + expect(error).toBeUndefined(); expect(result).toMatchObject({ ...section, ...update }); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/sections/${section.id}/`, BASE_API_URL), @@ -124,14 +126,14 @@ describe("sections: writing", () => { }; }); - const resp = await sections.remove( + const { error } = await sections.remove( document.id, section.id, "token", mockFetch, ); - expect(resp.status).toStrictEqual(204); + expect(error).toBeUndefined(); expect(mockFetch).toBeCalledWith( new URL(`documents/${document.id}/sections/${section.id}/`, BASE_API_URL), { diff --git a/src/lib/api/types.d.ts b/src/lib/api/types.d.ts index 7b39d1156..39e048ebd 100644 --- a/src/lib/api/types.d.ts +++ b/src/lib/api/types.d.ts @@ -33,11 +33,18 @@ export type ViewerMode = ReadMode | WriteMode; export type Zoom = number | Sizes | "width" | "height"; -export interface APIError { - error: { - status: number; - message: string; - }; +export interface APIError { + status: number; + message: string; + errors?: E; +} + +/** + * Wrap an API response so we can pass errors along + */ +export interface APIResponse { + data?: T; + error?: APIError; } export interface NoteHighlight { @@ -281,3 +288,6 @@ export interface Flatpage { title: string; content: string; // Could be HTML or Markdown } + +// known errors +export interface ValidationError extends Record {} diff --git a/src/lib/components/forms/DocumentUpload.svelte b/src/lib/components/forms/DocumentUpload.svelte index 4eb4c1793..49910463e 100644 --- a/src/lib/components/forms/DocumentUpload.svelte +++ b/src/lib/components/forms/DocumentUpload.svelte @@ -56,16 +56,11 @@ }; }); - let created: Document[]; - try { - created = await documents.create(docs, csrf_token, fetch); - } catch (err) { - return { - type: "error", - status: 400, - error: err, - }; - } + let { data: created, error } = await documents.create( + docs, + csrf_token, + fetch, + ); // upload const uploads = created.map((d, i) => @@ -89,7 +84,7 @@ ); // todo: i18n - if (process_response.ok) { + if (!process_response.error) { const query = new URLSearchParams([["q", userDocs(user, access)]]); return { type: "redirect", @@ -100,8 +95,8 @@ return { type: "error", - status: process_response.status, - error: await process_response.json(), + status: process_response.error.status, + error: process_response.error.errors, }; } @@ -109,7 +104,6 @@ @@ -47,10 +49,7 @@ - +