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(http): add http package #9

Merged
merged 1 commit into from
Feb 13, 2025
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
8 changes: 8 additions & 0 deletions core/http/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@roka/http",
"exports": {
"./graphql": "./graphql.ts",
"./json": "./json.ts",
"./request": "./request.ts"
}
}
88 changes: 88 additions & 0 deletions core/http/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { RequestError } from "@roka/http/request";
import {
type AnyVariables,
cacheExchange,
Client,
fetchExchange,
} from "@urql/core";
import { retryExchange } from "@urql/exchange-retry";

/** A GraphQL client for making queries and handling pagination. */
export class GraphQLClient {
private client;

/**
* Creates an instance of GraphQLClient.
*
* @param url The URL of the GraphQL endpoint.
* @param options Configuration options.
* @param options.token Optional authorization token.
*/
constructor(url: string, options: { token?: string } = {}) {
this.client = new Client({
url,
exchanges: [cacheExchange, retryExchange({}), fetchExchange],
fetchOptions: () => {
return {
headers: {
...(options.token
? { "Authorization": `Bearer ${options.token}` }
: {}),
},
};
},
});
}

/**
* Make a GraphQL query.
*
* @param queryPaths An array of paths to the GraphQL query files.
* @param variables Optional variables for the query.
* @returns The query result.
* @throws {RequestError} If the query fails.
*/
async query<T>(
queryPaths: string[],
variables: AnyVariables = {},
): Promise<T> {
const query = (await Promise.all(
queryPaths.map(async (path) =>
await Deno.readTextFile(
new URL(`${path}.graphql`, Deno.mainModule),
)
),
)).join("\n");
const response = await this.client.query(query, variables);
if (response.error) {
throw new RequestError(response.error.message);
}
return response.data as T;
}

/**
* Make a paginated GraphQL query.
*
* @param queryPaths An array of paths to the GraphQL query files.
* @param getEdges A function to extract edges from the query result.
* @param getCursor A function to extract the cursor from the query result.
*/
async queryPaginated<T, U>(
queryPaths: string[],
getEdges: (data: T) => { edges: { node: U }[] },
getCursor: (data: T) => string | null,
variables: AnyVariables & { cursor?: string; limit?: number } = {},
): Promise<U[]> {
let nodes: U[] = [];
let cursor: string | null = null;
do {
const data = await this.query<T>(queryPaths, { ...variables, cursor });
nodes = nodes.concat(getEdges(data).edges.map((edge) => edge.node));
cursor = getCursor(data);
} while (
cursor &&
(variables.limit === undefined || nodes.length < variables.limit)
);
return nodes;
}
}
73 changes: 73 additions & 0 deletions core/http/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { request } from "@roka/http/request";

/** A client for making JSON-based HTTP requests. */
export class JsonClient {
/**
* Creates an instance of the JsonClient with the specified URL and options.
*
* @param url The base URL to which the requests are made.
* @param options Parameters for the requests.
* @param options.token Optional token for authentication.
* @param options.referrer Optional referrer for the requests.
*/
constructor(
private url: string,
private options: { token?: string; referrer?: string } = {},
) {}

/**
* Sends a GET request to the specified path.
*
* @template T The expected response type.
* @param path The path to which the GET request is made.
* @returns The response data.
*/
async get<T>(path: string): Promise<T> {
const { response } = await request<T>(`${this.url}${path}`, {
headers: {
"Accept": "application/json; charset=UTF-8",
},
...this.options,
});
return await response.json();
}

/**
* Sends a POST request to the specified path with the provided body.
*
* @template T The expected response type.
* @param path The path to which the POST request is made.
* @param body The body of the POST request.
* @returns The response data.
*/
async post<T>(path: string, body: object): Promise<T> {
const { response } = await request<T>(`${this.url}${path}`, {
method: "POST",
headers: {
"Accept": "application/json; charset=UTF-8",
"Content-Type": "application/json; charset=UTF-8",
},
body,
...this.options,
});
return await response.json();
}

/**
* Sends a DELETE request to the specified path.
*
* @template T The expected response type.
* @param path The path to which the DELETE request is made.
* @returns The response data.
*/
async delete<T>(path: string): Promise<T> {
const { response } = await request<T>(`${this.url}${path}`, {
method: "DELETE",
headers: {
"Accept": "application/json; charset=UTF-8",
},
...this.options,
});
return await response.json();
}
}
9 changes: 9 additions & 0 deletions core/http/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Utilities for maknig HTTP requests.
*
* @module
*/

