Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor client API #61

Merged
merged 9 commits into from
Aug 4, 2024
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