Skip to content

Commit

Permalink
Extend HTTPRequest to support path overrides (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
ecooper authored Oct 3, 2024
1 parent 226786c commit dbf5646
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 15 deletions.
38 changes: 35 additions & 3 deletions __tests__/unit/fetch-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
QuerySuccess,
} from "../../src";
import { getDefaultHTTPClientOptions } from "../client";
import { SupportedFaunaAPIPaths } from "../../src/http-client";

let fetchClient: FetchClient;

Expand Down Expand Up @@ -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();
}
Expand All @@ -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());
Expand All @@ -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),
);
});
});
52 changes: 52 additions & 0 deletions __tests__/unit/node-http2-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,62 @@
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());

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",
});
});
});
19 changes: 13 additions & 6 deletions src/http-client/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { getServiceError, NetworkError } from "../errors";
import { QueryFailure } from "../wire-protocol";
import { FaunaAPIPaths } from "./paths";
import {
HTTPClient,
HTTPClientOptions,
Expand All @@ -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<HTTPResponse> {
const signal =
AbortSignal.timeout === undefined
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion src/http-client/http-client.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -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;
};

/**
Expand All @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/http-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 11 additions & 5 deletions src/http-client/node-http2-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -165,6 +169,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient {
data: requestData,
headers: requestHeaders,
method,
path = this.#defaultRequestPath,
}: HTTPRequest): Promise<HTTPResponse> {
return new Promise<HTTPResponse>((resolvePromise, rejectPromise) => {
let req: ClientHttp2Stream;
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -298,7 +304,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient {
async function* reader(): AsyncGenerator<string> {
const httpRequestHeaders: OutgoingHttpHeaders = {
...requestHeaders,
[http2.constants.HTTP2_HEADER_PATH]: "/stream/1",
[http2.constants.HTTP2_HEADER_PATH]: path,
[http2.constants.HTTP2_HEADER_METHOD]: method,
};

Expand Down
11 changes: 11 additions & 0 deletions src/http-client/paths.ts
Original file line number Diff line number Diff line change
@@ -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];

0 comments on commit dbf5646

Please sign in to comment.