-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
2c8879e
commit fc7e9eb
Showing
7 changed files
with
330 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.