From dbf5646b6693ab98c9ce750d32ca574148432de6 Mon Sep 17 00:00:00 2001 From: "E. Cooper" Date: Thu, 3 Oct 2024 09:35:09 -0700 Subject: [PATCH] Extend HTTPRequest to support path overrides (#288) --- __tests__/unit/fetch-client.test.ts | 38 +++++++++++++++-- __tests__/unit/node-http2-client.test.ts | 52 ++++++++++++++++++++++++ src/http-client/fetch-client.ts | 19 ++++++--- src/http-client/http-client.ts | 9 +++- src/http-client/index.ts | 1 + src/http-client/node-http2-client.ts | 16 +++++--- src/http-client/paths.ts | 11 +++++ 7 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 src/http-client/paths.ts diff --git a/__tests__/unit/fetch-client.test.ts b/__tests__/unit/fetch-client.test.ts index 3aa55d5d..b23cb5bc 100644 --- a/__tests__/unit/fetch-client.test.ts +++ b/__tests__/unit/fetch-client.test.ts @@ -11,6 +11,7 @@ import { QuerySuccess, } from "../../src"; import { getDefaultHTTPClientOptions } from "../client"; +import { SupportedFaunaAPIPaths } from "../../src/http-client"; let fetchClient: FetchClient; @@ -117,7 +118,7 @@ describe("fetch client", () => { } catch (e) { if (e instanceof NetworkError) { expect(e.message).toEqual( - "The network connection encountered a problem." + "The network connection encountered a problem.", ); expect(e.cause).toBeDefined(); } @@ -128,7 +129,7 @@ describe("fetch client", () => { expect.assertions(2); fetchMock.mockResponseOnce( () => - new Promise((resolve) => setTimeout(() => resolve({ body: "" }), 100)) + new Promise((resolve) => setTimeout(() => resolve({ body: "" }), 100)), ); try { const badClient = new FetchClient(getDefaultHTTPClientOptions()); @@ -137,10 +138,41 @@ describe("fetch client", () => { } catch (e) { if (e instanceof NetworkError) { expect(e.message).toEqual( - "The network connection encountered a problem." + "The network connection encountered a problem.", ); expect(e.cause).toBeDefined(); } } }); + + it("uses the default path if one is not provided in HttpRequest", async () => { + expect.assertions(1); + fetchMock.mockResponseOnce(JSON.stringify({}), { + headers: { "content-type": "application/json" }, + }); + await fetchClient.request({ + ...dummyRequest, + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/query/1"), + expect.any(Object), + ); + }); + + it("uses the path provided in the HttpRequest if provided", async () => { + expect.assertions(1); + fetchMock.mockResponseOnce(JSON.stringify({}), { + headers: { "content-type": "application/json" }, + }); + await fetchClient.request({ + ...dummyRequest, + path: "/non-the-default-api" as SupportedFaunaAPIPaths, + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/non-the-default-api"), + expect.any(Object), + ); + }); }); diff --git a/__tests__/unit/node-http2-client.test.ts b/__tests__/unit/node-http2-client.test.ts index 37f2d7fe..0ab8a6e6 100644 --- a/__tests__/unit/node-http2-client.test.ts +++ b/__tests__/unit/node-http2-client.test.ts @@ -1,5 +1,16 @@ +import http2 from "node:http2"; import { getDefaultHTTPClient, NodeHTTP2Client } from "../../src"; +import { HTTPRequest } from "../../src"; import { getDefaultHTTPClientOptions } from "../client"; +import { SupportedFaunaAPIPaths } from "../../src/http-client"; + +const mockRequest = jest.fn(); +const mockClient = { + request: mockRequest, + once: () => mockClient, + setTimeout: jest.fn(), +}; +jest.spyOn(http2, "connect").mockReturnValue(mockClient as any); const client = getDefaultHTTPClient(getDefaultHTTPClientOptions()); @@ -7,4 +18,45 @@ describe("node http2 client", () => { it("default client for Node.js is the NodeHTTP2Client", async () => { expect(client).toBeInstanceOf(NodeHTTP2Client); }); + + it("uses the default request path if none is provided", async () => { + const request: HTTPRequest = { + client_timeout_ms: 0, + data: { query: "some-query" }, + headers: {}, + method: "POST", + }; + + // We don't actually care about the status of this request for this specific test. + // We're just testing the path is being set correctly. + try { + await client.request(request); + } catch (_) {} + + expect(mockRequest).toHaveBeenCalledWith({ + ":method": "POST", + ":path": "/query/1", + }); + }); + + it("uses the path provided in HttpRequest if provided", async () => { + const request: HTTPRequest = { + client_timeout_ms: 0, + data: { query: "some-query" }, + headers: {}, + method: "POST", + path: "/some-path" as SupportedFaunaAPIPaths, + }; + + // We don't actually care about the status of this request for this specific test. + // We're just testing the path is being set correctly. + try { + await client.request(request); + } catch (_) {} + + expect(mockRequest).toHaveBeenCalledWith({ + ":method": "POST", + ":path": "/some-path", + }); + }); }); diff --git a/src/http-client/fetch-client.ts b/src/http-client/fetch-client.ts index 619807cf..b96fbb42 100644 --- a/src/http-client/fetch-client.ts +++ b/src/http-client/fetch-client.ts @@ -3,6 +3,7 @@ import { getServiceError, NetworkError } from "../errors"; import { QueryFailure } from "../wire-protocol"; +import { FaunaAPIPaths } from "./paths"; import { HTTPClient, HTTPClientOptions, @@ -17,22 +18,27 @@ import { * An implementation for {@link HTTPClient} that uses the native fetch API */ export class FetchClient implements HTTPClient, HTTPStreamClient { - #queryURL: string; - #streamURL: string; + #baseUrl: string; + #defaultRequestPath = FaunaAPIPaths.QUERY; + #defaultStreamPath = FaunaAPIPaths.STREAM; #keepalive: boolean; constructor({ url, fetch_keepalive }: HTTPClientOptions) { - this.#queryURL = new URL("/query/1", url).toString(); - this.#streamURL = new URL("/stream/1", url).toString(); + this.#baseUrl = url; this.#keepalive = fetch_keepalive; } + #resolveURL(path: string): string { + return new URL(path, this.#baseUrl).toString(); + } + /** {@inheritDoc HTTPClient.request} */ async request({ data, headers: requestHeaders, method, client_timeout_ms, + path = this.#defaultRequestPath, }: HTTPRequest): Promise { const signal = AbortSignal.timeout === undefined @@ -44,7 +50,7 @@ export class FetchClient implements HTTPClient, HTTPStreamClient { })() : AbortSignal.timeout(client_timeout_ms); - const response = await fetch(this.#queryURL, { + const response = await fetch(this.#resolveURL(path), { method, headers: { ...requestHeaders, "Content-Type": "application/json" }, body: JSON.stringify(data), @@ -75,8 +81,9 @@ export class FetchClient implements HTTPClient, HTTPStreamClient { data, headers: requestHeaders, method, + path = this.#defaultStreamPath, }: HTTPStreamRequest): StreamAdapter { - const request = new Request(this.#streamURL, { + const request = new Request(this.#resolveURL(path), { method, headers: { ...requestHeaders, "Content-Type": "application/json" }, body: JSON.stringify(data), diff --git a/src/http-client/http-client.ts b/src/http-client/http-client.ts index ca800184..66ed767c 100644 --- a/src/http-client/http-client.ts +++ b/src/http-client/http-client.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { Client } from "../client"; import { QueryRequest, StreamRequest } from "../wire-protocol"; +import { SupportedFaunaAPIPaths } from "./paths"; /** * An object representing an http request. @@ -20,6 +21,9 @@ export type HTTPRequest = { /** HTTP method to use */ method: "POST"; + + /** The path of the endpoint to call if not using the default */ + path?: SupportedFaunaAPIPaths; }; /** @@ -80,6 +84,9 @@ export type HTTPStreamRequest = { /** HTTP method to use */ method: "POST"; + + /** The path of the endpoint to call if not using the default */ + path?: string; }; /** @@ -91,7 +98,7 @@ export interface StreamAdapter { } /** - * An interface to provide implementation-specific, asyncronous http calls. + * An interface to provide implementation-specific, asynchronous http calls. * This driver provides default implementations for common environments. Users * can configure the {@link Client} to use custom implementations if desired. */ diff --git a/src/http-client/index.ts b/src/http-client/index.ts index 962b8bf5..bea7f9bc 100644 --- a/src/http-client/index.ts +++ b/src/http-client/index.ts @@ -7,6 +7,7 @@ import { } from "./http-client"; import { NodeHTTP2Client } from "./node-http2-client"; +export * from "./paths"; export * from "./fetch-client"; export * from "./http-client"; export * from "./node-http2-client"; diff --git a/src/http-client/node-http2-client.ts b/src/http-client/node-http2-client.ts index 7ee330c2..60bbc3e7 100644 --- a/src/http-client/node-http2-client.ts +++ b/src/http-client/node-http2-client.ts @@ -15,6 +15,7 @@ import { } from "./http-client"; import { NetworkError, getServiceError } from "../errors"; import { QueryFailure } from "../wire-protocol"; +import { FaunaAPIPaths } from "./paths"; // alias http2 types type ClientHttp2Session = any; @@ -35,6 +36,9 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { #numberOfUsers = 0; #session: ClientHttp2Session | null; + #defaultRequestPath = FaunaAPIPaths.QUERY; + #defaultStreamPath = FaunaAPIPaths.STREAM; + private constructor({ http2_session_idle_ms, url, @@ -144,18 +148,18 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { #connect() { // create the session if it does not exist or is closed if (!this.#session || this.#session.closed || this.#session.destroyed) { - const new_session: ClientHttp2Session = http2 + const newSession: ClientHttp2Session = http2 .connect(this.#url, { peerMaxConcurrentStreams: this.#http2_max_streams, }) .once("error", () => this.#closeForAll()) .once("goaway", () => this.#closeForAll()); - new_session.setTimeout(this.#http2_session_idle_ms, () => { + newSession.setTimeout(this.#http2_session_idle_ms, () => { this.#closeForAll(); }); - this.#session = new_session; + this.#session = newSession; } return this.#session; } @@ -165,6 +169,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { data: requestData, headers: requestHeaders, method, + path = this.#defaultRequestPath, }: HTTPRequest): Promise { return new Promise((resolvePromise, rejectPromise) => { let req: ClientHttp2Stream; @@ -195,7 +200,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { try { const httpRequestHeaders: OutgoingHttpHeaders = { ...requestHeaders, - [http2.constants.HTTP2_HEADER_PATH]: "/query/1", + [http2.constants.HTTP2_HEADER_PATH]: path, [http2.constants.HTTP2_HEADER_METHOD]: method, }; @@ -227,6 +232,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { data: requestData, headers: requestHeaders, method, + path = this.#defaultStreamPath, }: HTTPStreamRequest): StreamAdapter { let resolveChunk: (chunk: string[]) => void; let rejectChunk: (reason: any) => void; @@ -298,7 +304,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { async function* reader(): AsyncGenerator { const httpRequestHeaders: OutgoingHttpHeaders = { ...requestHeaders, - [http2.constants.HTTP2_HEADER_PATH]: "/stream/1", + [http2.constants.HTTP2_HEADER_PATH]: path, [http2.constants.HTTP2_HEADER_METHOD]: method, }; diff --git a/src/http-client/paths.ts b/src/http-client/paths.ts new file mode 100644 index 00000000..0a74710e --- /dev/null +++ b/src/http-client/paths.ts @@ -0,0 +1,11 @@ +/** + * Readonly object representing the paths of the Fauna API to be used + * with HTTP clients. + */ +export const FaunaAPIPaths = { + QUERY: "/query/1", + STREAM: "/stream/1", +} as const; + +export type SupportedFaunaAPIPaths = + (typeof FaunaAPIPaths)[keyof typeof FaunaAPIPaths];