diff --git a/src/errors/meilisearch-api-error.ts b/src/errors/meilisearch-api-error.ts index aeb1dd0cb..88c39302f 100644 --- a/src/errors/meilisearch-api-error.ts +++ b/src/errors/meilisearch-api-error.ts @@ -4,17 +4,22 @@ import { MeiliSearchError } from "./meilisearch-error.js"; export class MeiliSearchApiError extends MeiliSearchError { override name = "MeiliSearchApiError"; override cause?: MeiliSearchErrorResponse; - readonly response: Response; + readonly details: unknown; - constructor(response: Response, responseBody?: MeiliSearchErrorResponse) { + constructor( + responseBodyOrMessage: MeiliSearchErrorResponse | string, + details: unknown, + ) { super( - responseBody?.message ?? `${response.status}: ${response.statusText}`, + typeof responseBodyOrMessage === "string" + ? responseBodyOrMessage + : responseBodyOrMessage.message, ); - this.response = response; + this.details = details; - if (responseBody !== undefined) { - this.cause = responseBody; + if (typeof responseBodyOrMessage !== "string") { + this.cause = responseBodyOrMessage; } } } diff --git a/src/http-requests.ts b/src/http-requests.ts index 42355c267..aa886385b 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -228,10 +228,17 @@ export class HttpRequests { let response: Response; let responseBody: string; + try { if (this.#customRequestFn !== undefined) { - // When using a custom HTTP client, the response should already be handled and ready to be returned - return (await this.#customRequestFn(url, init)) as T; + // when using custom HTTP client, response is handled differently + const resp = await this.#customRequestFn(url, init); + + if (!resp.success) { + throw new MeiliSearchApiError(resp.value, resp.details); + } + + return resp.value as T; } response = await fetch(url, init); @@ -254,8 +261,10 @@ export class HttpRequests { if (!response.ok) { throw new MeiliSearchApiError( + parsedResponse === undefined + ? `${response.status}: ${response.statusText}` + : (parsedResponse as MeiliSearchErrorResponse), response, - parsedResponse as MeiliSearchErrorResponse | undefined, ); } diff --git a/src/types/types.ts b/src/types/types.ts index d818d0908..5a6c5b118 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -43,6 +43,20 @@ export type HttpRequestsRequestInit = Omit & { headers: Headers; }; +/** + * An object in which `success` boolean indicates whether the response was + * successful (status 200-299), `value` is the parsed response body or a string + * describing the error in case there is no response body and `details` is any + * details about the error on failure. + */ +export type CustomHttpClientResult = + | { success: true; value: unknown } + | { + success: false; + value: MeiliSearchErrorResponse | string; + details: unknown; + }; + /** Main configuration object for the meilisearch client. */ export type Config = { /** @@ -59,21 +73,28 @@ export type Config = { */ apiKey?: string; /** - * Custom strings that will be concatted to the "X-Meilisearch-Client" header - * on each request. + * Custom strings that will be concatenated to the "X-Meilisearch-Client" + * header on each request. */ clientAgents?: string[]; /** Base request options that may override the default ones. */ requestInit?: BaseRequestInit; /** - * Custom function that can be provided in place of {@link fetch}. + * Custom function that can be provided to make requests to Meilisearch. * - * @remarks - * API response errors will have to be handled manually with this as well. - * @deprecated This will be removed in a future version. See - * {@link https://github.com/meilisearch/meilisearch-js/issues/1824 | issue}. + * Expects a {@link CustomHttpClientResult}, an object in which `success` + * boolean indicates whether the response was successful (status 200-299), + * `value` is the parsed response body or a string describing the error in + * case there is no response body and `details` is any details about the error + * on failure. + * + * By default + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | fetch} + * is used. */ - httpClient?: (...args: Parameters) => Promise; + httpClient?: ( + ...args: Parameters + ) => Promise; /** Timeout in milliseconds for each HTTP request. */ timeout?: number; /** Customizable default options for awaiting tasks. */ diff --git a/tests/client.test.ts b/tests/client.test.ts index c1b5b5926..5da79a354 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -8,8 +8,18 @@ import { type MockInstance, beforeAll, } from "vitest"; -import type { Health, Version, Stats, IndexSwap } from "../src/index.js"; -import { ErrorStatusCode, MeiliSearchRequestError } from "../src/index.js"; +import type { + Health, + Version, + Stats, + IndexSwap, + MeiliSearchErrorResponse, +} from "../src/index.js"; +import { + ErrorStatusCode, + MeiliSearchApiError, + MeiliSearchRequestError, +} from "../src/index.js"; import { PACKAGE_VERSION } from "../src/package-version.js"; import { clearAllIndexes, @@ -269,9 +279,14 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( const client = new MeiliSearch({ ...config, apiKey: key, - async httpClient(...params: Parameters) { + async httpClient(...params) { const result = await fetch(...params); - return result.json() as Promise; + + if (!result.ok) { + throw new Error("expected custom HTTP client to not fail"); + } + + return { success: true, value: result.json() as Promise }; }, }); const health = await client.isHealthy(); @@ -292,6 +307,37 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( expect(documents.length).toBe(1); }); + test(`${permission} key: Create client with custom http client that fails`, async () => { + const key = await getKey(permission); + const client = new MeiliSearch({ + ...config, + apiKey: key, + async httpClient(...params) { + const response = await fetch(...params); + + if (response.ok) { + throw new Error("expected custom HTTP client to fail"); + } + + const text = await response.text(); + + const value = + text === "" + ? "(no response body)" + : (JSON.parse(text) as MeiliSearchErrorResponse); + + return { success: false, value, details: response }; + }, + }); + + const error = await assert.rejects( + client.multiSearch({ queries: [{ indexUid: crypto.randomUUID() }] }), + MeiliSearchRequestError, + ); + + assert.instanceOf(error.cause, MeiliSearchApiError); + }); + describe("Header tests", () => { let fetchSpy: MockInstance;