Skip to content

Commit

Permalink
Replace "got" HTTP-client with fetch API
Browse files Browse the repository at this point in the history
  • Loading branch information
Borewit committed Aug 25, 2024
1 parent 8939828 commit 78900c2
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 293 deletions.
9 changes: 9 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
"linter": {
"enabled": true,
"rules": {
"correctness": {
"noConstantCondition": "warn",
"noUnusedImports": "error",
"noNodejsModules": "error"
},
"style": {
"noParameterAssign": "off",
"useConst": "error"
},
"recommended": true,
"complexity": {
"noForEach": "off"
Expand Down
11 changes: 5 additions & 6 deletions lib/coverartarchive-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable-next-line */
import got from 'got';
import {HttpClient} from "./httpClient.js";

export type CovertType = 'Front' | 'Back' | 'Booklet' | 'Medium' | 'Obi' | 'Spine' | 'Track' | 'Tray' | 'Sticker' |
'Poster' | 'Liner' | 'Watermark' | 'Raw/Unedited' | 'Matrix/Runout' | 'Top' | 'Bottom' | 'Other';
Expand Down Expand Up @@ -29,16 +29,15 @@ export interface ICoverInfo {

export class CoverArtArchiveApi {

private host = 'coverartarchive.org';
private httpClient = new HttpClient({baseUrl: 'https://coverartarchive.org', userAgent: 'Node.js musicbrains-api', timeout: 20000})

private async getJson(path: string) {
const response = await got.get(`https://${this.host}${path}`, {
const response = await this.httpClient.get(path, {
headers: {
Accept: "application/json"
},
responseType: 'json'
}
});
return response.body;
return response.json();
}

/**
Expand Down
6 changes: 2 additions & 4 deletions lib/digest-auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'node:crypto';
import sparkMd5 from 'spark-md5';

interface IChallenge {
algorithm?: string;
Expand All @@ -14,9 +14,7 @@ export interface ICredentials {
password: string;
}

function md5(str: string): string {
return crypto.createHash('md5').update(str).digest('hex'); // lgtm [js/insufficient-password-hash]
}
const md5 = sparkMd5.hash;

export class DigestAuth {

Expand Down
85 changes: 85 additions & 0 deletions lib/httpClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {CookieJar} from "tough-cookie";

export type HttpFormData = { [key: string]: string; }

export interface IHttpClientOptions {
baseUrl: string,
timeout: number;
userAgent: string;
}

export interface IFetchOptions {
query?: HttpFormData;
retryLimit?: number;
body?: string;
headers?: HeadersInit;
followRedirects?: boolean;
}

export class HttpClient {

private cookieJar: CookieJar;

public constructor(private options: IHttpClientOptions) {
this.cookieJar = new CookieJar();
}

public get(path: string, options?: IFetchOptions): Promise<Response> {
return this._fetch('get', path, options);
}

public post(path: string, options?: IFetchOptions) {
return this._fetch('post', path, options);
}

public postForm(path: string, formData: HttpFormData, options?: IFetchOptions) {
const encodedFormData = new URLSearchParams(formData).toString();
return this._fetch('post', path, {...options, body: encodedFormData, headers: {'Content-Type': 'application/x-www-form-urlencoded'}});
}

// biome-ignore lint/complexity/noBannedTypes:
public postJson(path: string, json: Object, options?: IFetchOptions) {
const encodedJson = JSON.stringify(json);
return this._fetch('post', path, {...options, body: encodedJson, headers: {'Content-Type': 'application/json.'}});
}

private async _fetch(method: string, path: string, options?: IFetchOptions): Promise<Response> {

if (!options) options = {};

let url = `${this.options.baseUrl}/${path}`;
if (options.query) {
url += `?${new URLSearchParams(options.query)}`;
}

const cookies = await this.getCookies();

const headers: HeadersInit = {
...options.headers,
'User-Agent': this.options.userAgent,
'Cookie': cookies
};

const response = await fetch(url, {
method,
...options,
headers,
body: options.body,
redirect: options.followRedirects === false ? 'manual' : 'follow'
});
await this.registerCookies(response);
return response;
}

private registerCookies(response: Response) {
const cookie = response.headers.get('set-cookie');
if (cookie) {
return this.cookieJar.setCookie(cookie, response.url);
}
}

public getCookies(): Promise<string> {
return this.cookieJar.getCookieString(this.options.baseUrl); // Get cookies for the request
}

}
124 changes: 47 additions & 77 deletions lib/musicbrainz-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as assert from 'node:assert';

import { StatusCodes as HttpStatus } from 'http-status-codes';
import Debug from 'debug';

Expand All @@ -13,15 +11,10 @@ import { DigestAuth } from './digest-auth.js';

import { RateLimitThreshold } from 'rate-limit-threshold';
import * as mb from './musicbrainz.types.js';

import got, {type Options, type ToughCookieJar} from 'got';

import {type Cookie, CookieJar} from 'tough-cookie';
import {HttpClient, type HttpFormData} from "./httpClient.js";

export * from './musicbrainz.types.js';

import { promisify } from 'node:util';

/*
* https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2#Subqueries
*/
Expand Down Expand Up @@ -138,7 +131,7 @@ export interface IMusicBrainzConfig {
username?: string,
password?: string
},
baseUrl?: string,
baseUrl: string,

appName?: string,
appVersion?: string,
Expand Down Expand Up @@ -173,7 +166,7 @@ export class MusicBrainzApi {
};

private rateLimiter: RateLimitThreshold;
private options: Options;
private httpClient: HttpClient;
private session?: ISessionInformation;

public static fetchCsrf(html: string): ICsrfSession {
Expand All @@ -195,44 +188,30 @@ export class MusicBrainzApi {
}
}

private getCookies: (currentUrl: string) => Promise<Cookie[]>;

public constructor(_config?: IMusicBrainzConfig) {

Object.assign(this.config, _config);

const cookieJar: CookieJar = new CookieJar();
this.getCookies = promisify(cookieJar.getCookies.bind(cookieJar));

// @ts-ignore
this.options = {
prefixUrl: this.config.baseUrl as string,
timeout: {
read: 20 * 1000
},
headers: {
'User-Agent': `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
},
cookieJar: cookieJar as ToughCookieJar
};
this.httpClient = new HttpClient({
baseUrl: this.config.baseUrl,
timeout: 20 * 1000,
userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`
});

this.rateLimiter = new RateLimitThreshold(15, 18);
}

public async restGet<T>(relUrl: string, query: { [key: string]: any; } = {}): Promise<T> {
public async restGet<T>(relUrl: string, query: { [key: string]: string; } = {}): Promise<T> {

query.fmt = 'json';

await this.applyRateLimiter();
const response: any = await got.get(`ws/2${relUrl}`, {
...this.options,
searchParams: query,
responseType: 'json',
retry: {
limit: 10
}

const response = await this.httpClient.get(`ws/2${relUrl}`, {
query,
retryLimit: 10
});
return response.body;
return response.json();
}

/**
Expand Down Expand Up @@ -328,22 +307,21 @@ export class MusicBrainzApi {
const path = `ws/2/${entity}/`;
// Get digest challenge

let digest: string | undefined;
let digest = '';
let n = 1;
const postData = xmlMetadata.toXml();

do {
await this.applyRateLimiter();
const response: any = await got.post(path, {
...this.options,
searchParams: {client: clientId},
const response: any = await this.httpClient.post(path, {
query: {client: clientId},
headers: {
authorization: digest,
'Content-Type': 'application/xml'
},
body: postData,
throwHttpErrors: false
body: postData
});

if (response.statusCode === HttpStatus.UNAUTHORIZED) {
// Respond to digest challenge
const auth = new DigestAuth(this.config.botAccount as {username: string, password: string});
Expand All @@ -358,37 +336,33 @@ export class MusicBrainzApi {

public async login(): Promise<boolean> {

assert.ok(this.config.botAccount?.username, 'bot username should be set');
assert.ok(this.config.botAccount?.password, 'bot password should be set');
if(!this.config.botAccount?.username) throw new Error('bot username should be set');
if(!this.config.botAccount?.password) throw new Error('bot password should be set');

if (this.session?.loggedIn) {
for (const cookie of await this.getCookies(this.options.prefixUrl as string)) {
if (cookie.key === 'remember_login') {
return true;
}
}
const cookies = await this.httpClient.getCookies();
return cookies.indexOf('musicbrainz_server_session') !== -1;
}
this.session = await this.getSession();

const redirectUri = '/success';

const formData = {
const formData: HttpFormData = {
username: this.config.botAccount.username,
password: this.config.botAccount.password,
csrf_session_key: this.session.csrf.sessionKey,
csrf_token: this.session.csrf.token,
remember_me: 1
remember_me: '1'
};

const response = await got.post('login', {
...this.options,
followRedirect: false,
searchParams: {
returnto: redirectUri
},
form: formData
const response = await this.httpClient.postForm('login', formData, {
query: {
returnto: redirectUri
},
followRedirects: false
});
const success = response.statusCode === HttpStatus.MOVED_TEMPORARILY && response.headers.location === redirectUri;

const success = response.status === HttpStatus.MOVED_TEMPORARILY && response.headers.get('location') === redirectUri;
if (success) {
this.session.loggedIn = true;
}
Expand All @@ -401,14 +375,13 @@ export class MusicBrainzApi {
public async logout(): Promise<boolean> {
const redirectUri = '/success';

const response = await got.get('logout', {
...this.options,
followRedirect: false,
searchParams: {
const response = await this.httpClient.post('logout', {
followRedirects: false,
query: {
returnto: redirectUri
}
});
const success = response.statusCode === HttpStatus.MOVED_TEMPORARILY && response.headers.location === redirectUri;
const success = response.status === HttpStatus.MOVED_TEMPORARILY && response.headers.get('location') === redirectUri;
if (success && this.session) {
this.session.loggedIn = true;
}
Expand All @@ -433,16 +406,14 @@ export class MusicBrainzApi {
formData.password = this.config.botAccount?.password;
formData.remember_me = 1;

const response = await got.post(`${entity}/${mbid}/edit`, {
...this.options,
form: formData,
followRedirect: false
const response = await this.httpClient.postForm(`${entity}/${mbid}/edit`, formData, {
followRedirects: false
});
if (response.statusCode === HttpStatus.OK)
if (response.status === HttpStatus.OK)
throw new Error("Failed to submit form data");
if (response.statusCode === HttpStatus.MOVED_TEMPORARILY)
if (response.status === HttpStatus.MOVED_TEMPORARILY)
return;
throw new Error(`Unexpected status code: ${response.statusCode}`);
throw new Error(`Unexpected status code: ${response.status}`);
}

/**
Expand Down Expand Up @@ -510,7 +481,9 @@ export class MusicBrainzApi {
*/
public addSpotifyIdToRecording(recording: mb.IRecording, spotifyId: string, editNote: string) {

assert.strictEqual(spotifyId.length, 22);
if (spotifyId.length !== 22) {
throw new Error('Invalid Spotify ID length');
}

return this.addUrlToRecording(recording, {
linkTypeId: mb.LinkType.stream_for_free,
Expand All @@ -520,14 +493,11 @@ export class MusicBrainzApi {

private async getSession(): Promise<ISessionInformation> {

const response = await got.get('login', {
...this.options,
followRedirect: false, // Disable redirects
responseType: 'text'
const response = await this.httpClient.get('login', {
followRedirects: false
});

return {
csrf: MusicBrainzApi.fetchCsrf(response.body)
csrf: MusicBrainzApi.fetchCsrf(await response.text())
};
}

Expand Down
Loading

0 comments on commit 78900c2

Please sign in to comment.