From 97e8e3ad1cfa0d570bc4c30ee95752de798cac04 Mon Sep 17 00:00:00 2001 From: Tugrul Ates Date: Fri, 14 Feb 2025 00:37:39 +0100 Subject: [PATCH] feat(http): add http package --- core/http/deno.json | 8 +++ core/http/graphql.ts | 88 ++++++++++++++++++++++++++++++++ core/http/json.ts | 73 +++++++++++++++++++++++++++ core/http/mod.ts | 9 ++++ core/http/request.ts | 116 +++++++++++++++++++++++++++++++++++++++++++ deno.json | 6 ++- deno.lock | 33 +++++++++++- 7 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 core/http/deno.json create mode 100644 core/http/graphql.ts create mode 100644 core/http/json.ts create mode 100644 core/http/mod.ts create mode 100644 core/http/request.ts diff --git a/core/http/deno.json b/core/http/deno.json new file mode 100644 index 0000000..1536219 --- /dev/null +++ b/core/http/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@roka/http", + "exports": { + "./graphql": "./graphql.ts", + "./json": "./json.ts", + "./request": "./request.ts" + } +} diff --git a/core/http/graphql.ts b/core/http/graphql.ts new file mode 100644 index 0000000..0113af2 --- /dev/null +++ b/core/http/graphql.ts @@ -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( + queryPaths: string[], + variables: AnyVariables = {}, + ): Promise { + 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( + queryPaths: string[], + getEdges: (data: T) => { edges: { node: U }[] }, + getCursor: (data: T) => string | null, + variables: AnyVariables & { cursor?: string; limit?: number } = {}, + ): Promise { + let nodes: U[] = []; + let cursor: string | null = null; + do { + const data = await this.query(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; + } +} diff --git a/core/http/json.ts b/core/http/json.ts new file mode 100644 index 0000000..7646e64 --- /dev/null +++ b/core/http/json.ts @@ -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(path: string): Promise { + const { response } = await request(`${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(path: string, body: object): Promise { + const { response } = await request(`${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(path: string): Promise { + const { response } = await request(`${this.url}${path}`, { + method: "DELETE", + headers: { + "Accept": "application/json; charset=UTF-8", + }, + ...this.options, + }); + return await response.json(); + } +} diff --git a/core/http/mod.ts b/core/http/mod.ts new file mode 100644 index 0000000..c284274 --- /dev/null +++ b/core/http/mod.ts @@ -0,0 +1,9 @@ +/** + * Utilities for maknig HTTP requests. + * + * @module + */ + +export * from "./graphql.ts"; +export * from "./json.ts"; +export * from "./request.ts"; diff --git a/core/http/request.ts b/core/http/request.ts new file mode 100644 index 0000000..706dfa9 --- /dev/null +++ b/core/http/request.ts @@ -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; + /** 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( + 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, + }; + } +} diff --git a/deno.json b/deno.json index 7d4e051..6472204 100644 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "workspace": [ "./core/git", "./core/github", + "./core/http", "./core/testing" ], "tasks": { @@ -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" } } diff --git a/deno.lock b/deno.lock index 55d1d74..02d740f 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@std/assert@^1.0.10": "1.0.11", "jsr:@std/assert@^1.0.11": "1.0.11", + "jsr:@std/async@^1.0.10": "1.0.10", "jsr:@std/collections@^1.0.10": "1.0.10", "jsr:@std/data-structures@^1.0.6": "1.0.6", "jsr:@std/fs@^1.0.9": "1.0.11", @@ -10,7 +11,9 @@ "jsr:@std/path@^1.0.8": "1.0.8", "jsr:@std/testing@^1.0.9": "1.0.9", "npm:@octokit/openapi-types@^23.0.1": "23.0.1", - "npm:@octokit/rest@^21.1.0": "21.1.0_@octokit+core@6.1.3" + "npm:@octokit/rest@^21.1.0": "21.1.0_@octokit+core@6.1.3", + "npm:@urql/core@^5.1.0": "5.1.0", + "npm:@urql/exchange-retry@^1.3.0": "1.3.0_@urql+core@5.1.0" }, "jsr": { "@std/assert@1.0.11": { @@ -19,6 +22,9 @@ "jsr:@std/internal" ] }, + "@std/async@1.0.10": { + "integrity": "2ff1b1c7d33d1416159989b0f69e59ec7ee8cb58510df01e454def2108b3dbec" + }, "@std/collections@1.0.10": { "integrity": "903af106a3d92970d74e20f7ebff77d9658af9bef4403f1dc42a7801c0575899" }, @@ -49,6 +55,9 @@ } }, "npm": { + "@0no-co/graphql.web@1.0.13": { + "integrity": "sha512-jqYxOevheVTU1S36ZdzAkJIdvRp2m3OYIG5SEoKDw5NI8eVwkoI0D/Q3DYNGmXCxkA6CQuoa7zvMiDPTLqUNuw==" + }, "@octokit/auth-token@5.1.2": { "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==" }, @@ -133,6 +142,20 @@ "@octokit/openapi-types" ] }, + "@urql/core@5.1.0": { + "integrity": "sha512-yC3sw8yqjbX45GbXxfiBY8GLYCiyW/hLBbQF9l3TJrv4ro00Y0ChkKaD9I2KntRxAVm9IYBqh0awX8fwWAe/Yw==", + "dependencies": [ + "@0no-co/graphql.web", + "wonka" + ] + }, + "@urql/exchange-retry@1.3.0_@urql+core@5.1.0": { + "integrity": "sha512-FLt+d81gP4oiHah4hWFDApimc+/xABWMU1AMYsZ1PVB0L0YPtrMCjbOp9WMM7hBzy4gbTDrG24sio0dCfSh/HQ==", + "dependencies": [ + "@urql/core", + "wonka" + ] + }, "before-after-hook@3.0.2": { "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" }, @@ -141,16 +164,22 @@ }, "universal-user-agent@7.0.2": { "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" + }, + "wonka@6.3.4": { + "integrity": "sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==" } }, "workspace": { "dependencies": [ "jsr:@std/assert@^1.0.11", + "jsr:@std/async@^1.0.10", "jsr:@std/collections@^1.0.10", "jsr:@std/path@^1.0.8", "jsr:@std/testing@^1.0.9", "npm:@octokit/openapi-types@^23.0.1", - "npm:@octokit/rest@^21.1.0" + "npm:@octokit/rest@^21.1.0", + "npm:@urql/core@^5.1.0", + "npm:@urql/exchange-retry@^1.3.0" ] } }