diff --git a/package.json b/package.json index 20890ea..7e7d1ba 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ }, "devDependencies": { "@spotify/web-scripts": "^7.0.0", + "@types/qs": "^6.9.2", "husky": "^4.2.5" }, "dependencies": { - "axios": "^0.19.2" + "axios": "^0.19.2", + "qs": "^6.9.4" } } diff --git a/src/SpotifyWebApi.spec.ts b/src/SpotifyWebApi.spec.ts index 9288310..5d7086d 100644 --- a/src/SpotifyWebApi.spec.ts +++ b/src/SpotifyWebApi.spec.ts @@ -1,14 +1,179 @@ +import axios from 'axios'; +import { TOKEN_URL } from './constants'; +import { encodeToBase64 } from './helpers/encodeToBase64'; +import { getAuthorizationUrl } from './helpers/getAuthorizationUrl'; import { SpotifyWebApi } from './SpotifyWebApi'; +jest.mock('axios'); +jest.mock('./helpers/getAuthorizationUrl'); + +const axiosMock = axios as jest.Mocked; +const getAuthorizationUrlMock = getAuthorizationUrl as jest.MockedFunction< + typeof getAuthorizationUrl +>; + +beforeEach(() => { + jest.resetAllMocks(); +}); + describe('SpotifyWebApi', () => { - beforeEach(() => { - jest.resetAllMocks(); + it('should construct a SpotifyWebApi instance (without options)', () => { + const spotify = new SpotifyWebApi(); + + expect(spotify.getAccessToken()).toBe(''); + expect(spotify.getClientId()).toBe(''); + expect(spotify.getClientSecret()).toBe(''); + expect(spotify.getRedirectUri()).toBe(''); + }); + + it('should construct a SpotifyWebApi instance (with options)', () => { + const spotify = new SpotifyWebApi({ + accessToken: 'foo', + clientId: 'bar', + clientSecret: 'baz', + redirectUri: 'qux', + }); + + expect(spotify.getAccessToken()).toBe('foo'); + expect(spotify.getClientId()).toBe('bar'); + expect(spotify.getClientSecret()).toBe('baz'); + expect(spotify.getRedirectUri()).toBe('qux'); }); it('should get and set the access token', () => { - const spotify = new SpotifyWebApi('token'); + const spotify = new SpotifyWebApi({ accessToken: 'token' }); expect(spotify.getAccessToken()).toBe('token'); spotify.setAccessToken('newToken'); expect(spotify.getAccessToken()).toBe('newToken'); }); + + describe('getRefreshableAuthorizationUrl', () => { + it('should get a URL for refreshable authorization (without options)', () => { + const spotify = new SpotifyWebApi({ + clientId: 'foo', + redirectUri: 'bar', + }); + + spotify.getRefreshableAuthorizationUrl(); + + expect(getAuthorizationUrlMock).toBeCalledWith( + 'foo', + 'bar', + 'code', + undefined, + ); + }); + + it('should get a URL for refreshable authorization (with options)', () => { + const spotify = new SpotifyWebApi({ + clientId: 'foo', + redirectUri: 'bar', + }); + spotify.getRefreshableAuthorizationUrl({ state: 'baz' }); + + expect(getAuthorizationUrlMock).toBeCalledWith('foo', 'bar', 'code', { + state: 'baz', + }); + }); + }); + + describe('getTemporaryAuthorizationUrl', () => { + it('should get a URL for temporary authorization (without options)', () => { + const spotify = new SpotifyWebApi({ + clientId: 'foo', + redirectUri: 'bar', + }); + + spotify.getTemporaryAuthorizationUrl(); + + expect(getAuthorizationUrlMock).toBeCalledWith( + 'foo', + 'bar', + 'token', + undefined, + ); + }); + + it('should get a URL for temporary authorization (with options)', () => { + const spotify = new SpotifyWebApi({ + clientId: 'foo', + redirectUri: 'bar', + }); + spotify.getTemporaryAuthorizationUrl({ state: 'baz' }); + + expect(getAuthorizationUrlMock).toBeCalledWith('foo', 'bar', 'token', { + state: 'baz', + }); + }); + }); + + describe('getRefreshableUserTokens', () => { + it('should get refreshable user tokens', async () => { + axiosMock.post.mockResolvedValue({}); + const spotify = new SpotifyWebApi({ + clientId: 'foo', + clientSecret: 'bar', + redirectUri: 'baz', + }); + + await spotify.getRefreshableUserTokens('qux'); + + expect(axiosMock.post).toBeCalledWith( + TOKEN_URL, + 'code=qux&grant_type=authorization_code&redirect_uri=baz', + { + headers: { + Authorization: `Basic ${encodeToBase64('foo:bar')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + }); + }); + + describe('getRefreshedAccessToken', () => { + it('should get a refreshed access token', async () => { + axiosMock.post.mockResolvedValue({}); + const spotify = new SpotifyWebApi({ + clientId: 'foo', + clientSecret: 'bar', + }); + + await spotify.getRefreshedAccessToken('baz'); + + expect(axiosMock.post).toBeCalledWith( + TOKEN_URL, + 'grant_type=refresh_token&refresh_token=baz', + { + headers: { + Authorization: `Basic ${encodeToBase64('foo:bar')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + }); + }); + + describe('getTemporaryAppTokens', () => { + it('should get temporary app tokens', async () => { + axiosMock.post.mockResolvedValue({}); + const spotify = new SpotifyWebApi({ + clientId: 'foo', + clientSecret: 'bar', + }); + + await spotify.getTemporaryAppTokens(); + + expect(axiosMock.post).toBeCalledWith( + TOKEN_URL, + 'grant_type=client_credentials', + { + headers: { + Authorization: `Basic ${encodeToBase64('foo:bar')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + }); + }); }); diff --git a/src/SpotifyWebApi.ts b/src/SpotifyWebApi.ts index 013b807..ee30031 100644 --- a/src/SpotifyWebApi.ts +++ b/src/SpotifyWebApi.ts @@ -1,37 +1,53 @@ -import { Http } from './helpers/Http'; +import axios from 'axios'; +import qs from 'qs'; import * as apis from './apis'; +import { TOKEN_URL } from './constants'; +import { encodeToBase64 } from './helpers/encodeToBase64'; +import { + getAuthorizationUrl, + GetAuthorizationUrlOptions, +} from './helpers/getAuthorizationUrl'; +import { Http } from './helpers/Http'; +import { + GetRefreshableUserTokensResponse, + GetRefreshedAccessTokenResponse, + GetTemporaryAppTokensResponse, +} from './types'; + +type SpotifyWebApiOptions = { + accessToken?: string; + clientId?: string; + clientSecret?: string; + redirectUri?: string; +}; export class SpotifyWebApi { + private clientId: string; + private clientSecret: string; + private redirectUri: string; + private http: Http; public albums: apis.AlbumsApi; - public artists: apis.ArtistsApi; - public browse: apis.BrowseApi; - public episodes: apis.EpisodesApi; - public follow: apis.FollowApi; - public library: apis.LibraryApi; - public personalization: apis.PersonalizationApi; - public player: apis.PlayerApi; - public playlists: apis.PlaylistsApi; - public search: apis.SearchApi; - public shows: apis.ShowsApi; - public tracks: apis.TracksApi; - public users: apis.UsersApi; - constructor(accessToken: string) { - this.http = new Http(accessToken); + constructor(options?: SpotifyWebApiOptions) { + this.clientId = options?.clientId ?? ''; + this.clientSecret = options?.clientSecret ?? ''; + this.redirectUri = options?.redirectUri ?? ''; + + this.http = new Http(options?.accessToken ?? ''); this.albums = new apis.AlbumsApi(this.http); this.artists = new apis.ArtistsApi(this.http); @@ -55,4 +71,141 @@ export class SpotifyWebApi { setAccessToken(accessToken: string) { this.http.setAccessToken(accessToken); } + + getClientId() { + return this.clientId; + } + + getClientSecret() { + return this.clientSecret; + } + + getRedirectUri() { + return this.redirectUri; + } + + // +--------------------+ + // | Authorization URLs | + // +--------------------+ + + /** + * Get an authorization URL for use with the Authorization Code flow. + * + * @param options Optional URL parameters. + */ + getRefreshableAuthorizationUrl(options?: GetAuthorizationUrlOptions) { + return getAuthorizationUrl( + this.clientId, + this.redirectUri, + 'code', + options, + ); + } + + /** + * Get an authorization URL for use with the Implicit Grant and Client + * Credentials flows. + * + * @param options Optional URL parameters. + */ + getTemporaryAuthorizationUrl(options?: GetAuthorizationUrlOptions) { + return getAuthorizationUrl( + this.clientId, + this.redirectUri, + 'token', + options, + ); + } + + // +---------------------------+ + // | Refreshable Authorization | + // +---------------------------+ + + /** + * Get refreshable authorization tokens using the Authorization Code flow. + * + * This flow is suitable for long-running applications in which the user + * grants permission only once. It provides an access token that can be + * refreshed. Since the token exchange involves sending your secret key, + * perform this on a secure location, like a backend service, and not from a + * client such as a browser or from a mobile app. + * + * @param code The authorization code returned from the initial request to + * the authorization endpoint. + */ + async getRefreshableUserTokens(code: string) { + const response = await axios.post( + TOKEN_URL, + qs.stringify({ + code, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + }), + { + headers: { + Authorization: `Basic ${encodeToBase64( + `${this.clientId}:${this.clientSecret}`, + )}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + return response.data; + } + + /** + * Obtain a refreshed access token given the original refresh token from the + * initial authorization code exchange. + * + * @param refreshToken The refresh token returned from the authorization code + * exchange. + */ + async getRefreshedAccessToken(refreshToken: string) { + const response = await axios.post( + TOKEN_URL, + qs.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + { + headers: { + Authorization: `Basic ${encodeToBase64( + `${this.clientId}:${this.clientSecret}`, + )}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + return response.data; + } + + // +-------------------------+ + // | Temporary Authorization | + // +-------------------------+ + + /** + * Get temporary authorization tokens using the Client Credentials flow. + * + * The Client Credentials flow is used in server-to-server authentication. + * Only endpoints that do not access user information can be accessed. The + * advantage here in comparison with requests to the Web API made without an + * access token, is that a higher rate limit is applied. + */ + async getTemporaryAppTokens() { + const response = await axios.post( + TOKEN_URL, + qs.stringify({ + grant_type: 'client_credentials', + }), + { + headers: { + Authorization: `Basic ${encodeToBase64( + `${this.clientId}:${this.clientSecret}`, + )}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + return response.data; + } } diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 09c6ad2..0000000 --- a/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const BASE_URL = 'https://api.spotify.com/v1'; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..76e4fa4 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,4 @@ +export const BASE_API_URL = 'https://api.spotify.com/v1'; +export const BASE_ACCOUNTS_URL = 'https://accounts.spotify.com'; +export const AUTHORIZE_URL = `${BASE_ACCOUNTS_URL}/authorize`; +export const TOKEN_URL = `${BASE_ACCOUNTS_URL}/api/token`; diff --git a/src/helpers/encodeToBase64.spec.ts b/src/helpers/encodeToBase64.spec.ts new file mode 100644 index 0000000..79a7444 --- /dev/null +++ b/src/helpers/encodeToBase64.spec.ts @@ -0,0 +1,7 @@ +import { encodeToBase64 } from './encodeToBase64'; + +describe('encodeToBase64', () => { + it('should encode a string into its Base64 representation', () => { + expect(encodeToBase64('foo')).toBe('Zm9v'); + }); +}); diff --git a/src/helpers/encodeToBase64.ts b/src/helpers/encodeToBase64.ts new file mode 100644 index 0000000..fc9c6f2 --- /dev/null +++ b/src/helpers/encodeToBase64.ts @@ -0,0 +1,3 @@ +export function encodeToBase64(str: string) { + return Buffer.from(str).toString('base64'); +} diff --git a/src/helpers/getAuthorizationUrl.spec.ts b/src/helpers/getAuthorizationUrl.spec.ts new file mode 100644 index 0000000..9f6ee12 --- /dev/null +++ b/src/helpers/getAuthorizationUrl.spec.ts @@ -0,0 +1,29 @@ +import { getAuthorizationUrl } from './getAuthorizationUrl'; +import { AUTHORIZE_URL } from '../constants'; + +describe('getAuthorizationUrl', () => { + it('should get the Spotify authorization URL (without options)', () => { + expect(getAuthorizationUrl('foo', 'bar', 'code')).toBe( + AUTHORIZE_URL.concat('?client_id=foo') + .concat('&redirect_uri=bar') + .concat('&response_type=code'), + ); + }); + + it('should get the Spotify authorization URL (with options)', () => { + expect( + getAuthorizationUrl('foo', 'bar', 'token', { + scope: ['streaming', 'app-remote-control'], + show_dialog: true, + state: 'baz', + }), + ).toBe( + AUTHORIZE_URL.concat('?client_id=foo') + .concat('&redirect_uri=bar') + .concat('&response_type=token') + .concat('&scope=streaming%20app-remote-control') + .concat('&show_dialog=true') + .concat('&state=baz'), + ); + }); +}); diff --git a/src/helpers/getAuthorizationUrl.ts b/src/helpers/getAuthorizationUrl.ts new file mode 100644 index 0000000..61d9d02 --- /dev/null +++ b/src/helpers/getAuthorizationUrl.ts @@ -0,0 +1,25 @@ +import qs from 'qs'; +import { AUTHORIZE_URL } from '../constants'; +import { AuthorizationScope } from '../types'; + +export type GetAuthorizationUrlOptions = { + scope?: AuthorizationScope[]; + show_dialog?: boolean; + state?: string; +}; + +export function getAuthorizationUrl( + clientId: string, + redirectUri: string, + responseType: 'code' | 'token', + options?: GetAuthorizationUrlOptions, +) { + return `${AUTHORIZE_URL}?${qs.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + ...(options?.scope && { scope: options.scope.join(' ') }), + ...(options?.show_dialog && { show_dialog: options.show_dialog }), + ...(options?.state && { state: options.state }), + })}`; +} diff --git a/src/helpers/spotifyAxios.spec.ts b/src/helpers/spotifyAxios.spec.ts index eca7381..af72635 100644 --- a/src/helpers/spotifyAxios.spec.ts +++ b/src/helpers/spotifyAxios.spec.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { spotifyAxios } from './spotifyAxios'; -import { BASE_URL } from '../config'; +import { BASE_API_URL } from '../constants'; jest.mock('axios'); @@ -22,7 +22,7 @@ describe('spotifyAxios', () => { params: { bar: 'baz', }, - baseURL: BASE_URL, + baseURL: BASE_API_URL, headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json', @@ -40,7 +40,7 @@ describe('spotifyAxios', () => { }); expect(axiosMock).toBeCalledWith({ data: 'bar', - baseURL: BASE_URL, + baseURL: BASE_API_URL, headers: { Authorization: 'Bearer token', 'Content-Type': 'image/jpeg', diff --git a/src/helpers/spotifyAxios.ts b/src/helpers/spotifyAxios.ts index a409cbb..6136432 100644 --- a/src/helpers/spotifyAxios.ts +++ b/src/helpers/spotifyAxios.ts @@ -1,5 +1,5 @@ import axios, { Method, AxiosRequestConfig, AxiosError } from 'axios'; -import { BASE_URL } from '../config'; +import { BASE_API_URL } from '../constants'; export type SpotifyAxiosConfig = AxiosRequestConfig & { contentType?: string }; @@ -13,7 +13,7 @@ export async function spotifyAxios( const { contentType, ...axiosConfig } = config ?? {}; const response = await axios({ ...axiosConfig, - baseURL: BASE_URL, + baseURL: BASE_API_URL, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': contentType ?? 'application/json', diff --git a/src/types/SpotifyAuthorization.ts b/src/types/SpotifyAuthorization.ts new file mode 100644 index 0000000..39237b2 --- /dev/null +++ b/src/types/SpotifyAuthorization.ts @@ -0,0 +1,42 @@ +export type AuthorizationScope = + | 'ugc-image-upload' + | 'user-read-playback-state' + | 'user-modify-playback-state' + | 'user-read-currently-playing' + | 'streaming' + | 'app-remote-control' + | 'user-read-email' + | 'user-read-private' + | 'playlist-read-collaborative' + | 'playlist-modify-public' + | 'playlist-read-private' + | 'playlist-modify-private' + | 'user-library-modify' + | 'user-library-read' + | 'user-top-read' + | 'user-read-playback-position' + | 'user-read-recently-played' + | 'user-follow-read' + | 'user-follow-modify'; + +export type GetRefreshableUserTokensResponse = { + access_token: string; + token_type: 'Bearer'; + scope: string; + expires_in: number; + refresh_token: string; +}; + +export type GetRefreshedAccessTokenResponse = { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + scope: string; +}; + +export type GetTemporaryAppTokensResponse = { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + scope: string; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 091428e..d3ed84a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +export * from './SpotifyAuthorization'; export * from './SpotifyObjects'; export * from './SpotifyOptions'; export * from './SpotifyResponses'; diff --git a/yarn.lock b/yarn.lock index 8b34a5f..56c4a1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -907,6 +907,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== +"@types/qs@^6.9.2": + version "6.9.2" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.2.tgz#faab98ec4f96ee72c829b7ec0983af4f4d343113" + integrity sha512-a9bDi4Z3zCZf4Lv1X/vwnvbbDYSNz59h3i3KdyuYYN+YrLjSeJD0dnphdULDfySvUv6Exy/O0K6wX/kQpnPQ+A== + "@types/react-dom@^16.8.4": version "16.9.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.6.tgz#9e7f83d90566521cc2083be2277c6712dcaf754c" @@ -6659,6 +6664,11 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== +qs@^6.9.4: + version "6.9.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" + integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"