From e6fadc1dbff735ee429f90c0184a502775a13cdc Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Sat, 20 Apr 2024 19:15:41 +0200 Subject: [PATCH 1/4] Create `API.with()` and `ChildAPI` --- lib/index.ts | 88 +++++++++++++++++++++++++++++++++++++++-------- lib/tests/test.ts | 74 +++++++++++++++++++++++---------------- 2 files changed, 117 insertions(+), 45 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index 6443f02..def977c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -71,6 +71,29 @@ function correctType(x: any): any { return x } +function anySignal(signals: AbortSignal[]) { + const controller = new AbortController() + const unsubscribe: (() => void)[] = [] + + function onAbort(signal: AbortSignal) { + controller.abort(signal.reason) + unsubscribe.forEach((f) => f()) + } + + for (const signal of signals) { + if (signal.aborted) { + onAbort(signal) + break + } + + const handler = onAbort.bind(undefined, signal) + signal.addEventListener('abort', handler) + unsubscribe.push(() => signal.removeEventListener('abort', handler)) + } + + return controller.signal +} + /** * Generates a link for users to click on in order to use your application! @@ -114,8 +137,14 @@ export class APIError { export class API { // ACCESS TOKEN STUFF + private _access_token: string = "" /** The key that allows you to talk with the API */ - access_token: string = "" + get access_token() { + return this._access_token + } + set access_token(token: string) { + this._access_token = token + } /** Should always be "Bearer" */ token_type: string = "Bearer" @@ -275,6 +304,11 @@ export class API { await new_api.getAndSetToken({client_id: client.id, client_secret: client.secret, grant_type: "client_credentials", scope: "public"}, new_api) } + public with(overrides: RequestInit): ChildAPI { + const child_api = new ChildAPI(this, overrides) + return child_api + } + /** * Get a websocket to get WebSocket events from! * @param server Where the "notification websocket/server" is @@ -422,13 +456,23 @@ export class API { * @param info Context given by a prior request * @returns A Promise with the API's response */ - public async request(method: "get" | "post" | "put" | "delete", endpoint: string, - parameters: {[k: string]: any} = {}, info: {number_try: number, just_refreshed: boolean} = {number_try: 1, just_refreshed: false}): Promise { + public async request(method: "get" | "post" | "put" | "delete", endpoint: string, parameters: {[k: string]: any} = {}, overrides?: RequestInit, + info: {number_try: number, just_refreshed: boolean} = {number_try: 1, just_refreshed: false}): Promise { let to_retry = false let error_object: Error | undefined let error_code: number | undefined let error_string = "none" + const timeout_controller = new AbortController() + const timeout_signal = timeout_controller.signal + const timeout_timer = this.timeout > 0 ? setTimeout(() => { + if (this.retry.on_timeout) { + to_retry = true + } + timeout_controller.abort() + }, this.timeout * 1000) : false + const signal = overrides?.signal ? anySignal([timeout_signal, overrides.signal]) : timeout_signal + // For GET requests specifically, requests need to be shaped in very particular ways if (parameters !== undefined && method === "get") { // If a parameter is an empty string or is undefined, remove it @@ -479,14 +523,6 @@ export class API { return param[1].map((array_element) => `${param[0]}=${array_element}`).join("&") }).join("&")) : "") - const controller = new AbortController() - const timer = this.timeout > 0 ? setTimeout(() => { - if (this.retry.on_timeout) { - to_retry = true - } - controller.abort() - }, this.timeout * 1000) : false - const response = await fetch(url, { method, headers: { @@ -497,7 +533,7 @@ export class API { "Authorization": `${this.token_type} ${this.access_token}` }, body: method !== "get" ? JSON.stringify(parameters) : undefined, // parameters are here if request is NOT GET - signal: controller.signal + signal }) .catch((error: AbortError | FetchError) => { this.log(true, error.message) @@ -505,8 +541,8 @@ export class API { error_string = `${error.name} (${error.name === "FetchError" ? error.errno : error.type})` }) .finally(() => { - if (timer) { - clearTimeout(timer) + if (timeout_timer) { + clearTimeout(timeout_timer) } }) @@ -549,7 +585,7 @@ export class API { if (to_retry === true && this.retry.disabled === false && info.number_try < this.retry.maximum_amount) { this.log(true, `Will request again in ${this.retry.delay} seconds...`, `(Try #${info.number_try})`) await new Promise(res => setTimeout(res, this.retry.delay)) - return await this.request(method, endpoint, parameters, {number_try: info.number_try + 1, just_refreshed: info.just_refreshed}) + return await this.request(method, endpoint, parameters, overrides, {number_try: info.number_try + 1, just_refreshed: info.just_refreshed}) } throw new APIError(error_string, `${this.server}/api/v2`, endpoint, parameters, error_code, error_object) @@ -830,3 +866,25 @@ export class API { return await this.request("get", "seasonal-backgrounds") } } + +export class ChildAPI extends API { + original: API + overrides: RequestInit + + get access_token(): string {return this.original.access_token} + get refresh_token(): string | undefined {return this.original.refresh_token} + get refresh_timeout(): NodeJS.Timeout | undefined {return this.original.refresh_timeout} + refreshToken = async () => {return await this.original.refreshToken()} + request = async (...args: Parameters) => { + console.log("You would see this if ChildAPI.request() was called") // but it's not called + args[3] ??= this.overrides + return await this.original.request(...args) + } + + constructor(original: API, overrides: RequestInit) { + super({}) + + this.original = original + this.overrides = overrides + } +} diff --git a/lib/tests/test.ts b/lib/tests/test.ts index 493659c..5c03798 100644 --- a/lib/tests/test.ts +++ b/lib/tests/test.ts @@ -341,36 +341,50 @@ const testOther = async (): Promise => { const test = async (id: string, secret: string): Promise => { api = await osu.API.createAsync({id: Number(id), secret}, undefined, "all") //"http://127.0.0.1:8080") - const tests = [ - testBeatmapPack, - testBeatmap, - testBeatmapsetDiscussion, - testBeatmapset, - testChangelog, - testComment, - testEvent, - testForum, - testHome, - testMultiplayer, - testNews, - testRanking, - testUser, - testWiki, - testOther, - ] - - const results: {test_name: string, passed: boolean}[] = [] - for (let i = 0; i < tests.length; i++) { - results.push({test_name: tests[i].name, passed: await tests[i]()}) - } - console.log("\n", ...results.map((r) => `${r.test_name}: ${r.passed ? "✔️" : "❌"}\n`)) - await api.revokeToken() - - if (!results.find((r) => !r.passed)) { - console.log("✔️ Looks like the test went well!") - } else { - throw new Error("❌ Something in the test went wrong...") - } + const controller = new AbortController() + const new_api = api.with({signal: controller.signal}) + + setTimeout(() => controller.abort(), 300) + + await Promise.all([ + attempt(api.getUser, 7276846), + attempt(api.getUser, 7276845), + attempt(new_api.getUser, 7276846), + attempt(new_api.getUser, 7276845), + ]) + + api.revokeToken() + + // const tests = [ + // testBeatmapPack, + // testBeatmap, + // testBeatmapsetDiscussion, + // testBeatmapset, + // testChangelog, + // testComment, + // testEvent, + // testForum, + // testHome, + // testMultiplayer, + // testNews, + // testRanking, + // testUser, + // testWiki, + // testOther, + // ] + + // const results: {test_name: string, passed: boolean}[] = [] + // for (let i = 0; i < tests.length; i++) { + // results.push({test_name: tests[i].name, passed: await tests[i]()}) + // } + // console.log("\n", ...results.map((r) => `${r.test_name}: ${r.passed ? "✔️" : "❌"}\n`)) + // await api.revokeToken() + + // if (!results.find((r) => !r.passed)) { + // console.log("✔️ Looks like the test went well!") + // } else { + // throw new Error("❌ Something in the test went wrong...") + // } } test(process.env.ID, process.env.SECRET) From 63fa1e01056cb4189dfd5cddcc1e74dc93902943 Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Sat, 20 Apr 2024 21:34:03 +0200 Subject: [PATCH 2/4] Move helper functions from `index.ts` to `misc.ts` --- lib/index.ts | 108 ++++-------------------------------------- lib/misc.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++ lib/tests/test.ts | 14 ++---- 3 files changed, 129 insertions(+), 109 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index def977c..310d283 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -15,7 +15,7 @@ import { Forum } from "./forum.js" import { WikiPage } from "./wiki.js" import { NewsPost } from "./news.js" import { Home } from "./home.js" -import { Scope, Spotlight } from "./misc.js" +import { Scope, Spotlight, adaptParametersForGETRequests, anySignal, correctType } from "./misc.js" import { Chat } from "./chat.js" import { Comment } from "./comment.js" @@ -38,61 +38,6 @@ export { Ruleset, Mod, Scope, Spotlight } from "./misc.js" export { Chat } from "./chat.js" export { WebSocket } from "./websocket.js" export { Comment } from "./comment.js" - -/** - * Some stuff doesn't have the right type to begin with, such as dates, which are being returned as strings, this fixes that - * @param x Anything, but should be a string, an array that contains a string, or an object which has a string - * @returns x, but with it (or what it contains) now having the correct type - */ -function correctType(x: any): any { - const bannedProperties = [ - "name", "artist", "title", "location", "interests", "occupation", "twitter", - "discord", "version", "author", "raw", "bbcode", "title", "message", "creator", "source" - ] - - if (typeof x === "boolean") { - return x - } else if (/^[+-[0-9][0-9]+-[0-9]{2}-[0-9]{2}($|[ T].*)/.test(x)) { - if (/[0-9]{2}:[0-9]{2}:[0-9]{2}$/.test(x)) x += "Z" - if (/[0-9]{2}:[0-9]{2}:[0-9]{2}\+[0-9]{2}:[0-9]{2}$/.test(x)) x = x.substring(0, x.indexOf("+")) + "Z" - return new Date(x) - } else if (Array.isArray(x)) { - return x.map((e) => correctType(e)) - } else if (!isNaN(x) && x !== "") { - return x === null ? null : Number(x) - } else if (typeof x === "object" && x !== null) { - const k = Object.keys(x) - const v = Object.values(x) - for (let i = 0; i < k.length; i++) { - if (typeof v[i] === "string" && bannedProperties.some((p) => k[i].includes(p))) continue // don't turn names made of numbers into integers - x[k[i]] = correctType(v[i]) - } - } - return x -} - -function anySignal(signals: AbortSignal[]) { - const controller = new AbortController() - const unsubscribe: (() => void)[] = [] - - function onAbort(signal: AbortSignal) { - controller.abort(signal.reason) - unsubscribe.forEach((f) => f()) - } - - for (const signal of signals) { - if (signal.aborted) { - onAbort(signal) - break - } - - const handler = onAbort.bind(undefined, signal) - signal.addEventListener('abort', handler) - unsubscribe.push(() => signal.removeEventListener('abort', handler)) - } - - return controller.signal -} /** @@ -379,7 +324,7 @@ export class API { }, api: API): Promise { const controller = new AbortController() const timer = this.timeout > 0 ? setTimeout(() => { - controller.abort() + controller.abort(`The request wasn't made in time (took more than ${this.timeout} seconds)`) }, this.timeout * 1000) : false const response = await fetch(`${this.server}/oauth/token`, { @@ -456,7 +401,7 @@ export class API { * @param info Context given by a prior request * @returns A Promise with the API's response */ - public async request(method: "get" | "post" | "put" | "delete", endpoint: string, parameters: {[k: string]: any} = {}, overrides?: RequestInit, + public async request(method: "get" | "post" | "put" | "delete", endpoint: string, parameters: {[k: string]: any} = {}, overrides?: ChildAPI["overrides"], info: {number_try: number, just_refreshed: boolean} = {number_try: 1, just_refreshed: false}): Promise { let to_retry = false let error_object: Error | undefined @@ -475,46 +420,7 @@ export class API { // For GET requests specifically, requests need to be shaped in very particular ways if (parameters !== undefined && method === "get") { - // If a parameter is an empty string or is undefined, remove it - for (let i = 0; i < Object.entries(parameters).length; i++) { - if (!String(Object.values(parameters)[i]).length || Object.values(parameters)[i] === undefined) { - delete parameters[Object.keys(parameters)[i]] - i-- - } - } - - // If a parameter is an Array, add "[]" to its name, so the server understands the request properly - for (let i = 0; i < Object.entries(parameters).length; i++) { - if (Array.isArray(Object.values(parameters)[i]) && !Object.keys(parameters)[i].includes("[]")) { - parameters[`${Object.keys(parameters)[i]}[]`] = Object.values(parameters)[i] - delete parameters[Object.keys(parameters)[i]] - i-- - } - } - - // If a parameter is an object, add its properties in "[]" such as "cursor[id]=5&cursor[score]=36.234" - let parameters_to_add: {[k: string]: any} = {} - for (let i = 0; i < Object.entries(parameters).length; i++) { - const value = Object.values(parameters)[i] - if (typeof value === "object" && !Array.isArray(value) && value !== null) { - const main_name = Object.keys(parameters)[i] - for (let e = 0; e < Object.entries(value).length; e++) { - parameters_to_add[`${main_name}[${Object.keys(value)[e]}]`] = Object.values(value)[e] - } - delete parameters[Object.keys(parameters)[i]] - i-- - } - } - for (let i = 0; i < Object.entries(parameters_to_add).length; i++) { - parameters[Object.keys(parameters_to_add)[i]] = Object.values(parameters_to_add)[i] - } - - // If a parameter is a date, make it a string - for (let i = 0; i < Object.entries(parameters).length; i++) { - if (Object.values(parameters)[i] instanceof Date) { - parameters[Object.keys(parameters)[i]] = (Object.values(parameters)[i] as Date).toISOString() - } - } + parameters = adaptParametersForGETRequests(parameters) } // parameters are here if request is GET @@ -867,6 +773,9 @@ export class API { } } +/** + * Created with {@link API.with} + */ export class ChildAPI extends API { original: API overrides: RequestInit @@ -876,12 +785,11 @@ export class ChildAPI extends API { get refresh_timeout(): NodeJS.Timeout | undefined {return this.original.refresh_timeout} refreshToken = async () => {return await this.original.refreshToken()} request = async (...args: Parameters) => { - console.log("You would see this if ChildAPI.request() was called") // but it's not called args[3] ??= this.overrides return await this.original.request(...args) } - constructor(original: API, overrides: RequestInit) { + constructor(original: ChildAPI["original"], overrides: ChildAPI["overrides"]) { super({}) this.original = original diff --git a/lib/misc.ts b/lib/misc.ts index fc0fd4f..068f738 100644 --- a/lib/misc.ts +++ b/lib/misc.ts @@ -1,5 +1,8 @@ import { API } from "./index.js" + +// EXPORTED FOR PACKAGE USERS + /** * Scopes determine what the API instance can do as a user! * https://osu.ppy.sh/docs/index.html#scopes @@ -47,6 +50,119 @@ export namespace Spotlight { } } + +// PRIVATE TO PACKAGE USERS + +/** + * When using `fetch()` for a GET request, you can't just give the parameters the same way you'd give them for a POST request! + * @param parameters The parameters as they'd be for a POST request (prior to using `JSON.stringify`) + * @returns Parameters adapted for a GET request + */ +export function adaptParametersForGETRequests(parameters: {[k: string]: any}): {[k: string]: any} { + // If a parameter is an empty string or is undefined, remove it + for (let i = 0; i < Object.entries(parameters).length; i++) { + if (!String(Object.values(parameters)[i]).length || Object.values(parameters)[i] === undefined) { + delete parameters[Object.keys(parameters)[i]] + i-- + } + } + + // If a parameter is an Array, add "[]" to its name, so the server understands the request properly + for (let i = 0; i < Object.entries(parameters).length; i++) { + if (Array.isArray(Object.values(parameters)[i]) && !Object.keys(parameters)[i].includes("[]")) { + parameters[`${Object.keys(parameters)[i]}[]`] = Object.values(parameters)[i] + delete parameters[Object.keys(parameters)[i]] + i-- + } + } + + // If a parameter is an object, add its properties in "[]" such as "cursor[id]=5&cursor[score]=36.234" + let parameters_to_add: {[k: string]: any} = {} + for (let i = 0; i < Object.entries(parameters).length; i++) { + const value = Object.values(parameters)[i] + if (typeof value === "object" && !Array.isArray(value) && value !== null) { + const main_name = Object.keys(parameters)[i] + for (let e = 0; e < Object.entries(value).length; e++) { + parameters_to_add[`${main_name}[${Object.keys(value)[e]}]`] = Object.values(value)[e] + } + delete parameters[Object.keys(parameters)[i]] + i-- + } + } + for (let i = 0; i < Object.entries(parameters_to_add).length; i++) { + parameters[Object.keys(parameters_to_add)[i]] = Object.values(parameters_to_add)[i] + } + + // If a parameter is a date, make it a string + for (let i = 0; i < Object.entries(parameters).length; i++) { + if (Object.values(parameters)[i] instanceof Date) { + parameters[Object.keys(parameters)[i]] = (Object.values(parameters)[i] as Date).toISOString() + } + } + + return parameters +} + +/** + * Some stuff doesn't have the right type to begin with, such as dates, which are being returned as strings, this fixes that + * @param x Anything, but should be a string, an array that contains a string, or an object which has a string + * @returns x, but with it (or what it contains) now having the correct type + */ +export function correctType(x: any): any { + const bannedProperties = [ + "name", "artist", "title", "location", "interests", "occupation", "twitter", + "discord", "version", "author", "raw", "bbcode", "title", "message", "creator", "source" + ] + + if (typeof x === "boolean") { + return x + } else if (/^[+-[0-9][0-9]+-[0-9]{2}-[0-9]{2}($|[ T].*)/.test(x)) { + if (/[0-9]{2}:[0-9]{2}:[0-9]{2}$/.test(x)) x += "Z" + if (/[0-9]{2}:[0-9]{2}:[0-9]{2}\+[0-9]{2}:[0-9]{2}$/.test(x)) x = x.substring(0, x.indexOf("+")) + "Z" + return new Date(x) + } else if (Array.isArray(x)) { + return x.map((e) => correctType(e)) + } else if (!isNaN(x) && x !== "") { + return x === null ? null : Number(x) + } else if (typeof x === "object" && x !== null) { + const k = Object.keys(x) + const v = Object.values(x) + for (let i = 0; i < k.length; i++) { + if (typeof v[i] === "string" && bannedProperties.some((p) => k[i].includes(p))) continue // don't turn names made of numbers into integers + x[k[i]] = correctType(v[i]) + } + } + return x +} + +/** + * This is an alternative to `AbortSignal.any` that is friendly to older versions of Node.js, it was provided by the kind https://github.com/baileyherbert + * @param signals The multiple signals you'd want `fetch()` to take + * @returns A signal that's aborted when any of the `signals` is aborted + */ +export function anySignal(signals: AbortSignal[]) { + const controller = new AbortController() + const unsubscribe: (() => void)[] = [] + + function onAbort(signal: AbortSignal) { + controller.abort(signal.reason) + unsubscribe.forEach((f) => f()) + } + + for (const signal of signals) { + if (signal.aborted) { + onAbort(signal) + break + } + + const handler = onAbort.bind(undefined, signal) + signal.addEventListener("abort", handler) + unsubscribe.push(() => signal.removeEventListener("abort", handler)) + } + + return controller.signal +} + /** * A function that makes it easy to get the id from the argument of a function * @param arg The id or the object with the id diff --git a/lib/tests/test.ts b/lib/tests/test.ts index 5c03798..e65a15b 100644 --- a/lib/tests/test.ts +++ b/lib/tests/test.ts @@ -343,15 +343,11 @@ const test = async (id: string, secret: string): Promise => { const controller = new AbortController() const new_api = api.with({signal: controller.signal}) - - setTimeout(() => controller.abort(), 300) - - await Promise.all([ - attempt(api.getUser, 7276846), - attempt(api.getUser, 7276845), - attempt(new_api.getUser, 7276846), - attempt(new_api.getUser, 7276845), - ]) + console.log("reason:", controller.signal.reason) + setTimeout(() => controller.abort("manual abort"), 200) + await new_api.getUser(7276846) + .catch((e: osu.APIError) => console.log(e.original_error)) + console.log("reason:", controller.signal.reason) api.revokeToken() From 00295ff988c7ff5862b372f2f21899e007e01f87 Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Sun, 21 Apr 2024 00:35:32 +0200 Subject: [PATCH 3/4] Transform `API` properties to accessors & hide `ChildAPI` accessors This should make it so package users don't start using `ChildAPI` in unorthodox ways --- lib/index.ts | 133 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 89 insertions(+), 44 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index 310d283..2857f87 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,4 +1,4 @@ -import fetch, { AbortError, FetchError } from "node-fetch" +import fetch, { AbortError, FetchError, RequestInit } from "node-fetch" import { WebSocket } from "ws" import { User } from "./user.js" @@ -84,22 +84,18 @@ export class API { private _access_token: string = "" /** The key that allows you to talk with the API */ - get access_token() { - return this._access_token - } - set access_token(token: string) { - this._access_token = token - } + get access_token() {return this._access_token} + set access_token(token) {this._access_token = token} + private _token_type: string = "Bearer" /** Should always be "Bearer" */ - token_type: string = "Bearer" + get token_type() {return this._token_type} + set token_type(token) {this._token_type = token} - private _expires: Date = new Date(new Date().getTime() + 24 * 60 * 60 * 1000) + private _expires: Date = new Date(new Date().getTime() + 24 * 60 * 60 * 1000) // in 24 hours /** The expiration date of your access_token */ - get expires(): Date { - return this._expires - } - set expires(date: Date) { + get expires() {return this._expires} + set expires(date) { this._expires = date this.updateRefreshTimeout() } @@ -112,34 +108,30 @@ export class API { * Valid for an unknown amount of time, allows you to get a new token without going through the Authorization Code Grant again! * Use {@link API.refreshToken} to do that */ - get refresh_token(): string | undefined { - return this._refresh_token - } - set refresh_token(token: string | undefined) { + get refresh_token() {return this._refresh_token} + set refresh_token(token) { this._refresh_token = token this.updateRefreshTimeout() // because the refresh token may be specified last } - + + private _refresh_on_401: boolean = true /** If true, upon failing a request due to a 401, it will use the {@link API.refresh_token} if it exists (defaults to **true**) */ - refresh_on_401: boolean = true + get refresh_on_401() {return this._refresh_on_401} + set refresh_on_401(refresh) {this._refresh_on_401 = refresh} private _refresh_on_expires: boolean = true /** * If true, the application will silently use the {@link API.refresh_token} right before the {@link API.access_token} expires, * as determined by {@link API.expires} (defaults to **true**) */ - get refresh_on_expires(): boolean { - return this._refresh_on_expires - } - set refresh_on_expires(enabled: boolean) { + get refresh_on_expires() {return this._refresh_on_expires} + set refresh_on_expires(enabled) { this._refresh_on_expires = enabled this.updateRefreshTimeout() } private _refresh_timeout?: NodeJS.Timeout - get refresh_timeout(): NodeJS.Timeout | undefined { - return this._refresh_timeout - } + get refresh_timeout(): API["_refresh_timeout"] {return this._refresh_timeout} set refresh_timeout(timeout: NodeJS.Timeout) { // if a previous one already exists, clear it if (this._refresh_timeout) { @@ -153,34 +145,49 @@ export class API { // CLIENT INFO - client: { + private _client: { id: number secret: string } = {id: 0, secret: ""} + /** The details of your client, which you've got from https://osu.ppy.sh/home/account/edit#oauth */ + get client() {return this._client} + set client(client) {this._client = client} + + private _server: string = "https://osu.ppy.sh" /** * The base url of the server where the requests should land (defaults to **https://osu.ppy.sh**) * @remarks For tokens, requests will be sent to the `oauth/token` route, other requests will be sent to the `api/v2` route */ - server: string = "https://osu.ppy.sh" + get server() {return this._server} + set server(server) {this._server = server} + + private _user?: User["id"] /** The osu! user id of the user who went through the Authorization Code Grant */ - user?: User["id"] + get user() {return this._user} + set user(user) {this._user = user} + + private _scopes?: Scope[] /** The {@link Scope}s your application has, assuming it acts as a user */ - scopes?: Scope[] + get scopes() {return this._scopes} + set scopes(scopes) {this._scopes = scopes} // CLIENT CONFIGURATION + private _verbose?: "none" | "errors" | "all" = "none" /** Which events should be logged (defaults to **none**) */ - verbose: "none" | "errors" | "all" = "none" - + get verbose() {return this._verbose} + set verbose(verbose) {this._verbose = verbose} + + private _timeout: number = 20 /** * The maximum **amount of seconds** requests should take before returning an answer (defaults to **20**) * @remarks 0 means no maximum, no timeout */ - timeout: number = 20 + get timeout() {return this._timeout} + set timeout(timeout) {this._timeout = timeout} - /** Configure how this instance should behave when it comes to automatically retrying a request */ - retry: { + private _retry: { /** If true, doesn't retry under any circumstances (defaults to **false**) */ disabled: boolean /** In seconds, how long should it wait until retrying? (defaults to **2**) */ @@ -201,6 +208,9 @@ export class API { on_timeout: false, on_status_codes: [429] } + /** Configure how this instance should behave when it comes to automatically retrying a request */ + get retry() {return this._retry} + set retry(retry) {this._retry = retry} /** @@ -249,9 +259,8 @@ export class API { await new_api.getAndSetToken({client_id: client.id, client_secret: client.secret, grant_type: "client_credentials", scope: "public"}, new_api) } - public with(overrides: RequestInit): ChildAPI { - const child_api = new ChildAPI(this, overrides) - return child_api + public with(overrides: ChildAPI["overrides"]): ChildAPI { + return new ChildAPI(this, overrides) } /** @@ -416,7 +425,7 @@ export class API { } timeout_controller.abort() }, this.timeout * 1000) : false - const signal = overrides?.signal ? anySignal([timeout_signal, overrides.signal]) : timeout_signal + const signal = overrides?.signal ? anySignal([timeout_signal, overrides.signal as AbortSignal]) : timeout_signal // For GET requests specifically, requests need to be shaped in very particular ways if (parameters !== undefined && method === "get") { @@ -439,6 +448,7 @@ export class API { "Authorization": `${this.token_type} ${this.access_token}` }, body: method !== "get" ? JSON.stringify(parameters) : undefined, // parameters are here if request is NOT GET + ...overrides, // has priority over what's above, but not over what's lower signal }) .catch((error: AbortError | FetchError) => { @@ -777,18 +787,53 @@ export class API { * Created with {@link API.with} */ export class ChildAPI extends API { + /** The {@link API} where {@link API.with} was used; this `ChildAPI` gets everything from it! */ original: API overrides: RequestInit - - get access_token(): string {return this.original.access_token} - get refresh_token(): string | undefined {return this.original.refresh_token} - get refresh_timeout(): NodeJS.Timeout | undefined {return this.original.refresh_timeout} - refreshToken = async () => {return await this.original.refreshToken()} request = async (...args: Parameters) => { args[3] ??= this.overrides return await this.original.request(...args) } + // Those are first in accessors -> methods order, then in alphabetical order + // For the sake of decent documentation and autocomplete + /** @hidden @deprecated use API equivalent */ + get access_token() {return this.original.access_token} + /** @hidden @deprecated use API equivalent */ + get client() {return this.original.client} + /** @hidden @deprecated use API equivalent */ + get expires() {return this.original.expires} + /** @hidden @deprecated use API equivalent */ + get refresh_on_401() {return this.original.refresh_on_401} + /** @hidden @deprecated use API equivalent */ + get refresh_on_expires() {return this.original.refresh_on_expires} + /** @hidden @deprecated use API equivalent */ + get refresh_timeout() {return this.original.refresh_timeout} + /** @hidden @deprecated use API equivalent */ + get refresh_token() {return this.original.refresh_token} + /** @hidden @deprecated use API equivalent */ + get retry() {return this.original.retry} + /** @hidden @deprecated use API equivalent */ + get scopes() {return this.original.scopes} + /** @hidden @deprecated use API equivalent */ + get server() {return this.original.server} + /** @hidden @deprecated use API equivalent */ + get timeout() {return this.original.timeout} + /** @hidden @deprecated use API equivalent */ + get token_type() {return this.original.token_type} + /** @hidden @deprecated use API equivalent */ + get user() {return this.original.user} + /** @hidden @deprecated use API equivalent */ + get verbose() {return this.original.verbose} + /** @hidden @deprecated use API equivalent */ + generateWebSocket = () => {return this.original.generateWebSocket()} + /** @hidden @deprecated use API equivalent */ + refreshToken = async () => {return await this.original.refreshToken()} + /** @hidden @deprecated use API equivalent */ + revokeToken = async () => {return await this.original.revokeToken()} + /** @hidden @deprecated use API equivalent */ + with = (...args: Parameters) => {return this.original.with(...args)} + constructor(original: ChildAPI["original"], overrides: ChildAPI["overrides"]) { super({}) From 7733b7068a77b980ee7c1ac5264abc44201c1af8 Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Sun, 21 Apr 2024 19:25:47 +0200 Subject: [PATCH 4/4] Clarify the purpose of `API.withSettings()` & `ChildAPI` Would've kept the name of `API.with()` but it turns out `with` is kinda reserved And it looks like using reserved words messes up documentation stuff https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/with --- lib/index.ts | 47 ++++++++++++++++++++----------- lib/tests/test.ts | 70 ++++++++++++++++++++--------------------------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index 2857f87..f50629a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -259,8 +259,18 @@ export class API { await new_api.getAndSetToken({client_id: client.id, client_secret: client.secret, grant_type: "client_credentials", scope: "public"}, new_api) } - public with(overrides: ChildAPI["overrides"]): ChildAPI { - return new ChildAPI(this, overrides) + /** + * You can use this to specify additional settings for the method you're going to call, such as `headers`, an `AbortSignal`, and more advanced things! + * @example + * ```ts + * const controller = new AbortController() // this controller can be used to abort any request that uses its signal! + * const user = await api.withSettings({signal: controller.signal}).getUser(7276846) + * ``` + * @param additional_fetch_settings You may get more info at https://www.npmjs.com/package/node-fetch#fetch-options + * @returns A special version of the `API` that changes how requests are done + */ + public withSettings(additional_fetch_settings: ChildAPI["additional_fetch_settings"]): ChildAPI { + return new ChildAPI(this, additional_fetch_settings) } /** @@ -407,11 +417,13 @@ export class API { * @param method The type of request, each endpoint uses a specific one (if it uses multiple, the intent and parameters become different) * @param endpoint What comes in the URL after `api/` * @param parameters The things to specify in the request, such as the beatmap_id when looking for a beatmap + * @param settings Additional settings **to add** to the current settings of the `fetch()` request * @param info Context given by a prior request * @returns A Promise with the API's response */ - public async request(method: "get" | "post" | "put" | "delete", endpoint: string, parameters: {[k: string]: any} = {}, overrides?: ChildAPI["overrides"], - info: {number_try: number, just_refreshed: boolean} = {number_try: 1, just_refreshed: false}): Promise { + public async request(method: "get" | "post" | "put" | "delete", endpoint: string, parameters: {[k: string]: any} = {}, + settings?: ChildAPI["additional_fetch_settings"],info: {number_try: number, just_refreshed: boolean} = {number_try: 1, just_refreshed: false}): + Promise { let to_retry = false let error_object: Error | undefined let error_code: number | undefined @@ -423,9 +435,9 @@ export class API { if (this.retry.on_timeout) { to_retry = true } - timeout_controller.abort() + timeout_controller.abort(`The request wasn't made in time (took more than ${this.timeout} seconds)`) }, this.timeout * 1000) : false - const signal = overrides?.signal ? anySignal([timeout_signal, overrides.signal as AbortSignal]) : timeout_signal + const signal = settings?.signal ? anySignal([timeout_signal, settings.signal as AbortSignal]) : timeout_signal // For GET requests specifically, requests need to be shaped in very particular ways if (parameters !== undefined && method === "get") { @@ -440,15 +452,16 @@ export class API { const response = await fetch(url, { method, + ...settings, // has priority over what's above, but not over what's lower headers: { "Accept": "application/json", "Accept-Encoding": "gzip", "Content-Type": "application/json", "User-Agent": "osu-api-v2-js (https://github.com/TTTaevas/osu-api-v2-js)", - "Authorization": `${this.token_type} ${this.access_token}` + "Authorization": `${this.token_type} ${this.access_token}`, + ...settings?.headers // written that way, custom headers with (for example) only a user-agent would only overwrite the default user-agent }, body: method !== "get" ? JSON.stringify(parameters) : undefined, // parameters are here if request is NOT GET - ...overrides, // has priority over what's above, but not over what's lower signal }) .catch((error: AbortError | FetchError) => { @@ -501,7 +514,7 @@ export class API { if (to_retry === true && this.retry.disabled === false && info.number_try < this.retry.maximum_amount) { this.log(true, `Will request again in ${this.retry.delay} seconds...`, `(Try #${info.number_try})`) await new Promise(res => setTimeout(res, this.retry.delay)) - return await this.request(method, endpoint, parameters, overrides, {number_try: info.number_try + 1, just_refreshed: info.just_refreshed}) + return await this.request(method, endpoint, parameters, settings, {number_try: info.number_try + 1, just_refreshed: info.just_refreshed}) } throw new APIError(error_string, `${this.server}/api/v2`, endpoint, parameters, error_code, error_object) @@ -784,14 +797,16 @@ export class API { } /** - * Created with {@link API.with} + * Created with {@link API.withSettings}, this special version of the {@link API} specifies additional settings to every request! + * @remarks This **is not** to be used for any purpose other than calling methods; The original {@link ChildAPI.original} handles tokens & configuration */ export class ChildAPI extends API { - /** The {@link API} where {@link API.with} was used; this `ChildAPI` gets everything from it! */ + /** The {@link API} where {@link API.withSettings} was used; this `ChildAPI` gets everything from it! */ original: API - overrides: RequestInit + /** The additional settings that are used for every request made by this object */ + additional_fetch_settings: Omit request = async (...args: Parameters) => { - args[3] ??= this.overrides + args[3] ??= this.additional_fetch_settings // args[3] is `settings` **for now** return await this.original.request(...args) } @@ -832,12 +847,12 @@ export class ChildAPI extends API { /** @hidden @deprecated use API equivalent */ revokeToken = async () => {return await this.original.revokeToken()} /** @hidden @deprecated use API equivalent */ - with = (...args: Parameters) => {return this.original.with(...args)} + withSettings = (...args: Parameters) => {return this.original.withSettings(...args)} - constructor(original: ChildAPI["original"], overrides: ChildAPI["overrides"]) { + constructor(original: ChildAPI["original"], additional_fetch_settings: ChildAPI["additional_fetch_settings"]) { super({}) this.original = original - this.overrides = overrides + this.additional_fetch_settings = additional_fetch_settings } } diff --git a/lib/tests/test.ts b/lib/tests/test.ts index e65a15b..493659c 100644 --- a/lib/tests/test.ts +++ b/lib/tests/test.ts @@ -341,46 +341,36 @@ const testOther = async (): Promise => { const test = async (id: string, secret: string): Promise => { api = await osu.API.createAsync({id: Number(id), secret}, undefined, "all") //"http://127.0.0.1:8080") - const controller = new AbortController() - const new_api = api.with({signal: controller.signal}) - console.log("reason:", controller.signal.reason) - setTimeout(() => controller.abort("manual abort"), 200) - await new_api.getUser(7276846) - .catch((e: osu.APIError) => console.log(e.original_error)) - console.log("reason:", controller.signal.reason) - - api.revokeToken() - - // const tests = [ - // testBeatmapPack, - // testBeatmap, - // testBeatmapsetDiscussion, - // testBeatmapset, - // testChangelog, - // testComment, - // testEvent, - // testForum, - // testHome, - // testMultiplayer, - // testNews, - // testRanking, - // testUser, - // testWiki, - // testOther, - // ] - - // const results: {test_name: string, passed: boolean}[] = [] - // for (let i = 0; i < tests.length; i++) { - // results.push({test_name: tests[i].name, passed: await tests[i]()}) - // } - // console.log("\n", ...results.map((r) => `${r.test_name}: ${r.passed ? "✔️" : "❌"}\n`)) - // await api.revokeToken() - - // if (!results.find((r) => !r.passed)) { - // console.log("✔️ Looks like the test went well!") - // } else { - // throw new Error("❌ Something in the test went wrong...") - // } + const tests = [ + testBeatmapPack, + testBeatmap, + testBeatmapsetDiscussion, + testBeatmapset, + testChangelog, + testComment, + testEvent, + testForum, + testHome, + testMultiplayer, + testNews, + testRanking, + testUser, + testWiki, + testOther, + ] + + const results: {test_name: string, passed: boolean}[] = [] + for (let i = 0; i < tests.length; i++) { + results.push({test_name: tests[i].name, passed: await tests[i]()}) + } + console.log("\n", ...results.map((r) => `${r.test_name}: ${r.passed ? "✔️" : "❌"}\n`)) + await api.revokeToken() + + if (!results.find((r) => !r.passed)) { + console.log("✔️ Looks like the test went well!") + } else { + throw new Error("❌ Something in the test went wrong...") + } } test(process.env.ID, process.env.SECRET)