Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add fetchOptions parameter to control fetch() #289

Merged
merged 4 commits into from
May 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions src/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,23 @@ export type FetchLike = (
export type AbortSignalLike = any;

/**
* The minimum required properties from RequestInit.
* A subset of RequestInit properties to configure a `fetch()` request.
*/
export interface RequestInitLike {
// Only options relevant to the client are included. Extending from the full
// RequestInit would cause issues, such as accepting Header objects.
//
// An interface is used to allow other libraries to augment the type with
// environment-specific types.
export interface RequestInitLike extends Pick<RequestInit, "cache"> {
/**
* An object literal to set the `fetch()` request's headers.
*/
headers?: Record<string, string>;

/**
* An object that allows you to abort a `fetch()` request if needed via an
* `AbortController` object
* An AbortSignal to set the `fetch()` request's signal.
*
* {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal}
* See: \<https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal\>
*/
// NOTE: `AbortSignalLike` is `any`! It is left as `AbortSignalLike`
// for backwards compatibility (the type is exported) and to signal to
Expand Down Expand Up @@ -259,18 +266,41 @@ export type ClientConfig = {
* Node.js, this function must be provided.
*/
fetch?: FetchLike;

/**
* Options provided to the client's `fetch()` on all network requests.
* These options will be merged with internally required options. They
* can also be overriden on a per-query basis using the query's
* `fetchOptions` parameter.
*/
fetchOptions?: RequestInitLike;
};

/**
* Parameters for any client method that use `fetch()`. Only a subset of
* `fetch()` parameters are exposed.
* Parameters for any client method that use `fetch()`.
*/
type FetchParams = {
/**
* Options provided to the client's `fetch()` on all network requests.
* These options will be merged with internally required options. They
* can also be overriden on a per-query basis using the query's
* `fetchOptions` parameter.
*/
fetchOptions?: RequestInitLike;

/**
* An `AbortSignal` provided by an `AbortController`. This allows the network
* request to be cancelled if necessary.
*
* {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal}
*
* @deprecated Move the `signal` parameter into `fetchOptions.signal`:
*
* ```typescript
* client.getByUID("page", "home",{
* fetchOptions: { signal }
* });
* ```
*/
signal?: AbortSignalLike;
};
Expand Down Expand Up @@ -409,6 +439,8 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
*/
fetchFn: FetchLike;

fetchOptions?: RequestInitLike;

/**
* Default parameters that will be sent with each query. These parameters can
* be overridden on each query if needed.
Expand Down Expand Up @@ -498,6 +530,7 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
this.accessToken = options.accessToken;
this.routes = options.routes;
this.brokenRoute = options.brokenRoute;
this.fetchOptions = options.fetchOptions;
this.defaultParams = options.defaultParams;

if (options.ref) {
Expand Down Expand Up @@ -1328,12 +1361,15 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
*/
async buildQueryURL({
signal,
fetchOptions,
...params
}: Partial<BuildQueryURLArgs> & FetchParams = {}): Promise<string> {
const ref = params.ref || (await this.getResolvedRefString({ signal }));
const ref =
params.ref || (await this.getResolvedRefString({ signal, fetchOptions }));
const integrationFieldsRef =
params.integrationFieldsRef ||
(await this.getCachedRepository({ signal })).integrationFieldsRef ||
(await this.getCachedRepository({ signal, fetchOptions }))
.integrationFieldsRef ||
undefined;

return buildQueryURL(this.endpoint, {
Expand Down Expand Up @@ -1404,12 +1440,13 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {

if (documentID != null && previewToken != null) {
const document = await this.getByID(documentID, {
signal: args.signal,
ref: previewToken,
lang: "*",
signal: args.signal,
fetchOptions: args.fetchOptions,
});

const url = asLink(document, args.linkResolver);
const url = asLink(document, { linkResolver: args.linkResolver });

if (typeof url === "string") {
return url;
Expand Down Expand Up @@ -1737,22 +1774,33 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
url: string,
params: FetchParams = {},
): Promise<T> {
const requestInit: RequestInitLike = {
...this.fetchOptions,
...params.fetchOptions,
headers: {
...this.fetchOptions?.headers,
...params.fetchOptions?.headers,
},
signal:
params.fetchOptions?.signal ||
params.signal ||
this.fetchOptions?.signal,
};

let job: Promise<FetchJobResult>;

// `fetchJobs` is keyed twice: first by the URL and again by is
// signal, if one exists.
//
// Using two keys allows us to reuse fetch requests for
// equivalent URLs, but eject when we detect unique signals.
if (this.fetchJobs[url] && this.fetchJobs[url].has(params.signal)) {
if (this.fetchJobs[url] && this.fetchJobs[url].has(requestInit.signal)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
job = this.fetchJobs[url].get(params.signal)!;
job = this.fetchJobs[url].get(requestInit.signal)!;
} else {
this.fetchJobs[url] = this.fetchJobs[url] || new Map();

job = this.fetchFn(url, {
signal: params.signal,
})
job = this.fetchFn(url, requestInit)
.then(async (res) => {
// We can assume Prismic REST API responses
// will have a `application/json`
Expand All @@ -1773,14 +1821,14 @@ export class Client<TDocuments extends PrismicDocument = PrismicDocument> {
};
})
.finally(() => {
this.fetchJobs[url].delete(params.signal);
this.fetchJobs[url].delete(requestInit.signal);

if (this.fetchJobs[url].size === 0) {
delete this.fetchJobs[url];
}
});

this.fetchJobs[url].set(params.signal, job);
this.fetchJobs[url].set(requestInit.signal, job);
}

const res = await job;
Expand Down
28 changes: 26 additions & 2 deletions test/__testutils__/testAbortableMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as prismic from "../../src";
type TestAbortableMethodArgs = {
run: (
client: prismic.Client,
signal: prismic.AbortSignalLike,
params?: Parameters<prismic.Client["get"]>[0],
) => Promise<unknown>;
};

Expand All @@ -25,7 +25,31 @@ export const testAbortableMethod = (
const client = createTestClient();

await expect(async () => {
await args.run(client, controller.signal);
await args.run(client, {
fetchOptions: {
signal: controller.signal,
},
});
}).rejects.toThrow(/aborted/i);
});

// TODO: Remove once the `signal` parameter is removed in favor of
// `fetchOptions.signal`.
it.concurrent(
`${description} (using deprecated \`signal\` param)`,
async (ctx) => {
const controller = new AbortController();
controller.abort();

mockPrismicRestAPIV2({ ctx });

const client = createTestClient();

await expect(async () => {
await args.run(client, {
signal: controller.signal,
});
}).rejects.toThrow(/aborted/i);
},
);
};
85 changes: 85 additions & 0 deletions test/__testutils__/testFetchOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect, it, vi } from "vitest";

import fetch from "node-fetch";

import { createTestClient } from "./createClient";
import { mockPrismicRestAPIV2 } from "./mockPrismicRestAPIV2";

import * as prismic from "../../src";

type TestFetchOptionsArgs = {
run: (
client: prismic.Client,
params?: Parameters<prismic.Client["get"]>[0],
) => Promise<unknown>;
};

export const testFetchOptions = (
description: string,
args: TestFetchOptionsArgs,
): void => {
it.concurrent(`${description} (on client)`, async (ctx) => {
const abortController = new AbortController();

const fetchSpy = vi.fn(fetch);
const fetchOptions: prismic.RequestInitLike = {
cache: "no-store",
headers: {
foo: "bar",
},
signal: abortController.signal,
};

mockPrismicRestAPIV2({
ctx,
queryResponse: ctx.mock.api.query({
documents: [ctx.mock.value.document()],
}),
});

const client = createTestClient({
clientConfig: {
fetch: fetchSpy,
fetchOptions,
},
});

await args.run(client);

for (const [input, init] of fetchSpy.mock.calls) {
expect(init, input.toString()).toStrictEqual(fetchOptions);
}
});

it.concurrent(`${description} (on method)`, async (ctx) => {
const abortController = new AbortController();

const fetchSpy = vi.fn(fetch);
const fetchOptions: prismic.RequestInitLike = {
cache: "no-store",
headers: {
foo: "bar",
},
signal: abortController.signal,
};

mockPrismicRestAPIV2({
ctx,
queryResponse: ctx.mock.api.query({
documents: [ctx.mock.value.document()],
}),
});

const client = createTestClient({
clientConfig: {
fetch: fetchSpy,
},
});

await args.run(client, { fetchOptions });

for (const [input, init] of fetchSpy.mock.calls) {
expect(init, input.toString()).toStrictEqual(fetchOptions);
}
});
};
7 changes: 6 additions & 1 deletion test/client-dangerouslyGetAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2";
import { testAbortableMethod } from "./__testutils__/testAbortableMethod";
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod";
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

/**
* The number of milliseconds in which a multi-page `getAll` (e.g. `getAll`,
Expand Down Expand Up @@ -191,8 +192,12 @@ it("does not throttle single page queries", async (ctx) => {
).toBe(true);
});

testFetchOptions("supports fetch options", {
run: (client, params) => client.dangerouslyGetAll(params),
});

testAbortableMethod("is abortable with an AbortController", {
run: (client, signal) => client.dangerouslyGetAll({ signal }),
run: (client, params) => client.dangerouslyGetAll(params),
});

testConcurrentMethod("shares concurrent equivalent network requests", {
Expand Down
9 changes: 4 additions & 5 deletions test/client-get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createTestClient } from "./__testutils__/createClient";
import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2";
import { testAbortableMethod } from "./__testutils__/testAbortableMethod";
import { testGetMethod } from "./__testutils__/testAnyGetMethod";
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

testGetMethod("resolves a query", {
run: (client) => client.get(),
Expand Down Expand Up @@ -92,11 +92,10 @@ it("uses cached repository metadata within the client's repository cache TTL", a
);
});

testAbortableMethod("is abortable with an AbortController", {
run: (client, signal) => client.get({ signal }),
testFetchOptions("supports fetch options", {
run: (client, params) => client.get(params),
});

testConcurrentMethod("shares concurrent equivalent network requests", {
testAbortableMethod("is abortable with an AbortController", {
run: (client, params) => client.get(params),
mode: "get",
});
7 changes: 6 additions & 1 deletion test/client-getAllByEveryTag.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { testAbortableMethod } from "./__testutils__/testAbortableMethod";
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod";
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

testGetAllMethod("returns all documents by every tag from paginated response", {
run: (client) => client.getAllByEveryTag(["foo", "bar"]),
Expand All @@ -24,8 +25,12 @@ testGetAllMethod("includes params if provided", {
},
});

testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByEveryTag(["foo", "bar"], params),
});

testAbortableMethod("is abortable with an AbortController", {
run: (client, signal) => client.getAllByEveryTag(["foo", "bar"], { signal }),
run: (client, params) => client.getAllByEveryTag(["foo", "bar"], params),
});

testConcurrentMethod("shares concurrent equivalent network requests", {
Expand Down
7 changes: 6 additions & 1 deletion test/client-getAllByIDs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { testAbortableMethod } from "./__testutils__/testAbortableMethod";
import { testGetAllMethod } from "./__testutils__/testAnyGetMethod";
import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

testGetAllMethod("returns all documents by IDs from paginated response", {
run: (client) => client.getAllByIDs(["id1", "id2"]),
Expand All @@ -24,8 +25,12 @@ testGetAllMethod("includes params if provided", {
},
});

testFetchOptions("supports fetch options", {
run: (client, params) => client.getAllByIDs(["id1", "id2"], params),
});

testAbortableMethod("is abortable with an AbortController", {
run: (client, signal) => client.getAllByIDs(["id1", "id2"], { signal }),
run: (client, params) => client.getAllByIDs(["id1", "id2"], params),
});

testConcurrentMethod("shares concurrent equivalent network requests", {
Expand Down
Loading