Skip to content

Commit

Permalink
Refactor client API (#61)
Browse files Browse the repository at this point in the history
* chore(lint): do not track unused vars in errors interfaces

* fix(manager): save last token refresh result after retries

* fix(manager): save last get token result after retries

* chore(manager): store access token expiration timestamp instead of date

* refactor(manager): simplified error handling with retries

* refactor(http): separate auth management from client

* fix(managers): do not create http client per each manager

* refactor(http): do not redefine static headers for each request
  • Loading branch information
seth2810 authored Aug 4, 2024
1 parent 7cc06e2 commit 916e3eb
Show file tree
Hide file tree
Showing 6 changed files with 347 additions and 305 deletions.
2 changes: 1 addition & 1 deletion src/interfaces/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ export interface SpotifyConfig {
}

export interface PrivateConfig {
tokenExpire?: Date;
tokenExpireAt?: number;
}
1 change: 1 addition & 0 deletions src/interfaces/Errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
export class AuthError extends Error {
data: Record<string, unknown>;
name = AuthError.name;
Expand Down
11 changes: 3 additions & 8 deletions src/lib/Manager.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { PrivateConfig, SpotifyConfig } from '../interfaces/Config';
import { HttpClient } from './http/HttpManager';

export class Manager {
protected http = new HttpClient(this.config, this.privateConfig);

constructor(
protected config: SpotifyConfig,
protected privateConfig: PrivateConfig
) {}
export abstract class Manager {
// eslint-disable-next-line no-unused-vars
constructor(protected readonly http: HttpClient) {}
}
27 changes: 13 additions & 14 deletions src/lib/SpotifyAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PrivateConfig, SpotifyConfig } from '../interfaces/Config';
import { AlbumManager } from './album/AlbumManager';
import { ArtistManager } from './artist/ArtistManager';
import { AudioManager } from './audio/AudioManager';
import { HttpClient } from './http/HttpManager';
import { MeManager } from './me/MeManager';
import { PlaylistManager } from './playlist/PlaylistManager';
import { RecommendationsManager } from './recommendations/RecommendationsManager';
Expand Down Expand Up @@ -30,26 +31,24 @@ export class SpotifyAPI {

private privateConfig: PrivateConfig = {};

config: SpotifyConfig;

constructor(config: SpotifyConfig) {
this.config = config;

constructor(public config: SpotifyConfig) {
// TODO: remove for v2
// eslint-disable-next-line deprecation/deprecation
if (!this.config.accessToken && config.acccessToken) {
// eslint-disable-next-line deprecation/deprecation
this.config.accessToken = config.acccessToken;
}

this.tracks = new TrackManager(this.config, this.privateConfig);
this.albums = new AlbumManager(this.config, this.privateConfig);
this.artists = new ArtistManager(this.config, this.privateConfig);
this.users = new UserManager(this.config, this.privateConfig);
this.me = new MeManager(this.config, this.privateConfig);
this.search = new SearchManager(this.config, this.privateConfig);
this.recommendations = new RecommendationsManager(this.config, this.privateConfig);
this.audio = new AudioManager(this.config, this.privateConfig);
this.playlist = new PlaylistManager(this.config, this.privateConfig);
const client = new HttpClient(this.config, this.privateConfig);

this.tracks = new TrackManager(client);
this.albums = new AlbumManager(client);
this.artists = new ArtistManager(client);
this.users = new UserManager(client);
this.me = new MeManager(client);
this.search = new SearchManager(client);
this.recommendations = new RecommendationsManager(client);
this.audio = new AudioManager(client);
this.playlist = new PlaylistManager(client);
}
}
163 changes: 163 additions & 0 deletions src/lib/http/AuthManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* eslint-disable no-console */
import axios, { AxiosInstance } from 'axios';
import { AuthError } from '../../interfaces/Errors';
import { PrivateConfig, SpotifyConfig } from '../../interfaces/Config';

const accountsApiUrl = 'https://accounts.spotify.com/api';

const accessTokenExpireTTL = 60 * 60 * 1_000; // 1hour

export class AuthManager {
protected client: AxiosInstance;

constructor(
// eslint-disable-next-line no-unused-vars
protected config: SpotifyConfig,
// eslint-disable-next-line no-unused-vars
protected privateConfig: PrivateConfig
) {
this.client = axios.create({
baseURL: accountsApiUrl,
auth: {
username: this.config.clientCredentials?.clientId,
password: this.config.clientCredentials?.clientSecret
},
validateStatus: () => true
});
}

/**
* @description Get a refresh token.
* @param {number} retryAttempt Number of of retries.
* @returns {string} Returns the refresh token.
*/
private async refreshToken(retryAttempt: number): Promise<string> {
if (
!this.config.clientCredentials.clientId ||
!this.config.clientCredentials.clientSecret ||
!this.config.refreshToken
) {
throw new AuthError(
'Missing information needed to refresh token, required: client id, client secret, refresh token'
);
}

const response = await this.client.post(
'/token',
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.config.refreshToken
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError('Failed to refresh token: bad request', {
data: response.data
});
}

if (retryAttempt >= 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to refresh token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (this.config.debug) {
console.log(
`Failed to refresh token: got (${statusCode}) response. Retrying... (${retryAttempt + 1})`
);
}

return await this.refreshToken(retryAttempt + 1);
}

/**
* Get authorization token with client credentials flow.
* @param {number} retryAttempt Number of of retries.
* @returns {string} Returns the authorization token.
*/
private async requestToken(retryAttempt: number): Promise<string> {
const response = await this.client.post(
'/token',
new URLSearchParams({
grant_type: 'client_credentials'
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError(`Failed to get token: bad request`, {
data: response.data
});
}

if (retryAttempt >= 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to get token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (typeof this.config.debug === 'boolean' && this.config.debug === true) {
console.log(
`Failed to get token: got (${statusCode}) response. retrying... (${retryAttempt + 1})`
);
}

return await this.requestToken(retryAttempt + 1);
}

/**
* @description Handles the auth tokens.
* @returns {string} Returns a auth token.
*/
async getToken(): Promise<string> {
if (this.config.accessToken) {
// check if token is expired
if (Date.now() < this.privateConfig.tokenExpireAt) {
// return already defined access token
return this.config.accessToken;
}
}

// refresh token
if (
this.config.clientCredentials?.clientId &&
this.config.clientCredentials?.clientSecret &&
this.config.refreshToken
) {
const accessToken = await this.refreshToken(1);

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

// add credentials flow
if (this.config.clientCredentials?.clientId && this.config.clientCredentials?.clientSecret) {
const accessToken = await this.requestToken(1);

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

throw new AuthError('auth failed: missing information to handle auth');
}
}
Loading

0 comments on commit 916e3eb

Please sign in to comment.