umami-api-js 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +5 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +372 -0
- package/dist/utilities.d.ts +17 -0
- package/dist/utilities.js +82 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Lightweight alternative to [@umami/api-client](https://github.com/umami-software/api-client), forked from [osu-api-v2-js](https://github.com/TTTaevas/osu-api-v2-js)
|
|
2
|
+
|
|
3
|
+
Please note that this package expects self-hosted instances of Umami, it is not built to work with Umami Cloud
|
|
4
|
+
|
|
5
|
+
Please also note that this is currently being built mainly to serve as a component to my [taevas.xyz](https://codeberg.org/Taevas/taevas.xyz) project
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export interface ValueAndPrev {
|
|
2
|
+
/** The actual value for the given time period */
|
|
3
|
+
value: number;
|
|
4
|
+
/** The value for the same *timespan* for the time period that ends where the time period for value starts */
|
|
5
|
+
prev: number;
|
|
6
|
+
}
|
|
7
|
+
/** If the {@link API} throws an error, it should always be an {@link APIError}! */
|
|
8
|
+
export declare class APIError extends Error {
|
|
9
|
+
message: string;
|
|
10
|
+
server: API["server"];
|
|
11
|
+
method: Parameters<API["request"]>[0];
|
|
12
|
+
endpoint: Parameters<API["request"]>[1];
|
|
13
|
+
parameters: Parameters<API["request"]>[2];
|
|
14
|
+
status_code?: number | undefined;
|
|
15
|
+
original_error?: Error | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* @param message The reason why things didn't go as expected
|
|
18
|
+
* @param server The server to which the request was sent
|
|
19
|
+
* @param method The method used for this request (like "get", "post", etc...)
|
|
20
|
+
* @param endpoint The type of resource that was requested from the server
|
|
21
|
+
* @param parameters The filters that were used to specify what resource was wanted
|
|
22
|
+
* @param status_code The status code that was returned by the server, if there is one
|
|
23
|
+
* @param original_error The error that caused the api to throw an {@link APIError} in the first place, if there is one
|
|
24
|
+
*/
|
|
25
|
+
constructor(message: string, server: API["server"], method: Parameters<API["request"]>[0], endpoint: Parameters<API["request"]>[1], parameters: Parameters<API["request"]>[2], status_code?: number | undefined, original_error?: Error | undefined);
|
|
26
|
+
}
|
|
27
|
+
/** An API instance is needed to make requests to the server! */
|
|
28
|
+
export declare class API {
|
|
29
|
+
/** If you have account credentials, you might want to use this constructor */
|
|
30
|
+
constructor(username: API["username"], password: API["password"], settings?: Partial<API>);
|
|
31
|
+
/** If you are already in possession of an {@link API.token} and don't necessarily wish to be able to get a new one, you might want to use this constructor */
|
|
32
|
+
constructor(token: API["token"], settings?: Partial<API>);
|
|
33
|
+
private _token;
|
|
34
|
+
/** The key that allows you to talk with the API */
|
|
35
|
+
get token(): string;
|
|
36
|
+
set token(token: string);
|
|
37
|
+
private _token_type;
|
|
38
|
+
/** Should always be "Bearer" */
|
|
39
|
+
get token_type(): string;
|
|
40
|
+
set token_type(token: string);
|
|
41
|
+
private _expires;
|
|
42
|
+
/** The expiration date of your token */
|
|
43
|
+
get expires(): Date;
|
|
44
|
+
set expires(date: Date);
|
|
45
|
+
private _username;
|
|
46
|
+
/** The username of the account */
|
|
47
|
+
get username(): string;
|
|
48
|
+
set username(username: string);
|
|
49
|
+
private _password;
|
|
50
|
+
/** The password of the account */
|
|
51
|
+
get password(): string;
|
|
52
|
+
set password(password: string);
|
|
53
|
+
private _server;
|
|
54
|
+
/** The base URL where requests should land, **should include the `/api` portion if applicable** */
|
|
55
|
+
get server(): string;
|
|
56
|
+
set server(server: string);
|
|
57
|
+
private _headers;
|
|
58
|
+
/** Used in practically all requests, those are all the headers the package uses excluding `Authorization`, the one with the token */
|
|
59
|
+
get headers(): {
|
|
60
|
+
[key: string]: any;
|
|
61
|
+
};
|
|
62
|
+
set headers(headers: {
|
|
63
|
+
[key: string]: any;
|
|
64
|
+
});
|
|
65
|
+
private _user;
|
|
66
|
+
/** Information about the account that has been used to log in */
|
|
67
|
+
get user(): {
|
|
68
|
+
id: string;
|
|
69
|
+
username: string;
|
|
70
|
+
role: string;
|
|
71
|
+
createdAt: Date;
|
|
72
|
+
isAdmin: boolean;
|
|
73
|
+
};
|
|
74
|
+
set user(user: {
|
|
75
|
+
id: string;
|
|
76
|
+
username: string;
|
|
77
|
+
role: string;
|
|
78
|
+
createdAt: Date;
|
|
79
|
+
isAdmin: boolean;
|
|
80
|
+
});
|
|
81
|
+
private number_of_requests;
|
|
82
|
+
/** Has {@link API.setNewToken} been called and not yet returned anything? */
|
|
83
|
+
private is_setting_token;
|
|
84
|
+
/** If {@link API.setNewToken} has been called, you can wait for it to be done through this promise */
|
|
85
|
+
private token_promise;
|
|
86
|
+
/**
|
|
87
|
+
* This creates a new {@link API.token}, alongside a new {@link API.refresh_token} if arguments are provided or if a refresh_token already exists
|
|
88
|
+
* @remarks The API object requires a {@link API.username} and a {@link API.password} to successfully get any token
|
|
89
|
+
* @returns Whether or not the token has changed (this should never throw)
|
|
90
|
+
*/
|
|
91
|
+
setNewToken(): Promise<boolean>;
|
|
92
|
+
private _set_token_on_401;
|
|
93
|
+
/** If true, upon failing a request due to a 401, it will call {@link API.setNewToken} (defaults to **true**) */
|
|
94
|
+
get set_token_on_401(): boolean;
|
|
95
|
+
set set_token_on_401(bool: boolean);
|
|
96
|
+
private _set_token_on_expires;
|
|
97
|
+
/**
|
|
98
|
+
* If true, the application will silently call {@link API.setNewToken} when the {@link API.token} is set to expire,
|
|
99
|
+
* as determined by {@link API.expires} (defaults to **false**)
|
|
100
|
+
*/
|
|
101
|
+
get set_token_on_expires(): boolean;
|
|
102
|
+
set set_token_on_expires(enabled: boolean);
|
|
103
|
+
private _token_timer?;
|
|
104
|
+
get token_timer(): API["_token_timer"];
|
|
105
|
+
set token_timer(timer: NodeJS.Timeout);
|
|
106
|
+
/** Add, remove, change the timeout used for setting a new token automatically whenever certain properties change */
|
|
107
|
+
private updateTokenTimer;
|
|
108
|
+
private _verbose?;
|
|
109
|
+
/** Which events should be logged (defaults to **none**) */
|
|
110
|
+
get verbose(): "none" | "errors" | "all" | undefined;
|
|
111
|
+
set verbose(verbose: "none" | "errors" | "all" | undefined);
|
|
112
|
+
private _timeout;
|
|
113
|
+
/**
|
|
114
|
+
* The maximum **amount of seconds** requests should take before returning an answer (defaults to **20**)
|
|
115
|
+
* @remarks 0 means no maximum, no timeout
|
|
116
|
+
*/
|
|
117
|
+
get timeout(): number;
|
|
118
|
+
set timeout(timeout: number);
|
|
119
|
+
private _signal?;
|
|
120
|
+
/** The `AbortSignal` used in every request */
|
|
121
|
+
get signal(): AbortSignal | undefined;
|
|
122
|
+
set signal(signal: AbortSignal | undefined);
|
|
123
|
+
private _retry_maximum_amount;
|
|
124
|
+
/**
|
|
125
|
+
* How many retries maximum before throwing an {@link APIError} (defaults to **4**)
|
|
126
|
+
* @remarks Pro tip: Set that to 0 to **completely** disable retries!
|
|
127
|
+
*/
|
|
128
|
+
get retry_maximum_amount(): number;
|
|
129
|
+
set retry_maximum_amount(retry_maximum_amount: number);
|
|
130
|
+
private _retry_delay;
|
|
131
|
+
/** In seconds, how long should it wait after a request failed before retrying? (defaults to **2**) */
|
|
132
|
+
get retry_delay(): number;
|
|
133
|
+
set retry_delay(retry_delay: number);
|
|
134
|
+
private _retry_on_new_token;
|
|
135
|
+
/** Should it retry a request upon successfully setting a new token due to {@link API.set_token_on_401} being `true`? (defaults to **true**) */
|
|
136
|
+
get retry_on_new_token(): boolean;
|
|
137
|
+
set retry_on_new_token(retry_on_new_token: boolean);
|
|
138
|
+
private _retry_on_status_codes;
|
|
139
|
+
/** Upon failing a request and receiving a response, because of which received status code should the request be retried? (defaults to **[429]**) */
|
|
140
|
+
get retry_on_status_codes(): number[];
|
|
141
|
+
set retry_on_status_codes(retry_on_status_codes: number[]);
|
|
142
|
+
private _retry_on_timeout;
|
|
143
|
+
/** Should it retry a request if that request failed because it has been aborted by the {@link API.timeout}? (defaults to **false**) */
|
|
144
|
+
get retry_on_timeout(): boolean;
|
|
145
|
+
set retry_on_timeout(retry_on_timeout: boolean);
|
|
146
|
+
/**
|
|
147
|
+
* Use this instead of `console.log` to log any information
|
|
148
|
+
* @param is_error Is the logging happening because of an error?
|
|
149
|
+
* @param to_log Whatever you would put between the parentheses of `console.log()`
|
|
150
|
+
*/
|
|
151
|
+
private log;
|
|
152
|
+
/**
|
|
153
|
+
* Use this instead of a straight `fetch` to connect to the server
|
|
154
|
+
* @param is_token_related Is the request related to getting a token? If so, it bypasses `token_promise`
|
|
155
|
+
* @param method The HTTP method https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods
|
|
156
|
+
* @param endpoint The endpoint, may or may not be listed on https://umami.is/docs/api
|
|
157
|
+
* @param parameters GET query in object form, or the request body if the method is not GET
|
|
158
|
+
* @param info Relevant only when `fetch` calls itself, avoid setting it
|
|
159
|
+
* @remarks Consider using the higher-level method called {@link API.request}
|
|
160
|
+
*/
|
|
161
|
+
private fetch;
|
|
162
|
+
/**
|
|
163
|
+
* The function that directly communicates with the API! Almost every functions of the API object uses this function!
|
|
164
|
+
* @param method The type of request, each endpoint uses a specific one (if it uses multiple, the intent and parameters become different)
|
|
165
|
+
* @param endpoint What comes in the URL after `api/`, **DO NOT USE TEMPLATE LITERALS (\`) OR THE ADDITION OPERATOR (+), put everything separately for type safety**
|
|
166
|
+
* @param parameters The things to specify in the request, such as the beatmap_id when looking for a beatmap
|
|
167
|
+
* @param settings Additional settings **to add** to the current settings of the `fetch()` request
|
|
168
|
+
* @returns A Promise with the API's response
|
|
169
|
+
*/
|
|
170
|
+
request(method: "get" | "post" | "put" | "delete", endpoint: Array<string | number>, parameters?: {
|
|
171
|
+
[k: string]: any;
|
|
172
|
+
}): Promise<any>;
|
|
173
|
+
getWebsiteStats(website_id: string, startAt: Date, endAt: Date): Promise<{
|
|
174
|
+
pageviews: ValueAndPrev;
|
|
175
|
+
visitors: ValueAndPrev;
|
|
176
|
+
visits: ValueAndPrev;
|
|
177
|
+
bounces: ValueAndPrev;
|
|
178
|
+
totaltime: ValueAndPrev;
|
|
179
|
+
}>;
|
|
180
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { adaptParametersForGETRequests, correctType } from "./utilities.js";
|
|
2
|
+
/** If the {@link API} throws an error, it should always be an {@link APIError}! */
|
|
3
|
+
export class APIError extends Error {
|
|
4
|
+
message;
|
|
5
|
+
server;
|
|
6
|
+
method;
|
|
7
|
+
endpoint;
|
|
8
|
+
parameters;
|
|
9
|
+
status_code;
|
|
10
|
+
original_error;
|
|
11
|
+
/**
|
|
12
|
+
* @param message The reason why things didn't go as expected
|
|
13
|
+
* @param server The server to which the request was sent
|
|
14
|
+
* @param method The method used for this request (like "get", "post", etc...)
|
|
15
|
+
* @param endpoint The type of resource that was requested from the server
|
|
16
|
+
* @param parameters The filters that were used to specify what resource was wanted
|
|
17
|
+
* @param status_code The status code that was returned by the server, if there is one
|
|
18
|
+
* @param original_error The error that caused the api to throw an {@link APIError} in the first place, if there is one
|
|
19
|
+
*/
|
|
20
|
+
constructor(message, server, method, endpoint, parameters, status_code, original_error) {
|
|
21
|
+
super();
|
|
22
|
+
this.message = message;
|
|
23
|
+
this.server = server;
|
|
24
|
+
this.method = method;
|
|
25
|
+
this.endpoint = endpoint;
|
|
26
|
+
this.parameters = parameters;
|
|
27
|
+
this.status_code = status_code;
|
|
28
|
+
this.original_error = original_error;
|
|
29
|
+
if (this.parameters?.password) {
|
|
30
|
+
this.parameters.password = "<REDACTED>";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** An API instance is needed to make requests to the server! */
|
|
35
|
+
export class API {
|
|
36
|
+
constructor(username_or_token, password_or_settings, settings) {
|
|
37
|
+
settings ??= typeof password_or_settings !== "string" ? password_or_settings : undefined;
|
|
38
|
+
if (settings) {
|
|
39
|
+
/** Delete every property that is `undefined` so the class defaults aren't overwritten by `undefined` */
|
|
40
|
+
Object.keys(settings).forEach((key) => {
|
|
41
|
+
settings[key] === undefined ? delete settings[key] : {};
|
|
42
|
+
});
|
|
43
|
+
Object.assign(this, settings);
|
|
44
|
+
}
|
|
45
|
+
/** We want to set a new token instantly if account credentials have been provided */
|
|
46
|
+
if (typeof username_or_token === "string" && typeof password_or_settings === "string") {
|
|
47
|
+
this.username = username_or_token;
|
|
48
|
+
this.password = password_or_settings;
|
|
49
|
+
this.setNewToken();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
_token = "";
|
|
53
|
+
/** The key that allows you to talk with the API */
|
|
54
|
+
get token() { return this._token; }
|
|
55
|
+
set token(token) { this._token = token; }
|
|
56
|
+
_token_type = "Bearer";
|
|
57
|
+
/** Should always be "Bearer" */
|
|
58
|
+
get token_type() { return this._token_type; }
|
|
59
|
+
set token_type(token) { this._token_type = token; }
|
|
60
|
+
_expires = new Date(new Date().getTime() + 24 * 60 * 60 * 1000); // 24 hours default, is set through getAndSetToken anyway
|
|
61
|
+
/** The expiration date of your token */
|
|
62
|
+
get expires() { return this._expires; }
|
|
63
|
+
set expires(date) {
|
|
64
|
+
this._expires = date;
|
|
65
|
+
this.updateTokenTimer();
|
|
66
|
+
}
|
|
67
|
+
// CLIENT INFO
|
|
68
|
+
_username = "";
|
|
69
|
+
/** The username of the account */
|
|
70
|
+
get username() { return this._username; }
|
|
71
|
+
set username(username) { this._username = username; }
|
|
72
|
+
_password = "";
|
|
73
|
+
/** The password of the account */
|
|
74
|
+
get password() { return this._password; }
|
|
75
|
+
set password(password) { this._password = password; }
|
|
76
|
+
_server = "";
|
|
77
|
+
/** The base URL where requests should land, **should include the `/api` portion if applicable** */
|
|
78
|
+
get server() { return this._server; }
|
|
79
|
+
set server(server) { this._server = server; }
|
|
80
|
+
_headers = {
|
|
81
|
+
"Accept": "application/json",
|
|
82
|
+
"Accept-Encoding": "gzip",
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"User-Agent": "umami-api-js (https://codeberg.org/Taevas/umami-api-js)",
|
|
85
|
+
};
|
|
86
|
+
/** Used in practically all requests, those are all the headers the package uses excluding `Authorization`, the one with the token */
|
|
87
|
+
get headers() { return this._headers; }
|
|
88
|
+
set headers(headers) { this._headers = headers; }
|
|
89
|
+
_user = {
|
|
90
|
+
id: "",
|
|
91
|
+
username: "",
|
|
92
|
+
role: "",
|
|
93
|
+
createdAt: new Date(),
|
|
94
|
+
isAdmin: false,
|
|
95
|
+
};
|
|
96
|
+
/** Information about the account that has been used to log in */
|
|
97
|
+
get user() { return this._user; }
|
|
98
|
+
set user(user) { this._user = user; }
|
|
99
|
+
number_of_requests = 0;
|
|
100
|
+
// TOKEN HANDLING
|
|
101
|
+
/** Has {@link API.setNewToken} been called and not yet returned anything? */
|
|
102
|
+
is_setting_token = false;
|
|
103
|
+
/** If {@link API.setNewToken} has been called, you can wait for it to be done through this promise */
|
|
104
|
+
token_promise = new Promise(r => r);
|
|
105
|
+
/**
|
|
106
|
+
* This creates a new {@link API.token}, alongside a new {@link API.refresh_token} if arguments are provided or if a refresh_token already exists
|
|
107
|
+
* @remarks The API object requires a {@link API.username} and a {@link API.password} to successfully get any token
|
|
108
|
+
* @returns Whether or not the token has changed (this should never throw)
|
|
109
|
+
*/
|
|
110
|
+
async setNewToken() {
|
|
111
|
+
const old_token = this.token;
|
|
112
|
+
this.is_setting_token = true;
|
|
113
|
+
const body = {
|
|
114
|
+
username: this.username,
|
|
115
|
+
password: this.password,
|
|
116
|
+
};
|
|
117
|
+
this.token_promise = new Promise((resolve, reject) => {
|
|
118
|
+
this.fetch(true, "post", ["auth", "login"], body)
|
|
119
|
+
.then((response) => {
|
|
120
|
+
response.json()
|
|
121
|
+
.then((json) => {
|
|
122
|
+
if (!json.token) {
|
|
123
|
+
const error_message = json.error_description ?? json.message ?? "No token obtained"; // Expect "Client authentication failed"
|
|
124
|
+
this.log(true, "Unable to obtain a token! Here's what was received from the API:", json);
|
|
125
|
+
reject(new APIError(error_message, this.server, "post", ["auth", "login"], body, response.status));
|
|
126
|
+
}
|
|
127
|
+
this.token_type = json.token_type;
|
|
128
|
+
this.token = json.token;
|
|
129
|
+
const expiration_date = new Date();
|
|
130
|
+
expiration_date.setDate(expiration_date.getDate() + 1); // Assume 24 hours
|
|
131
|
+
this.expires = expiration_date;
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
})
|
|
135
|
+
.catch(reject); // reject the promise with the received error instead of throwing (is it even useful?)
|
|
136
|
+
});
|
|
137
|
+
await this.token_promise;
|
|
138
|
+
this.is_setting_token = false;
|
|
139
|
+
if (old_token !== this.token)
|
|
140
|
+
this.log(false, "A new token has been set!");
|
|
141
|
+
return old_token !== this.token;
|
|
142
|
+
}
|
|
143
|
+
_set_token_on_401 = true;
|
|
144
|
+
/** If true, upon failing a request due to a 401, it will call {@link API.setNewToken} (defaults to **true**) */
|
|
145
|
+
get set_token_on_401() { return this._set_token_on_401; }
|
|
146
|
+
set set_token_on_401(bool) { this._set_token_on_401 = bool; }
|
|
147
|
+
_set_token_on_expires = false;
|
|
148
|
+
/**
|
|
149
|
+
* If true, the application will silently call {@link API.setNewToken} when the {@link API.token} is set to expire,
|
|
150
|
+
* as determined by {@link API.expires} (defaults to **false**)
|
|
151
|
+
*/
|
|
152
|
+
get set_token_on_expires() { return this._set_token_on_expires; }
|
|
153
|
+
set set_token_on_expires(enabled) {
|
|
154
|
+
this._set_token_on_expires = enabled;
|
|
155
|
+
this.updateTokenTimer();
|
|
156
|
+
}
|
|
157
|
+
_token_timer;
|
|
158
|
+
get token_timer() { return this._token_timer; }
|
|
159
|
+
set token_timer(timer) {
|
|
160
|
+
// if a previous one already exists, clear it
|
|
161
|
+
if (this._token_timer) {
|
|
162
|
+
clearTimeout(this._token_timer);
|
|
163
|
+
}
|
|
164
|
+
this._token_timer = timer;
|
|
165
|
+
this._token_timer.unref(); // don't prevent exiting the program while this timeout is going on
|
|
166
|
+
}
|
|
167
|
+
/** Add, remove, change the timeout used for setting a new token automatically whenever certain properties change */
|
|
168
|
+
updateTokenTimer() {
|
|
169
|
+
if (this.expires && this.set_token_on_expires) {
|
|
170
|
+
const now = new Date();
|
|
171
|
+
const ms = this.expires.getTime() - now.getTime();
|
|
172
|
+
/**
|
|
173
|
+
* Let's say that we used a refresh token *after* the expiration time, our refresh token would naturally get updated
|
|
174
|
+
* However, if it is updated before the (local) expiration date is updated, then ms should be 0
|
|
175
|
+
* This should mean that, upon using a refresh token, we would use our new refresh token instantly...
|
|
176
|
+
* In other words, don't allow timeouts that would mean no timeout; {@link API.setNewToken} exists for that
|
|
177
|
+
*/
|
|
178
|
+
if (ms <= 0) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
this.token_timer = setTimeout(() => {
|
|
182
|
+
try {
|
|
183
|
+
this.setNewToken();
|
|
184
|
+
}
|
|
185
|
+
catch { }
|
|
186
|
+
}, ms);
|
|
187
|
+
}
|
|
188
|
+
else if (this._token_timer) {
|
|
189
|
+
clearTimeout(this._token_timer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// CLIENT CONFIGURATION
|
|
193
|
+
_verbose = "none";
|
|
194
|
+
/** Which events should be logged (defaults to **none**) */
|
|
195
|
+
get verbose() { return this._verbose; }
|
|
196
|
+
set verbose(verbose) { this._verbose = verbose; }
|
|
197
|
+
_timeout = 20;
|
|
198
|
+
/**
|
|
199
|
+
* The maximum **amount of seconds** requests should take before returning an answer (defaults to **20**)
|
|
200
|
+
* @remarks 0 means no maximum, no timeout
|
|
201
|
+
*/
|
|
202
|
+
get timeout() { return this._timeout; }
|
|
203
|
+
set timeout(timeout) { this._timeout = timeout; }
|
|
204
|
+
_signal;
|
|
205
|
+
/** The `AbortSignal` used in every request */
|
|
206
|
+
get signal() { return this._signal; }
|
|
207
|
+
set signal(signal) { this._signal = signal; }
|
|
208
|
+
// RETRIES
|
|
209
|
+
_retry_maximum_amount = 4;
|
|
210
|
+
/**
|
|
211
|
+
* How many retries maximum before throwing an {@link APIError} (defaults to **4**)
|
|
212
|
+
* @remarks Pro tip: Set that to 0 to **completely** disable retries!
|
|
213
|
+
*/
|
|
214
|
+
get retry_maximum_amount() { return this._retry_maximum_amount; }
|
|
215
|
+
set retry_maximum_amount(retry_maximum_amount) { this._retry_maximum_amount = retry_maximum_amount; }
|
|
216
|
+
_retry_delay = 2;
|
|
217
|
+
/** In seconds, how long should it wait after a request failed before retrying? (defaults to **2**) */
|
|
218
|
+
get retry_delay() { return this._retry_delay; }
|
|
219
|
+
set retry_delay(retry_delay) { this._retry_delay = retry_delay; }
|
|
220
|
+
_retry_on_new_token = true;
|
|
221
|
+
/** Should it retry a request upon successfully setting a new token due to {@link API.set_token_on_401} being `true`? (defaults to **true**) */
|
|
222
|
+
get retry_on_new_token() { return this._retry_on_new_token; }
|
|
223
|
+
set retry_on_new_token(retry_on_new_token) { this._retry_on_new_token = retry_on_new_token; }
|
|
224
|
+
_retry_on_status_codes = [429];
|
|
225
|
+
/** Upon failing a request and receiving a response, because of which received status code should the request be retried? (defaults to **[429]**) */
|
|
226
|
+
get retry_on_status_codes() { return this._retry_on_status_codes; }
|
|
227
|
+
set retry_on_status_codes(retry_on_status_codes) { this._retry_on_status_codes = retry_on_status_codes; }
|
|
228
|
+
_retry_on_timeout = false;
|
|
229
|
+
/** Should it retry a request if that request failed because it has been aborted by the {@link API.timeout}? (defaults to **false**) */
|
|
230
|
+
get retry_on_timeout() { return this._retry_on_timeout; }
|
|
231
|
+
set retry_on_timeout(retry_on_timeout) { this._retry_on_timeout = retry_on_timeout; }
|
|
232
|
+
// OTHER METHODS
|
|
233
|
+
/**
|
|
234
|
+
* Use this instead of `console.log` to log any information
|
|
235
|
+
* @param is_error Is the logging happening because of an error?
|
|
236
|
+
* @param to_log Whatever you would put between the parentheses of `console.log()`
|
|
237
|
+
*/
|
|
238
|
+
log(is_error, ...to_log) {
|
|
239
|
+
if (this.verbose !== "none" && is_error === true) {
|
|
240
|
+
console.error("umami api ->", ...to_log);
|
|
241
|
+
}
|
|
242
|
+
else if (this.verbose === "all") {
|
|
243
|
+
console.log("umami api ->", ...to_log);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Use this instead of a straight `fetch` to connect to the server
|
|
248
|
+
* @param is_token_related Is the request related to getting a token? If so, it bypasses `token_promise`
|
|
249
|
+
* @param method The HTTP method https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods
|
|
250
|
+
* @param endpoint The endpoint, may or may not be listed on https://umami.is/docs/api
|
|
251
|
+
* @param parameters GET query in object form, or the request body if the method is not GET
|
|
252
|
+
* @param info Relevant only when `fetch` calls itself, avoid setting it
|
|
253
|
+
* @remarks Consider using the higher-level method called {@link API.request}
|
|
254
|
+
*/
|
|
255
|
+
async fetch(is_token_related, method, endpoint, parameters = {}, info = { number_try: 1, has_new_token: false }) {
|
|
256
|
+
let to_retry = false;
|
|
257
|
+
let error_object;
|
|
258
|
+
let error_code;
|
|
259
|
+
let error_message = "no error message available";
|
|
260
|
+
if (!is_token_related)
|
|
261
|
+
await this.token_promise.catch(() => this.token_promise = new Promise(r => r));
|
|
262
|
+
let url = `${this.server}`;
|
|
263
|
+
if (url.slice(-1) !== "/")
|
|
264
|
+
url += "/";
|
|
265
|
+
url += endpoint.join("/");
|
|
266
|
+
if (method === "get" && parameters) { // for GET requests specifically, requests need to be shaped in very particular ways
|
|
267
|
+
url += "?" + (Object.entries(adaptParametersForGETRequests(parameters)).map((param) => {
|
|
268
|
+
if (!Array.isArray(param[1])) {
|
|
269
|
+
return `${param[0]}=${param[1]}`;
|
|
270
|
+
}
|
|
271
|
+
return param[1].map((array_element) => `${param[0]}=${array_element}`).join("&");
|
|
272
|
+
}).join("&"));
|
|
273
|
+
}
|
|
274
|
+
const signals = [];
|
|
275
|
+
if (this.timeout > 0)
|
|
276
|
+
signals.push(AbortSignal.timeout(this.timeout * 1000));
|
|
277
|
+
if (this.signal && !is_token_related)
|
|
278
|
+
signals.push(this.signal);
|
|
279
|
+
const response = await fetch(url, {
|
|
280
|
+
method,
|
|
281
|
+
headers: {
|
|
282
|
+
"Authorization": is_token_related ? undefined : `${this.token_type} ${this.token}`,
|
|
283
|
+
...this.headers
|
|
284
|
+
},
|
|
285
|
+
body: method !== "get" ? JSON.stringify(parameters) : undefined, // parameters are here if method is NOT GET
|
|
286
|
+
signal: AbortSignal.any(signals)
|
|
287
|
+
})
|
|
288
|
+
.catch((error) => {
|
|
289
|
+
if (error.name === "TimeoutError" && this.retry_on_timeout)
|
|
290
|
+
to_retry = true;
|
|
291
|
+
this.log(true, error.message);
|
|
292
|
+
error_object = error;
|
|
293
|
+
error_message = `${error.name} (${error.message ?? error.errno ?? error.type})`;
|
|
294
|
+
})
|
|
295
|
+
.finally(() => this.number_of_requests += 1);
|
|
296
|
+
const request_id = `(${String(this.number_of_requests).padStart(8, "0")})`;
|
|
297
|
+
if (response) {
|
|
298
|
+
if (parameters.password)
|
|
299
|
+
parameters.password = "<REDACTED>";
|
|
300
|
+
this.log(this.verbose !== "none" && !response.ok, response.statusText, response.status, { method, endpoint, parameters }, request_id);
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
error_code = response.status;
|
|
303
|
+
error_message = response.statusText;
|
|
304
|
+
if (this.retry_on_status_codes.includes(response.status))
|
|
305
|
+
to_retry = true;
|
|
306
|
+
if (!is_token_related) {
|
|
307
|
+
if (response.status === 401) {
|
|
308
|
+
if (this.set_token_on_401 && !info.has_new_token) {
|
|
309
|
+
if (!this.is_setting_token) {
|
|
310
|
+
this.log(true, "Your token might have expired, I will attempt to get a new token...", request_id);
|
|
311
|
+
if (await this.setNewToken() && this.retry_on_new_token) {
|
|
312
|
+
to_retry = true;
|
|
313
|
+
info.has_new_token = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
this.log(true, "A new token is currently being obtained!", request_id);
|
|
318
|
+
if (this.retry_on_new_token) {
|
|
319
|
+
to_retry = true;
|
|
320
|
+
info.has_new_token = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (to_retry === true && info.number_try <= this.retry_maximum_amount) {
|
|
329
|
+
this.log(true, `Will request again in ${this.retry_delay} seconds...`, `(retry #${info.number_try}/${this.retry_maximum_amount})`, request_id);
|
|
330
|
+
await new Promise(res => setTimeout(res, this.retry_delay * 1000));
|
|
331
|
+
return await this.fetch(is_token_related, method, endpoint, parameters, { number_try: info.number_try + 1, has_new_token: info.has_new_token });
|
|
332
|
+
}
|
|
333
|
+
if (!response || !response.ok) {
|
|
334
|
+
throw new APIError(error_message, this.server, method, endpoint, parameters, error_code, error_object);
|
|
335
|
+
}
|
|
336
|
+
return response;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The function that directly communicates with the API! Almost every functions of the API object uses this function!
|
|
340
|
+
* @param method The type of request, each endpoint uses a specific one (if it uses multiple, the intent and parameters become different)
|
|
341
|
+
* @param endpoint What comes in the URL after `api/`, **DO NOT USE TEMPLATE LITERALS (\`) OR THE ADDITION OPERATOR (+), put everything separately for type safety**
|
|
342
|
+
* @param parameters The things to specify in the request, such as the beatmap_id when looking for a beatmap
|
|
343
|
+
* @param settings Additional settings **to add** to the current settings of the `fetch()` request
|
|
344
|
+
* @returns A Promise with the API's response
|
|
345
|
+
*/
|
|
346
|
+
async request(method, endpoint, parameters = {}) {
|
|
347
|
+
try {
|
|
348
|
+
const response = await this.fetch(false, method, endpoint, parameters);
|
|
349
|
+
if (response.status === 204)
|
|
350
|
+
return undefined; // 204 means the request worked as intended and did not give us anything, so just return nothing
|
|
351
|
+
const arrBuff = await response.arrayBuffer();
|
|
352
|
+
const buff = Buffer.from(arrBuff);
|
|
353
|
+
try { // Assume the response is in JSON format as it often is, it'll fail into the catch block if it isn't anyway
|
|
354
|
+
// My thorough testing leads me to believe nothing would change if the encoding was also "binary" here btw
|
|
355
|
+
return correctType(JSON.parse(buff.toString("utf-8")));
|
|
356
|
+
}
|
|
357
|
+
catch { // Assume the response is supposed to not be in JSON format so return it as simple text
|
|
358
|
+
return buff.toString("binary");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
if (e instanceof APIError)
|
|
363
|
+
throw e;
|
|
364
|
+
// Manual testing leads me to believe a TimeoutError is possible after the request ended (while arrayBuffer() is going on, presumably)
|
|
365
|
+
// Still no matter the Error, we want an APIError
|
|
366
|
+
throw new APIError(`${e?.name} (${e?.message ?? e?.errno ?? e?.type})`, this.server, method, endpoint, parameters, undefined, e);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async getWebsiteStats(website_id, startAt, endAt) {
|
|
370
|
+
return await this.request("get", ["websites", website_id, "stats"], { startAt: Number(startAt), endAt: Number(endAt) });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 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!
|
|
3
|
+
* @param parameters The parameters as they'd be for a POST request (prior to using `JSON.stringify`)
|
|
4
|
+
* @returns Parameters adapted for a GET request
|
|
5
|
+
*/
|
|
6
|
+
export declare function adaptParametersForGETRequests(parameters: {
|
|
7
|
+
[k: string]: any;
|
|
8
|
+
}): {
|
|
9
|
+
[k: string]: any;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Some stuff doesn't have the right type to begin with, such as dates, which are being returned as strings, this fixes that
|
|
13
|
+
* @param x Anything, but should be a string, an array that contains a string, or an object which has a string
|
|
14
|
+
* @param force_string Should `x` be as much as a string as it can? (defaults to false)
|
|
15
|
+
* @returns x, but with it (or what it contains) now having the correct type
|
|
16
|
+
*/
|
|
17
|
+
export declare function correctType(x: any, force_string?: boolean): any;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// This file hosts important functions that are too big or unreadable to belong in another file
|
|
2
|
+
/**
|
|
3
|
+
* 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!
|
|
4
|
+
* @param parameters The parameters as they'd be for a POST request (prior to using `JSON.stringify`)
|
|
5
|
+
* @returns Parameters adapted for a GET request
|
|
6
|
+
*/
|
|
7
|
+
export function adaptParametersForGETRequests(parameters) {
|
|
8
|
+
// If a parameter is an empty string or is undefined, remove it
|
|
9
|
+
Object.entries(parameters).forEach(([key, value]) => {
|
|
10
|
+
if (!String(value).length || value === undefined) {
|
|
11
|
+
delete parameters[key];
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
// If a parameter is an Array, add "[]" to its name, so the server understands the request properly
|
|
15
|
+
Object.entries(parameters).forEach(([key, value]) => {
|
|
16
|
+
if (Array.isArray(value) && !key.includes("[]")) {
|
|
17
|
+
parameters[`${key}[]`] = value;
|
|
18
|
+
delete parameters[key];
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
// If a parameter is an object, add its properties in "[]" such as "cursor[id]=5&cursor[score]=36.234"
|
|
22
|
+
const parameters_to_add = {};
|
|
23
|
+
Object.entries(parameters).forEach(([key, value]) => {
|
|
24
|
+
if (typeof value === "object" && !Array.isArray(value) && value !== null) {
|
|
25
|
+
Object.entries(value).forEach(([key2, value2]) => {
|
|
26
|
+
parameters_to_add[`${key}[${key2}]`] = value2;
|
|
27
|
+
});
|
|
28
|
+
delete parameters[key];
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
Object.entries(parameters_to_add).forEach(([key, value]) => {
|
|
32
|
+
parameters[key] = value;
|
|
33
|
+
});
|
|
34
|
+
// If a parameter is a date, make it a string
|
|
35
|
+
Object.entries(parameters).forEach(([key, value]) => {
|
|
36
|
+
if (value instanceof Date) {
|
|
37
|
+
parameters[key] = value.toISOString();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return parameters;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Some stuff doesn't have the right type to begin with, such as dates, which are being returned as strings, this fixes that
|
|
44
|
+
* @param x Anything, but should be a string, an array that contains a string, or an object which has a string
|
|
45
|
+
* @param force_string Should `x` be as much as a string as it can? (defaults to false)
|
|
46
|
+
* @returns x, but with it (or what it contains) now having the correct type
|
|
47
|
+
*/
|
|
48
|
+
export function correctType(x, force_string) {
|
|
49
|
+
// Apply this very function to all elements of the array, with `force_string` on if previously turned on
|
|
50
|
+
if (Array.isArray(x)) {
|
|
51
|
+
return x.map((e) => correctType(e, force_string));
|
|
52
|
+
}
|
|
53
|
+
// Objects have depth, we need to run this very function on all of their values
|
|
54
|
+
if (typeof x === "object" && x !== null) {
|
|
55
|
+
const keys = Object.keys(x);
|
|
56
|
+
const vals = Object.values(x);
|
|
57
|
+
// If a key is any of those, the value is expected to be a string, so we use `force_string` to make correctType convert them to string for us
|
|
58
|
+
const unconvertables = [];
|
|
59
|
+
for (let i = 0; i < keys.length; i++) {
|
|
60
|
+
x[keys[i]] = correctType(vals[i], unconvertables.some((p) => keys[i] === p));
|
|
61
|
+
}
|
|
62
|
+
return x;
|
|
63
|
+
}
|
|
64
|
+
// If `force_string` is on and is applicable, convert to a string (even if already string)
|
|
65
|
+
if (force_string && typeof x !== "object") {
|
|
66
|
+
return String(x);
|
|
67
|
+
}
|
|
68
|
+
// Responses by the API have their dates as strings in different formats, convert those strings to Date objects
|
|
69
|
+
if (/^[+-[0-9][0-9]+-[0-9]{2}-[0-9]{2}($|[ T].*)/.test(x)) {
|
|
70
|
+
// Before converting, force all dates into UTC+0 (to avoid differences depending of which timezone you run `new Date()` in)
|
|
71
|
+
if (/[0-9]{2}:[0-9]{2}:[0-9]{2}$/.test(x))
|
|
72
|
+
x += "Z";
|
|
73
|
+
if (/[0-9]{2}:[0-9]{2}:[0-9]{2}\+[0-9]{2}:[0-9]{2}$/.test(x))
|
|
74
|
+
x = x.substring(0, x.indexOf("+")) + "Z";
|
|
75
|
+
return new Date(x);
|
|
76
|
+
}
|
|
77
|
+
// If the string can be converted to a number and isn't `force_string`ed, convert it (namely because of user cover id and few others)
|
|
78
|
+
if (!isNaN(x) && x !== "" && x !== null && typeof x !== "boolean") {
|
|
79
|
+
return Number(x);
|
|
80
|
+
}
|
|
81
|
+
return x;
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "umami-api-js",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Package to easily access the umami api!",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prepublish": "npm run build",
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"test": "npm run build && node ./dist/test.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"author": "Taevas",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://codeberg.org/Taevas/umami-api-js"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://codeberg.org/Taevas/umami-api-js",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"umami",
|
|
24
|
+
"api",
|
|
25
|
+
"wrapper",
|
|
26
|
+
"api-wrapper"
|
|
27
|
+
],
|
|
28
|
+
"license": "Unlicense",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/chai": "^5.2.2",
|
|
31
|
+
"@types/node": "^24.3.1",
|
|
32
|
+
"dotenv": "^17.2.2",
|
|
33
|
+
"typescript": "^5.9.2"
|
|
34
|
+
}
|
|
35
|
+
}
|