Skip to content

Source

Package Location

  • Directorymodules
    • Directorygoget
      • index.ts

npm Dependencies

Terminal window
npm install server-only async-retry gqlmin radash
  • server-only: This package just ensures non of the code reaches the client as this client is primarily used to fetch data on the server. You can remove it if that isn’t a concern.
  • async-retry: This handles the automatic retry functionality, it is fairly crucial to the package.
  • gqlmin: This will minify the GraphQL query, this is mainly used for APIs that include a byte limit on queries. For example; Contentful and ContentStack. This can be easily removed if you want to.
  • radash: Using a few utilities from this package. This is usually a consistent dependency I include. You can easily replace it with something else or plain old JS if you wanted to.

Source Code

import "server-only";
import retry from "async-retry";
import gqlmin from "gqlmin";
import { get, isArray, isEmpty } from "radash";
type _Variables = Record<string, any>;
type _Headers = Record<string, string>;
type GoGetConfig = {
enableQueryDebug: boolean;
defaultHeaders: _Headers;
maxRequestByteSize: number;
totalRetries: number;
};
type Logger = {
info: (
message: string,
args?: {
[key: string]: any;
}
) => void;
};
export type RequestInit = globalThis.RequestInit;
export interface QueryStrategy {
alterInit?: (init: RequestInit) => RequestInit;
postResponse?: (response: Response) => Promise<void>;
}
export type QueryInput<Variables = _Variables> = {
query: string;
variables?: Variables;
/** Additional headers, will override the base headers */
headers?: _Headers;
/**
* Utilise a strategy to modify the `init` object and/or react to response data.
*
* This will be where you and configure the cache strategy for your request.
*/
strategy?: QueryStrategy;
};
export class GoGet {
#uri: string;
#config: GoGetConfig = {
enableQueryDebug: false,
defaultHeaders: {},
maxRequestByteSize: 8192,
totalRetries: 5,
};
#logger: Logger = {
info: console.log,
};
constructor(uri: string, config?: Partial<GoGetConfig>) {
this.#uri = uri;
this.#config = Object.assign(this.#config, config);
}
public setLogger(value: Logger) {
this.#logger = value;
return this;
}
public gimme() {
return this.query;
}
private async query<Response = unknown, Variables = _Variables>(
input: QueryInput<Variables>
): Promise<{
data: Response | null;
error: Error | null;
rawJson: unknown | null;
}> {
const { query, variables, strategy } = input;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
...this.#config.defaultHeaders,
...(input.headers || {}),
};
const body = JSON.stringify({
query: gqlmin(query),
...(isEmpty(variables) ? {} : { variables }),
});
let init: RequestInit = {
method: "POST",
headers,
body,
};
const regex = /\bquery\s+(\w+)/i;
const match = query.match(regex);
const queryName = match ? match[1] : "Unknown";
if (this.#config.enableQueryDebug) {
const bytes = Buffer.byteLength(body, "utf8");
const percentage = (bytes / this.#config.maxRequestByteSize) * 100;
const msg = `[${queryName}] Query size: (${bytes}/${this.#config.maxRequestByteSize}) bytes (${percentage.toFixed(
2
)}%)]`;
if (percentage > 80) {
this.#logger?.info(`\x1b[31m${msg}\x1b[0m`);
} else if (percentage > 50) {
this.#logger?.info(`\x1b[33m${msg}\x1b[0m`);
} else {
this.#logger?.info(`\x1b[32m${msg}\x1b[0m`);
}
}
if (strategy?.alterInit) {
init = strategy.alterInit(init);
}
const result = await retry(
async (_, attempt) => {
const result = await fetch(this.#uri, init);
if (!result.ok) {
switch (result.status) {
case 429:
this.#logger?.info("Too many requests, backing off", {
target: queryName,
attempt,
});
throw new GoGetRateLimitException(
"Too many requests, backing off"
);
default:
return {
data: null,
rawJson: null,
error: new GoGetRequestException(
`${result.status} ${result.statusText}: ${JSON.stringify(
await result.json()
)}`
),
};
}
}
if (strategy?.postResponse) {
await strategy.postResponse(result);
}
const json = await result.json();
const jsonData = get(json, "data");
const jsonErrors = get(json, "errors");
if (!isEmpty(jsonErrors) && isArray(jsonErrors)) {
return {
data: jsonData as Response,
rawJson: json,
error: new GoGetQueryException(jsonErrors),
};
}
return {
data: jsonData as Response,
rawJson: json,
error: null,
};
},
{
retries: this.#config.totalRetries,
}
);
return result;
}
}
export class GoGetRateLimitException extends Error {}
export class GoGetRequestException extends Error {}
export class GoGetQueryException extends Error {
constructor(private errors: any[]) {
super();
}
override get message() {
console.error("[GoGetQueryException]", { errors: this.errors });
const errorMessages = this.errors
.map((error) => error.message ?? undefined)
.filter(Boolean)
.join(". ")
.trim();
return `API returned ${this.errors.length} errors. ${errorMessages}`;
}
}