Skip to content
Open
60 changes: 31 additions & 29 deletions src/http-requests.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ import {
MeiliSearchRequestError,
MeiliSearchRequestTimeOutError,
} from "./errors/index.js";
import { addProtocolIfNotPresent, addTrailingSlash } from "./utils.js";
import { addProtocolIfNotPresent } from "./utils.js";

/** Append a set of key value pairs to a {@link URLSearchParams} object. */
function appendRecordToURLSearchParams(
@@ -34,36 +34,32 @@ function appendRecordToURLSearchParams(
}
}

const AGENT_HEADER_KEY = "X-Meilisearch-Client";
const CONTENT_TYPE_KEY = "Content-Type";
const AUTHORIZATION_KEY = "Authorization";
const PACKAGE_AGENT = `Meilisearch JavaScript (v${PACKAGE_VERSION})`;

/**
* Creates a new Headers object from a {@link HeadersInit} and adds various
* properties to it, some from {@link Config}.
*
* @returns A new Headers object
* properties to it, as long as they're not already provided by the user.
*/
function getHeaders(config: Config, headersInit?: HeadersInit): Headers {
const agentHeader = "X-Meilisearch-Client";
const packageAgent = `Meilisearch JavaScript (v${PACKAGE_VERSION})`;
const contentType = "Content-Type";
const authorization = "Authorization";

const headers = new Headers(headersInit);

// do not override if user provided the header
if (config.apiKey && !headers.has(authorization)) {
headers.set(authorization, `Bearer ${config.apiKey}`);
if (config.apiKey && !headers.has(AUTHORIZATION_KEY)) {
headers.set(AUTHORIZATION_KEY, `Bearer ${config.apiKey}`);
}

if (!headers.has(contentType)) {
headers.set(contentType, "application/json");
if (!headers.has(CONTENT_TYPE_KEY)) {
headers.set(CONTENT_TYPE_KEY, "application/json");
}

// Creates the custom user agent with information on the package used.
if (config.clientAgents !== undefined) {
const clients = config.clientAgents.concat(packageAgent);

headers.set(agentHeader, clients.join(" ; "));
const agents = config.clientAgents.concat(PACKAGE_AGENT);
headers.set(AGENT_HEADER_KEY, agents.join(" ; "));
} else {
headers.set(agentHeader, packageAgent);
headers.set(AGENT_HEADER_KEY, PACKAGE_AGENT);
}

return headers;
@@ -84,19 +80,23 @@ const TIMEOUT_ID = Symbol("<timeout>");
* function that clears the timeout
*/
function getTimeoutFn(
requestInit: RequestInit,
init: RequestInit,
ms: number,
): () => (() => void) | void {
const { signal } = requestInit;
const { signal } = init;
const ac = new AbortController();

init.signal = ac.signal;

if (signal != null) {
let acSignalFn: (() => void) | null = null;

if (signal.aborted) {
ac.abort(signal.reason);
} else {
const fn = () => ac.abort(signal.reason);
const fn = () => {
ac.abort(signal.reason);
};

signal.addEventListener("abort", fn, { once: true });

@@ -109,7 +109,9 @@ function getTimeoutFn(
return;
}

const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms);
const to = setTimeout(() => {
ac.abort(TIMEOUT_ID);
}, ms);
const fn = () => {
clearTimeout(to);

@@ -127,10 +129,10 @@ function getTimeoutFn(
};
}

requestInit.signal = ac.signal;

return () => {
const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms);
const to = setTimeout(() => {
ac.abort(TIMEOUT_ID);
}, ms);
return () => clearTimeout(to);
};
}
@@ -143,10 +145,10 @@ export class HttpRequests {
#requestTimeout?: Config["timeout"];

constructor(config: Config) {
const host = addTrailingSlash(addProtocolIfNotPresent(config.host));
const host = addProtocolIfNotPresent(config.host);

try {
this.#url = new URL(host);
this.#url = new URL(host.endsWith("/") ? host : host + "/");
} catch (error) {
throw new MeiliSearchError("The provided host is not valid", {
cause: error,
@@ -176,8 +178,8 @@ export class HttpRequests {

const headers = new Headers(extraHeaders);

if (contentType !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", contentType);
if (contentType !== undefined && !headers.has(CONTENT_TYPE_KEY)) {
headers.set(CONTENT_TYPE_KEY, contentType);
}

for (const [key, val] of this.#requestInit.headers) {
2 changes: 1 addition & 1 deletion src/task.ts
Original file line number Diff line number Diff line change
@@ -122,7 +122,7 @@ export class TaskClient {
}
}
} catch (error) {
throw Object.is((error as Error).cause, TIMEOUT_ID)
throw Object.is((error as Error)?.cause, TIMEOUT_ID)
? new MeiliSearchTaskTimeOutError(taskUid, timeout)
: error;
}
22 changes: 12 additions & 10 deletions src/types/types.ts
Original file line number Diff line number Diff line change
@@ -45,12 +45,7 @@ export type HttpRequestsRequestInit = Omit<BaseRequestInit, "headers"> & {

/** Main configuration object for the meilisearch client. */
export type Config = {
/**
* The base URL for reaching a meilisearch instance.
*
* @remarks
* Protocol and trailing slash can be omitted.
*/
/** The base URL for reaching a meilisearch instance. */
host: string;
/**
* API key for interacting with a meilisearch instance.
@@ -59,8 +54,8 @@ 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. */
@@ -69,12 +64,19 @@ export type Config = {
* Custom function that can be provided in place of {@link fetch}.
*
* @remarks
* API response errors will have to be handled manually with this as well.
* API response errors have to be handled manually.
* @deprecated This will be removed in a future version. See
* {@link https://github.com/meilisearch/meilisearch-js/issues/1824 | issue}.
*/
httpClient?: (...args: Parameters<typeof fetch>) => Promise<unknown>;
/** Timeout in milliseconds for each HTTP request. */
/**
* Timeout in milliseconds for each HTTP request.
*
* @remarks
* This uses {@link setTimeout}, which is not guaranteed to respect the
* provided milliseconds accurately.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#reasons_for_delays_longer_than_specified}
*/
timeout?: number;
/** Customizable default options for awaiting tasks. */
defaultWaitOptions?: WaitOptions;
20 changes: 11 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -2,18 +2,20 @@
return await new Promise((resolve) => setTimeout(resolve, ms));
}

let warningDispatched = false;
function addProtocolIfNotPresent(host: string): string {
if (!(host.startsWith("https://") || host.startsWith("http://"))) {
return `http://${host}`;
if (/^https?:\/\//.test(host)) {
return host;
}
return host;
}

function addTrailingSlash(url: string): string {
if (!url.endsWith("/")) {
url += "/";
if (!warningDispatched) {
console.warn(
`DEPRECATION WARNING: missing protocol in provided host ${host} will no longer be supported in the future`,
);
warningDispatched = true;

Check warning on line 15 in src/utils.ts

Codecov / codecov/patch

src/utils.ts#L11-L15

Added lines #L11 - L15 were not covered by tests
}
return url;

return `http://${host}`;

Check warning on line 18 in src/utils.ts

Codecov / codecov/patch

src/utils.ts#L18

Added line #L18 was not covered by tests
}

export { sleep, addProtocolIfNotPresent, addTrailingSlash };
export { sleep, addProtocolIfNotPresent };
902 changes: 0 additions & 902 deletions tests/client.test.ts

This file was deleted.

19 changes: 0 additions & 19 deletions tests/errors.test.ts

This file was deleted.

10 changes: 10 additions & 0 deletions tests/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { test } from "vitest";
import { assert, getClient } from "./utils/meilisearch-test-utils.js";

const ms = await getClient("Master");

test(`${ms.health.name} method`, async () => {
const health = await ms.health();
assert.strictEqual(Object.keys(health).length, 1);
assert.strictEqual(health.status, "available");
});
255 changes: 255 additions & 0 deletions tests/meilisearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
test,
vi,
type MockInstance,
} from "vitest";
import {
MeiliSearch,
MeiliSearchRequestTimeOutError,
MeiliSearchRequestError,
MeiliSearchError,
MeiliSearchApiError,
type MeiliSearchErrorResponse,
} from "../src/index.js";
import { assert, HOST } from "./utils/meilisearch-test-utils.js";

describe("abort", () => {
let spy: MockInstance<typeof fetch>;
beforeAll(() => {
spy = vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => {
assert.isDefined(init);
const signal = init.signal;
assert.isDefined(signal);
assert.isNotNull(signal);

return new Promise((_resolve, reject) => {
if (signal.aborted) {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(signal.reason as unknown);
}

signal.onabort = function () {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(signal.reason);
signal.removeEventListener("abort", this.onabort!);
};
});
});
});

afterAll(() => {
spy.mockRestore();
});

test.concurrent("with global timeout", async () => {
const timeout = 1;
const ms = new MeiliSearch({ host: HOST, timeout });

const error = await assert.rejects(ms.health(), MeiliSearchRequestError);
assert.instanceOf(error.cause, MeiliSearchRequestTimeOutError);
assert.strictEqual(error.cause.cause.timeout, timeout);
});

test.concurrent("with signal", async () => {
const ms = new MeiliSearch({ host: HOST });
const reason = Symbol("<reason>");

const error = await assert.rejects(
ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }),
MeiliSearchRequestError,
);
assert.strictEqual(error.cause, reason);
});

test.concurrent("with signal with a timeout", async () => {
const ms = new MeiliSearch({ host: HOST });

const error = await assert.rejects(
ms.multiSearch({ queries: [] }, { signal: AbortSignal.timeout(5) }),
MeiliSearchRequestError,
);

assert.strictEqual(
String(error.cause),
"TimeoutError: The operation was aborted due to timeout",
);
});

test.concurrent.for([
[2, 1],
[1, 2],
] as const)(
"with global timeout of %ims and signal timeout of %ims",
async ([timeout, signalTimeout]) => {
const ms = new MeiliSearch({ host: HOST, timeout });

const error = await assert.rejects(
ms.multiSearch(
{ queries: [] },
{ signal: AbortSignal.timeout(signalTimeout) },
),
MeiliSearchRequestError,
);

if (timeout > signalTimeout) {
assert.strictEqual(
String(error.cause),
"TimeoutError: The operation was aborted due to timeout",
);
} else {
assert.instanceOf(error.cause, MeiliSearchRequestTimeOutError);
assert.strictEqual(error.cause.cause.timeout, timeout);
}
},
);

test.concurrent(
"with global timeout and immediately aborted signal",
async () => {
const ms = new MeiliSearch({ host: HOST, timeout: 1 });
const reason = Symbol("<reason>");

const error = await assert.rejects(
ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }),
MeiliSearchRequestError,
);

assert.strictEqual(error.cause, reason);
},
);
});

test("headers with API key, clientAgents, global headers, and custom headers", async () => {
using spy = (() => {
const spy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(() => Promise.resolve(new Response()));

return {
get value() {
return spy;
},
[Symbol.dispose]() {
spy.mockRestore();
},
};
})();

const apiKey = "secrète";
const clientAgents = ["TEST"];
const globalHeaders = { my: "feather", not: "helper", extra: "header" };

const ms = new MeiliSearch({
host: HOST,
apiKey,
clientAgents,
requestInit: { headers: globalHeaders },
});

const customHeaders = { my: "header", not: "yours" };
await ms.multiSearch({ queries: [] }, { headers: customHeaders });

const { calls } = spy.value.mock;
assert.lengthOf(calls, 1);

const headers = calls[0][1]?.headers;
assert.isDefined(headers);
assert.instanceOf(headers, Headers);

const xMeilisearchClientKey = "x-meilisearch-client";
const xMeilisearchClient = headers.get(xMeilisearchClientKey);
headers.delete(xMeilisearchClientKey);

assert.isNotNull(xMeilisearchClient);
assert.sameMembers(
xMeilisearchClient.split(" ; ").slice(0, -1),
clientAgents,
);

const authorizationKey = "authorization";
const authorization = headers.get(authorizationKey);
headers.delete(authorizationKey);

assert.strictEqual(authorization, `Bearer ${apiKey}`);

// note how they overwrite each other, top priority being the custom headers
assert.deepEqual(Object.fromEntries(headers.entries()), {
"content-type": "application/json",
...globalHeaders,
...customHeaders,
});
});

test.concurrent("custom http client", async () => {
const httpClient = vi.fn((..._params: Parameters<typeof fetch>) =>
Promise.resolve(new Response()),
);

const ms = new MeiliSearch({ host: HOST, httpClient });
await ms.health();

assert.lengthOf(httpClient.mock.calls, 1);
const input = httpClient.mock.calls[0][0];

assert.instanceOf(input, URL);
assert(input.href.startsWith(HOST));
});

describe("errors", () => {
let spy: MockInstance<typeof fetch>;
beforeAll(() => {
spy = vi.spyOn(globalThis, "fetch");
});

afterAll(() => {
spy.mockRestore();
});

afterEach(() => {
spy.mockReset();
});

test(`${MeiliSearchError.name}`, () => {
assert.throws(
() => new MeiliSearch({ host: "http:// invalid URL" }),
MeiliSearchError,
"The provided host is not valid",
);
});

test(`${MeiliSearchRequestError.name}`, async () => {
const simulatedError = new TypeError("simulated network error");
spy.mockImplementation(() => Promise.reject(simulatedError));

const ms = new MeiliSearch({ host: "https://politi.dk/en/" });
const error = await assert.rejects(ms.health(), MeiliSearchRequestError);
assert.typeOf(error.message, "string");
assert.deepEqual(error.cause, simulatedError);
});

test(`${MeiliSearchApiError.name}`, async () => {
const simulatedCause: MeiliSearchErrorResponse = {
message: "message",
code: "code",
type: "type",
link: "link",
};
spy.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify(simulatedCause), { status: 400 }),
),
);

const ms = new MeiliSearch({ host: "https://polisen.se/en/" });
const error = await assert.rejects(ms.health(), MeiliSearchApiError);
assert.typeOf(error.message, "string");
assert.deepEqual(error.cause, simulatedCause);
assert.instanceOf(error.response, Response);
});

// MeiliSearchTaskTimeOutError is tested by tasks-and-batches tests
});
48 changes: 48 additions & 0 deletions tests/network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, afterAll } from "vitest";
import { assert, getClient } from "./utils/meilisearch-test-utils.js";
import type { Remote } from "../src/index.js";

const ms = await getClient("Master");

afterAll(async () => {
await ms.updateNetwork({
remotes: {
// TODO: Better types for Network
// @ts-expect-error This should be accepted
soi: null,
},
});
});

test(`${ms.updateNetwork.name} and ${ms.getNetwork.name} method`, async () => {
const network = {
self: "soi",
remotes: {
soi: {
url: "https://france-visas.gouv.fr/",
searchApiKey: "hemmelighed",
},
},
};

function validateRemotes(remotes: Record<string, Remote>) {
for (const [key, val] of Object.entries(remotes)) {
if (key !== "soi") {
assert.lengthOf(Object.keys(val), 2);
assert.typeOf(val.url, "string");
assert(
typeof val.searchApiKey === "string" || val.searchApiKey === null,
);
delete remotes[key];
}
}
}

const updateResponse = await ms.updateNetwork(network);
validateRemotes(updateResponse.remotes);
assert.deepEqual(updateResponse, network);

const getResponse = await ms.getNetwork();
validateRemotes(getResponse.remotes);
assert.deepEqual(getResponse, network);
});
37 changes: 37 additions & 0 deletions tests/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test } from "vitest";
import { assert, getClient } from "./utils/meilisearch-test-utils.js";

const ms = await getClient("Master");

test(`${ms.getStats.name} method`, async () => {
const stats = await ms.getStats();
assert.strictEqual(Object.keys(stats).length, 4);
const { databaseSize, usedDatabaseSize, lastUpdate, indexes } = stats;
assert.typeOf(databaseSize, "number");
assert.typeOf(usedDatabaseSize, "number");
assert(typeof lastUpdate === "string" || lastUpdate === null);

for (const indexStats of Object.values(indexes)) {
assert.lengthOf(Object.keys(indexStats), 7);
const {
numberOfDocuments,
isIndexing,
fieldDistribution,
numberOfEmbeddedDocuments,
numberOfEmbeddings,
rawDocumentDbSize,
avgDocumentSize,
} = indexStats;

assert.typeOf(numberOfDocuments, "number");
assert.typeOf(isIndexing, "boolean");
assert.typeOf(numberOfEmbeddedDocuments, "number");
assert.typeOf(numberOfEmbeddings, "number");
assert.typeOf(rawDocumentDbSize, "number");
assert.typeOf(avgDocumentSize, "number");

for (const val of Object.values(fieldDistribution)) {
assert.typeOf(val, "number");
}
}
});
13 changes: 13 additions & 0 deletions tests/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test } from "vitest";
import { assert, getClient } from "./utils/meilisearch-test-utils.js";

const ms = await getClient("Master");

test(`${ms.getVersion.name} method`, async () => {
const version = await ms.getVersion();
assert.strictEqual(Object.keys(version).length, 3);
const { commitDate, commitSha, pkgVersion } = version;
for (const v of [commitDate, commitSha, pkgVersion]) {
assert.typeOf(v, "string");
}
});