UNPKG

@chez14/mal-api-lite

Version:
258 lines (213 loc) 7.34 kB
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; } }