Skip to content

Commit

Permalink
feat: pass arbitrary options to fetch() with the fetchOptions par…
Browse files Browse the repository at this point in the history
…ameter (#9)

* feat: pass arbitrary options to `fetch()` with the `fetchOptions` parameter

* fix: ensure `fetchOptions.cache` is optional
  • Loading branch information
angeloashmore committed Dec 5, 2023
1 parent a3594f6 commit ae76fe8
Show file tree
Hide file tree
Showing 14 changed files with 251 additions and 7 deletions.
38 changes: 34 additions & 4 deletions src/client.ts
Expand Up @@ -43,6 +43,13 @@ export type CustomTypesClientConfig = {
* 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;
};

/**
Expand All @@ -54,10 +61,16 @@ export type CustomTypesClientMethodParams = Partial<
>;

/**
* Parameters for any client method that use `fetch()`. Only a subset of
* `fetch()` parameters are exposed.
* Parameters for client methods 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.
Expand Down Expand Up @@ -121,13 +134,21 @@ export class CustomTypesClient {
*/
fetchFn: 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;

/**
* Create a client for the Prismic Custom Types API.
*/
constructor(config: CustomTypesClientConfig) {
this.repositoryName = config.repositoryName;
this.endpoint = config.endpoint || DEFAULT_CUSTOM_TYPES_API_ENDPOINT;
this.token = config.token;
this.fetchOptions = config.fetchOptions;

// TODO: Remove the following `if` statement in v2.
//
Expand Down Expand Up @@ -419,13 +440,22 @@ export class CustomTypesClient {
).toString();

const res = await this.fetchFn(url, {
...this.fetchOptions,
...requestInit,
...params.fetchOptions,
headers: {
"Content-Type": "application/json",
repository: params.repositoryName || this.repositoryName,
Authorization: `Bearer ${params.token || this.token}`,
...this.fetchOptions?.headers,
...requestInit.headers,
...params.fetchOptions?.headers,
},
signal: params.signal,
...requestInit,
signal:
params.fetchOptions?.signal ||
params.signal ||
requestInit.signal ||
this.fetchOptions?.signal,
});

switch (res.status) {
Expand Down
24 changes: 22 additions & 2 deletions src/types.ts
Expand Up @@ -22,15 +22,35 @@ 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 Partial<Pick<RequestInit, "cache">> {
// Explicit method names are given for compatibility with `fetch-h2`.
// Most fetch implementation use `method?: string`, which is compatible
// with the version defiend here.
method?: "GET" | "POST" | "DELETE";

body?: string;

/**
* An object literal to set the `fetch()` request's headers.
*/
headers?: Record<string, string>;

/**
* An AbortSignal to set the `fetch()` request's signal.
*
* See:
* [https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal](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
// other readers that this should be an AbortSignal-like object.
signal?: AbortSignalLike;
}

Expand Down
113 changes: 113 additions & 0 deletions test/__testutils__/testFetchOptions.ts
@@ -0,0 +1,113 @@
import { expect, it, vi } from "vitest";
import * as msw from "msw";

import fetch from "node-fetch";

import { createClient } from "./createClient";
import { isAuthorizedRequest } from "./isAuthorizedRequest";

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

type TestFetchOptionsArgs = {
mockURL: (client: lib.CustomTypesClient) => URL;
mockURLMethod?: keyof typeof msw.rest;
run: (
client: lib.CustomTypesClient,
params?: Parameters<lib.CustomTypesClient["getAllCustomTypes"]>[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: lib.RequestInitLike = {
cache: "no-store",
headers: {
foo: "bar",
},
signal: abortController.signal,
};

const client = createClient(ctx, {
fetch: fetchSpy,
fetchOptions,
});

ctx.server.use(
msw.rest[args.mockURLMethod || "get"](
args.mockURL(client).toString(),
(req, res, ctx) => {
if (!isAuthorizedRequest(client, req)) {
return res(
ctx.status(403),
ctx.json({ message: "[MOCK FORBIDDEN ERROR]" }),
);
}

return res(ctx.json({}));
},
),
);

await args.run(client);

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

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

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

const client = createClient(ctx, {
fetch: fetchSpy,
});

const queryResponse = [ctx.mock.model.customType()];
ctx.server.use(
msw.rest[args.mockURLMethod || "get"](
args.mockURL(client).toString(),
(req, res, ctx) => {
if (!isAuthorizedRequest(client, req)) {
return res(
ctx.status(403),
ctx.json({ message: "[MOCK FORBIDDEN ERROR]" }),
);
}

return res(ctx.json(queryResponse));
},
),
);

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

for (const [input, init] of fetchSpy.mock.calls) {
expect(init, input.toString()).toStrictEqual(
expect.objectContaining({
...fetchOptions,
headers: expect.objectContaining(fetchOptions.headers),
}),
);
}
});
};
6 changes: 6 additions & 0 deletions test/client-getAllCustomTypes.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -71,3 +72,8 @@ test("is abortable", async (ctx) => {
await client.getAllCustomTypes({ signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./customtypes", client.endpoint),
run: (client, params) => client.getAllCustomTypes(params),
});
6 changes: 6 additions & 0 deletions test/client-getAllSharedSlices.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -71,3 +72,8 @@ test("is abortable", async (ctx) => {
await client.getAllSharedSlices({ signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./slices", client.endpoint),
run: (client, params) => client.getAllSharedSlices(params),
});
6 changes: 6 additions & 0 deletions test/client-getCustomTypeByID.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -96,3 +97,8 @@ test("is abortable", async (ctx) => {
await client.getCustomTypeByID("id", { signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./customtypes/id", client.endpoint),
run: (client, params) => client.getCustomTypeByID("id", params),
});
6 changes: 6 additions & 0 deletions test/client-getSharedSliceByID.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -96,3 +97,8 @@ test("is abortable", async (ctx) => {
await client.getSharedSliceByID("id", { signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./slices/id", client.endpoint),
run: (client, params) => client.getSharedSliceByID("id", params),
});
9 changes: 9 additions & 0 deletions test/client-insertCustomType.test.ts
@@ -1,9 +1,11 @@
import { test, expect } from "vitest";
import * as msw from "msw";
import * as assert from "assert";
import * as prismicT from "@prismicio/types";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -134,3 +136,10 @@ test.skip("is abortable", async (ctx) => {
await client.insertCustomType(customType, { signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./customtypes/insert", client.endpoint),
mockURLMethod: "post",
run: (client, params) =>
client.insertCustomType({} as prismicT.CustomTypeModel, params),
});
9 changes: 9 additions & 0 deletions test/client-insertSharedSlice.test.ts
@@ -1,9 +1,11 @@
import { test, expect } from "vitest";
import * as msw from "msw";
import * as assert from "assert";
import * as prismicT from "@prismicio/types";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -136,3 +138,10 @@ test.skip("is abortable", async (ctx) => {
await client.insertSharedSlice(sharedSlice, { signal: controller.signal });
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./slices/insert", client.endpoint),
mockURLMethod: "post",
run: (client, params) =>
client.insertSharedSlice({} as prismicT.SharedSliceModel, params),
});
7 changes: 7 additions & 0 deletions test/client-removeCustomType.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -72,6 +73,12 @@ test("is abortable", async (ctx) => {
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./customtypes/id", client.endpoint),
mockURLMethod: "delete",
run: (client, params) => client.removeCustomType("id", params),
});

// NOTE: The API does not return a 4xx status code if a non-existing Custom Type
// is deleted. Instead, it returns 204 just like a successful deletion request.
// As a result, we have nothing to test.
Expand Down
7 changes: 7 additions & 0 deletions test/client-removeSharedSlice.test.ts
Expand Up @@ -3,6 +3,7 @@ import * as msw from "msw";

import { createClient } from "./__testutils__/createClient";
import { isAuthorizedRequest } from "./__testutils__/isAuthorizedRequest";
import { testFetchOptions } from "./__testutils__/testFetchOptions";

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

Expand Down Expand Up @@ -72,6 +73,12 @@ test("is abortable", async (ctx) => {
}).rejects.toThrow(/aborted/i);
});

testFetchOptions("supports fetch options", {
mockURL: (client) => new URL("./slices/id", client.endpoint),
mockURLMethod: "delete",
run: (client, params) => client.removeSharedSlice("id", params),
});

// NOTE: The API does not return a 4xx status code if a non-existing Shared
// Slice is deleted. Instead, it returns 204 just like a successful deletion
// request. As a result, we have nothing to test.
Expand Down

0 comments on commit ae76fe8

Please sign in to comment.