Skip to content

Commit

Permalink
feat(http): add http package (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
tugrulates authored Feb 13, 2025
1 parent 2c8879e commit fc7e9eb
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 3 deletions.
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

0 comments on commit fc7e9eb

Please sign in to comment.