@chez14/mal-api-lite
Version:
MyAnimeList API client, Lite Version
258 lines (213 loc) • 7.34 kB
text/typescript
import { randomBytes } from 'node:crypto';
import got, { Got as GotInstance, Options as GotOptions } from 'got';
import { URL } from 'url';
import { PKG_VERSION } from './constants.js';
import { BaseRequester, PaginatableRequestParam, RequestParam, TokenResponse } from './types.js';
export interface Options {
clientId?: string;
clientSecret?: string;
accessToken?: string;
refreshToken?: string;
gotOptions?: GotOptions;
gotOAuthOptions?: GotOptions;
autoRefreshAccessToken?: boolean;
}
export class MALClient implements BaseRequester {
public clientId?: string;
public clientSecret?: string;
public accessToken?: string;
public refreshToken?: string;
public got: GotInstance;
public gotOAuth: GotInstance;
public PKCEChallangeGenerateSize = 32;
public userAgent = '@chez14/mal-api-lite';
public malApiBaseUrl = 'https://api.myanimelist.net/v2/';
public malOAuthUrl = 'https://myanimelist.net/v1/oauth2/';
/**
* Create MAL API Client
*
* @param param0 Your trusty configuration
*/
public constructor({ clientId, clientSecret, accessToken, refreshToken, gotOptions, gotOAuthOptions }: Options) {
if ((!clientSecret || !clientId) && !(accessToken || refreshToken)) {
// if either ( clientSecret or clientId ) not preset, AND accessToken or
// refreshToken is provided...
throw new Error(
'You need to provide both (`clientSecret` and `clientId`) OR one of (`accessToken` or `refreshToken`)',
);
}
this.clientId = clientId;
this.clientSecret = clientSecret;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.userAgent += ` v${PKG_VERSION}`;
this.got = got.extend({
prefixUrl: this.malApiBaseUrl,
responseType: 'json',
headers: { 'user-agent': this.userAgent },
hooks: {
beforeRequest: [
(options) => {
if (this.accessToken) {
options.headers.authorization = `Bearer ${this.accessToken}`;
} else {
options.headers['X-MAL-CLIENT-ID'] = this.clientId;
}
}
]
},
...gotOptions,
});
this.gotOAuth = got.extend({
prefixUrl: this.malOAuthUrl,
headers: { 'user-agent': this.userAgent },
responseType: 'json',
...gotOAuthOptions,
});
}
/**
* Get Access Token & Refresh Token from given Authorization Code.
*
* @param authCode Authorization code
* @param codeVerifier PKCE Code Challenge
* @param redirectUri Redirect url, specified on on previous step
*/
public async resolveAuthCode(authCode: string, codeVerifier: string, redirectUri?: string): Promise<TokenResponse> {
if (!this.clientSecret || !this.clientId) {
throw new Error('clientSecret and clientId must be filled to use this function!');
}
const resp = await this.gotOAuth.post<TokenResponse>('token', {
form: {
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'authorization_code',
code: authCode,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
},
});
const { access_token, refresh_token } = resp.body;
this.accessToken = access_token;
this.refreshToken = refresh_token;
return resp.body;
}
/**
* Generate OAuth URL to gain access to user account on MyAnimeList platform.
* Will require clientId and clientSecret from custructor.
*
* @param codeChallenge PKCE Code Challenge
* @param redirectUri If you have more than one Redirect URL, please specify
* the url you use.
* @param state Your app state
* @param codeChallengeMethod Only accept "plain". Don't change unless you
* know what you're doing!
*/
public getOAuthURL(
redirectUri?: string,
codeChallenge?: string,
state?: string,
): { url: string; codeChallenge: string; state?: string } {
if (!this.clientId) {
throw new Error('clientId must be filled to use this function!');
}
if (!codeChallenge) {
codeChallenge = randomBytes(this.PKCEChallangeGenerateSize).toString('base64url');
}
const query: Record<string, string | undefined> = {
response_type: 'code',
client_id: this.clientId,
state,
redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: 'plain',
};
const urlBuilder = new URL('authorize', this.gotOAuth.defaults.options.prefixUrl);
Object.keys(query).forEach((key) => {
if (query[key]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
urlBuilder.searchParams.append(key, query[key]!);
}
});
return { url: urlBuilder.toString(), codeChallenge, state };
}
/**
* Refresh your access token with refresh token.
*
* @param refreshToken Custom refresh token
*/
public async resolveRefreshToken(refreshToken?: string): Promise<TokenResponse> {
if (!refreshToken) {
refreshToken = this.refreshToken;
}
if (!refreshToken) {
throw Error('No refreshToken provided.');
}
const resp = await this.gotOAuth.post<TokenResponse>('token', {
form: {
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
},
});
const { access_token, refresh_token } = resp.body;
this.accessToken = access_token;
this.refreshToken = refresh_token;
return resp.body;
}
protected preprocessParam(param?: RequestParam): RequestParam | undefined {
if (param?.fields && Array.isArray(param.fields)) {
param.fields = param.fields.join(',');
}
return param;
}
/**
* Do HTTP GET stuffs.
*
* @param resource Url to call
* @param param Parameter body
*/
public async get<T = any>(resource: string, param?: RequestParam | PaginatableRequestParam): Promise<T> { // eslint-disable-line @typescript-eslint/no-explicit-any
param = this.preprocessParam(param);
const response = await this.got.get<T>(resource, {
searchParams: param,
});
return response.body;
}
/**
* Do HTTP POST stuffs.
*
* @param resource Url to call
* @param param Parameter body
*/
public async post<T = any>(resource: string, param?: RequestParam): Promise<T> { // eslint-disable-line @typescript-eslint/no-explicit-any
param = this.preprocessParam(param);
const response = await this.got.post<T>(resource, {
form: param,
});
return response.body;
}
/**
* Do HTTP PATCH stuffs.
*
* @param resource Url to call
* @param param Parameter body
*/
public async patch<T = any>(resource: string, param?: RequestParam): Promise<T> { // eslint-disable-line @typescript-eslint/no-explicit-any
param = this.preprocessParam(param);
const response = await this.got.patch<T>(resource, {
form: param,
});
return response.body;
}
/**
* Do HTTP DELETE stuffs.
*
* @param resource Url to call
* @param param Parameter body (discouraged)
*/
public async delete<T = any>(resource: string): Promise<T> { // eslint-disable-line @typescript-eslint/no-explicit-any
const response = await this.got.delete<T>(resource);
return response.body;
}
}