export * from "./graphql.ts";
export * from "./json.ts";
export * from "./request.ts";
116 changes: 116 additions & 0 deletions core/http/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { retry, type RetryOptions } from "@std/async";

export { type RetryOptions } from "@std/async";

/** Represents an error that occurs during a request. */
export class RequestError extends Error {
/**
* Creates an instance of RequestError.
*
* @param msg The error message to be associated with this error.
*/
constructor(msg: string) {
super(msg);
this.name = "RequestError";
}
}

const RETRYABLE_STATUSES = [
429, // Too many requests
504, // Gateway timeout
];

/** Represents an HTTP request method. */
export type RequestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";

/** Represents the options for an HTTP request. */
export interface RequestOptions {
/** Errors that would not cause a {@linkcode RequestError}. */
allowedErrors?: string[];
/** Request body. */
body?: string | object;
/** Request method. */
method?: RequestMethod;
/** Request headers. */
headers?: Record<string, string>;
/** Retry options. */
retry?: RetryOptions;
/** Authentication token. */
token?: string;
/** Referrer. */
referrer?: string;
/** User agent. */
userAgent?: string;
}

/**
* Makes an HTTP request with the given URL and options, and returns a response object.
* Retries the request if it fails due to too many requests.
*
* @template T The expected response type.
* @param url The URL to request.
* @param options The options for the request.
* @returns An object containing the response and optionally an error.
* @throws {RequestError} If the response is not ok and the error type is not allowed.
*/
export async function request<T>(
url: string,
options: RequestOptions = {},
): Promise<{
response: Response;
error?: { type: string; message: string };
}> {
const response = await retry(async () => {
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
...options.headers,
...(options.token
? { "Authorization": `Bearer ${options.token}` }
: {}),
...(options.referrer ? { "Referer": options.referrer } : {}),
"User-Agent": options.userAgent ??
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1.1 Safari/605.1.15",
},
...options.body
? {
body: (typeof options.body === "string"
? options.body
: JSON.stringify(options.body)),
}
: {},
});
if (RETRYABLE_STATUSES.includes(response.status)) {
await response.body?.cancel();
throw new RequestError(response.statusText);
}
return response;
}, options.retry);

if (!response.ok) {
const error = await getErrorFromResponse(response);
if (options.allowedErrors?.includes(error.type)) {
return { response, error };
}
throw new RequestError(`${error.message} [${error.type}]`);
}

return { response };
}

async function getErrorFromResponse(
response: Response,
): Promise<{ type: string; message: string }> {
const text = await response.text();
try {
const { error } = JSON.parse(text) as {
error: { type: string; message: string };
};
return error;
} catch {
return {
type: response.status.toString(),
message: response.statusText,
};
}
}
6 changes: 5 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"workspace": [
"./core/git",
"./core/github",
"./core/http",
"./core/testing"
],
"tasks": {
Expand Down Expand Up @@ -33,8 +34,11 @@
"@octokit/openapi-types": "npm:@octokit/openapi-types@^23.0.1",
"@octokit/rest": "npm:@octokit/rest@^21.1.0",
"@std/assert": "jsr:@std/assert@^1.0.11",
"@std/async": "jsr:@std/async@^1.0.10",
"@std/collections": "jsr:@std/collections@^1.0.10",
"@std/path": "jsr:@std/path@^1.0.8",
"@std/testing": "jsr:@std/testing@^1.0.9"
"@std/testing": "jsr:@std/testing@^1.0.9",
"@urql/core": "npm:@urql/core@^5.1.0",
"@urql/exchange-retry": "npm:@urql/exchange-retry@^1.3.0"
}
}
Loading