From 533b521452e8c26ebbdbdc203a35acd7d2c7598d Mon Sep 17 00:00:00 2001 From: na2na-p Date: Tue, 9 Jan 2024 03:06:56 +0900 Subject: [PATCH] =?UTF-8?q?Store=E3=81=AB=E5=88=87=E3=82=8A=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=81=A8fetcher=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Spotify/internal/Spotify.class.ts | 74 +++++------- .../internal/Spotify/internal/funcs/index.ts | 5 - src/features/core/index.ts | 5 + src/features/core/internal/Store/index.ts | 9 ++ .../internal/Store/internal/Store.class.ts | 31 +++++ .../internal/Store/internal/Store.types.ts | 5 + src/features/others/spotify/index.ts | 6 + .../internal/fetchSpotifyToken/index.ts | 1 - .../internal/fetchSpotifyToken.constants.ts | 0 .../internal/fetchSpotifyToken.func.ts | 17 ++- .../spotify/internal/spotify.types.ts} | 0 .../spotify/internal/spotifyFetcher/index.ts | 1 + .../internal/spotifyFetcher.func.ts | 112 +++++++++++++++++ .../internal/spotifyFetcher.spec.ts | 113 ++++++++++++++++++ src/index.ts | 4 +- 15 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 src/features/core/internal/Store/index.ts create mode 100644 src/features/core/internal/Store/internal/Store.class.ts create mode 100644 src/features/core/internal/Store/internal/Store.types.ts create mode 100644 src/features/others/spotify/index.ts rename src/features/{commands/internal/Spotify/internal/funcs => others/spotify}/internal/fetchSpotifyToken/index.ts (63%) rename src/features/{commands/internal/Spotify/internal/funcs => others/spotify}/internal/fetchSpotifyToken/internal/fetchSpotifyToken.constants.ts (100%) rename src/features/{commands/internal/Spotify/internal/funcs => others/spotify}/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts (75%) rename src/features/{commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.types.ts => others/spotify/internal/spotify.types.ts} (100%) create mode 100644 src/features/others/spotify/internal/spotifyFetcher/index.ts create mode 100644 src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.func.ts create mode 100644 src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.spec.ts diff --git a/src/features/commands/internal/Spotify/internal/Spotify.class.ts b/src/features/commands/internal/Spotify/internal/Spotify.class.ts index 4d135a17..e91a4b98 100644 --- a/src/features/commands/internal/Spotify/internal/Spotify.class.ts +++ b/src/features/commands/internal/Spotify/internal/Spotify.class.ts @@ -1,18 +1,18 @@ -import type { Guild } from 'discord.js'; - +import { getStoreInstance } from '@/features/core/index.js'; +import type { Guild } from '@/features/library/index.js'; import { getGuildFromInteraction } from '@/features/others/discord/index.js'; +import { + fetchSpotifyToken, + GRANT_TYPE, + spotifyFetcher, +} from '@/features/others/spotify/index.js'; import { SpotifyAuthCommandOptions, SpotifyCommandOptions, SpotifyPlayerCommandOptions, } from './Spotify.constants.js'; -import { - fetchSpotifyToken, - getAuthUrl, - GRANT_TYPE, - type SpotifyTokenResponse, -} from './funcs/index.js'; +import { getAuthUrl } from './funcs/index.js'; import type { InteractArgs } from '../../CommandBase/index.js'; import { CommandBase } from '../../CommandBase/index.js'; @@ -21,14 +21,10 @@ export class Spotify extends CommandBase { public readonly description = 'Using Spotify.'; public override readonly options = Object.values(SpotifyCommandOptions); - /** - * TODO: Store系を一か所に集約してシングルトンの多用やめる - */ - #tokens: { - [key: Guild['id']]: SpotifyTokenResponse; - } = {}; - public override async interact({ interaction }: InteractArgs): Promise { + const store = getStoreInstance(); + const guild = getGuildFromInteraction({ interaction }); + switch (interaction.options.getSubcommand()) { case SpotifyAuthCommandOptions.url.name: const authUrl = getAuthUrl({ @@ -42,24 +38,26 @@ export class Spotify extends CommandBase { }); break; case SpotifyAuthCommandOptions.token.name: - const code = interaction.options.getString('code'); - if (code === null) throw new Error('Unexpected null'); - this.#tokens[getGuildFromInteraction({ interaction }).id] = - await fetchSpotifyToken({ + { + const code = interaction.options.getString('code'); + if (code === null) throw new Error('Unexpected null'); + const token = await fetchSpotifyToken({ type: GRANT_TYPE.AuthorizationCode, code, spotifyCallbackUrl: this.config.SPOTIFY_AUTH_CALLBACK_URL, spotifyClientId: this.config.SPOTIFY_CLIENT_ID, spotifyClientSecret: this.config.SPOTIFY_CLIENT_SECRET, }); + store.putSpotifyTokenByGuildId({ guildId: guild.id, token }); - interaction.reply({ - content: 'Authenticated!', - ephemeral: true, - }); + interaction.reply({ + content: 'Authenticated!', + ephemeral: true, + }); + } break; - case SpotifyPlayerCommandOptions.stop.name: - const token = this.#tokens[getGuildFromInteraction({ interaction }).id]; + case SpotifyPlayerCommandOptions.stop.name: { + const token = store.getSpotifyTokenByGuildId({ guildId: guild.id }); if (token === undefined) { interaction.reply({ content: 'You need to authenticate first.', @@ -67,12 +65,13 @@ export class Spotify extends CommandBase { }); return; } - await this.pauseSpotifyTrack({ token }); + await this.pauseSpotifyTrack({ guildId: guild.id }); interaction.reply({ content: 'Stopped!', ephemeral: true, }); break; + } default: throw new Error('Unexpected subcommand'); } @@ -82,26 +81,17 @@ export class Spotify extends CommandBase { * TODO あとで切り出すか消す */ async pauseSpotifyTrack({ - token, + guildId, }: { - token: SpotifyTokenResponse; + guildId: Guild['id']; }): Promise { - const response = await fetch('https://api.spotify.com/v1/me/player/pause', { + const response = await spotifyFetcher('me/player/pause', { + guildId, method: 'PUT', - headers: { - Authorization: `Bearer ${token.access_token}`, - 'Content-Type': 'application/json', - }, }); - // expiredしてたらrefreshする - if (response.status === 401) { - const refreshedToken = await fetchSpotifyToken({ - type: GRANT_TYPE.RefreshToken, - code: token.refresh_token, - spotifyClientId: this.config.SPOTIFY_CLIENT_ID, - spotifyClientSecret: this.config.SPOTIFY_CLIENT_SECRET, - }); - await this.pauseSpotifyTrack({ token: refreshedToken }); + + if (!response.ok) { + throw new Error('Failed to pause Spotify track.'); } } } diff --git a/src/features/commands/internal/Spotify/internal/funcs/index.ts b/src/features/commands/internal/Spotify/internal/funcs/index.ts index 05738ffc..27ab777b 100644 --- a/src/features/commands/internal/Spotify/internal/funcs/index.ts +++ b/src/features/commands/internal/Spotify/internal/funcs/index.ts @@ -1,6 +1 @@ export { getAuthUrl } from './internal/getAuthUrl/index.js'; -export { - fetchSpotifyToken, - type SpotifyTokenResponse, - GRANT_TYPE, -} from './internal/fetchSpotifyToken/index.js'; diff --git a/src/features/core/index.ts b/src/features/core/index.ts index d69b76c4..ec9be405 100644 --- a/src/features/core/index.ts +++ b/src/features/core/index.ts @@ -7,3 +7,8 @@ export { getVoiceInstance, } from './internal/Voice/index.js'; export type { Voice } from './internal/Voice/index.js'; +export { + getStoreInstance, + type Store, + type SpotifyToken, +} from './internal/Store/index.js'; diff --git a/src/features/core/internal/Store/index.ts b/src/features/core/internal/Store/index.ts new file mode 100644 index 00000000..773a1b05 --- /dev/null +++ b/src/features/core/internal/Store/index.ts @@ -0,0 +1,9 @@ +import { singleton } from '@/features/others/singleton/index.js'; + +import { Store } from './internal/Store.class.js'; +export type { Store } from './internal/Store.class.js'; +export type { SpotifyToken } from './internal/Store.types.js'; + +const createStoreInstance = () => new Store(); + +export const getStoreInstance = singleton(createStoreInstance); diff --git a/src/features/core/internal/Store/internal/Store.class.ts b/src/features/core/internal/Store/internal/Store.class.ts new file mode 100644 index 00000000..2c4ac1cd --- /dev/null +++ b/src/features/core/internal/Store/internal/Store.class.ts @@ -0,0 +1,31 @@ +import type { Guild } from '@/features/library/index.js'; +import type { SpotifyTokenResponse } from '@/features/others/spotify/index.js'; + +import type { SpotifyToken } from './Store.types.js'; + +export class Store { + #spotifyTokens: { + [key: Guild['id']]: SpotifyToken; + } = {}; + + public putSpotifyTokenByGuildId({ + guildId, + token, + }: { + guildId: Guild['id']; + token: SpotifyTokenResponse; + }) { + this.#spotifyTokens[guildId] = { + ...token, + updatedAt: new Date(), + }; + } + + public getSpotifyTokenByGuildId({ + guildId, + }: { + guildId: Guild['id']; + }): SpotifyToken | undefined { + return this.#spotifyTokens[guildId]; + } +} diff --git a/src/features/core/internal/Store/internal/Store.types.ts b/src/features/core/internal/Store/internal/Store.types.ts new file mode 100644 index 00000000..f133679f --- /dev/null +++ b/src/features/core/internal/Store/internal/Store.types.ts @@ -0,0 +1,5 @@ +import type { SpotifyTokenResponse } from '@/features/others/spotify/index.js'; + +export type SpotifyToken = SpotifyTokenResponse & { + updatedAt: Date; +}; diff --git a/src/features/others/spotify/index.ts b/src/features/others/spotify/index.ts new file mode 100644 index 00000000..7c738926 --- /dev/null +++ b/src/features/others/spotify/index.ts @@ -0,0 +1,6 @@ +export type { SpotifyTokenResponse } from './internal/spotify.types.js'; +export { + fetchSpotifyToken, + GRANT_TYPE, +} from './internal/fetchSpotifyToken/index.js'; +export { spotifyFetcher } from './internal/spotifyFetcher/index.js'; diff --git a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/index.ts b/src/features/others/spotify/internal/fetchSpotifyToken/index.ts similarity index 63% rename from src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/index.ts rename to src/features/others/spotify/internal/fetchSpotifyToken/index.ts index 41ce0ba1..d86fbab8 100644 --- a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/index.ts +++ b/src/features/others/spotify/internal/fetchSpotifyToken/index.ts @@ -1,3 +1,2 @@ export { fetchSpotifyToken } from './internal/fetchSpotifyToken.func.js'; -export type { SpotifyTokenResponse } from './internal/fetchSpotifyToken.types.js'; export { GRANT_TYPE } from './internal/fetchSpotifyToken.constants.js'; diff --git a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.constants.ts b/src/features/others/spotify/internal/fetchSpotifyToken/internal/fetchSpotifyToken.constants.ts similarity index 100% rename from src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.constants.ts rename to src/features/others/spotify/internal/fetchSpotifyToken/internal/fetchSpotifyToken.constants.ts diff --git a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts b/src/features/others/spotify/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts similarity index 75% rename from src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts rename to src/features/others/spotify/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts index 3896052d..5ccccaea 100644 --- a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts +++ b/src/features/others/spotify/internal/fetchSpotifyToken/internal/fetchSpotifyToken.func.ts @@ -1,5 +1,6 @@ +import type { SpotifyTokenResponse } from '@/features/others/spotify/index.js'; + import { GRANT_TYPE } from './fetchSpotifyToken.constants.js'; -import type { SpotifyTokenResponse } from './fetchSpotifyToken.types.js'; type FetchSpotifyTokenParams = | { @@ -11,6 +12,7 @@ type FetchSpotifyTokenParams = spotifyClientId: string; spotifyClientSecret: string; spotifyCallbackUrl: string; + fetchFn?: typeof fetch; } | { type: typeof GRANT_TYPE.RefreshToken; @@ -20,11 +22,16 @@ type FetchSpotifyTokenParams = code: string; spotifyClientId: string; spotifyClientSecret: string; + fetchFn?: typeof fetch; }; -export const fetchSpotifyToken = async ( - params: FetchSpotifyTokenParams -): Promise => { +/** + * TODO: エラーハンドリング + */ +export const fetchSpotifyToken = async ({ + fetchFn = fetch, + ...params +}: FetchSpotifyTokenParams): Promise => { const urlParams = new URLSearchParams(); urlParams.append('grant_type', params.type); urlParams.append('code', params.code); @@ -34,7 +41,7 @@ export const fetchSpotifyToken = async ( urlParams.append('redirect_uri', params.spotifyCallbackUrl); } - const response = await fetch('https://accounts.spotify.com/api/token', { + const response = await fetchFn('https://accounts.spotify.com/api/token', { method: 'POST', headers: { Authorization: `Basic ${Buffer.from( diff --git a/src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.types.ts b/src/features/others/spotify/internal/spotify.types.ts similarity index 100% rename from src/features/commands/internal/Spotify/internal/funcs/internal/fetchSpotifyToken/internal/fetchSpotifyToken.types.ts rename to src/features/others/spotify/internal/spotify.types.ts diff --git a/src/features/others/spotify/internal/spotifyFetcher/index.ts b/src/features/others/spotify/internal/spotifyFetcher/index.ts new file mode 100644 index 00000000..ead532f5 --- /dev/null +++ b/src/features/others/spotify/internal/spotifyFetcher/index.ts @@ -0,0 +1 @@ +export { spotifyFetcher } from './internal/spotifyFetcher.func.js'; diff --git a/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.func.ts b/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.func.ts new file mode 100644 index 00000000..66fb0ef2 --- /dev/null +++ b/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.func.ts @@ -0,0 +1,112 @@ +import { getConfig } from '@/features/config/index.js'; +import { + getStoreInstance, + type SpotifyToken, + type Store, +} from '@/features/core/index.js'; +import type { Guild } from '@/features/library/index.js'; +import { LogicException } from '@/features/others/Error/LogicException.js'; + +import { + fetchSpotifyToken, + GRANT_TYPE, +} from '../../fetchSpotifyToken/index.js'; + +type RequestMethod = { + Delete: 'DELETE'; + Get: 'GET'; + Patch: 'PATCH'; + Post: 'POST'; + Put: 'PUT'; +}; + +export type SpotifyFetcher = ( + endpoint: string, + options: { + store?: Store; + method: RequestMethod[keyof RequestMethod]; + guildId: Guild['id']; + body?: URLSearchParams; + apiBaseUrl?: string; + fetchFn?: typeof fetch; + refreshFn?: typeof refresh; + } +) => Promise; + +/** + * Spotify専用のfetcher + * Tokenがexpiredしている場合は自動でrefreshする + */ +export const spotifyFetcher: SpotifyFetcher = async ( + endpoint, + { + store = getStoreInstance(), + apiBaseUrl = 'https://api.spotify.com/v1', + guildId, + method, + body, + fetchFn = fetch, + refreshFn = refresh, + } +) => { + // NOTE: リフレッシュしたら書き換えが走るためlet + let tokens = store.getSpotifyTokenByGuildId({ guildId }); + + if (tokens === undefined) { + throw new Error('Authentication is required.'); + } + + // expires_inは発行からの経過秒数で表現される + // 現在時刻とupdatedAtの差分がexpires_inより大きい場合はrefreshが必要 + const shouldRefresh = + tokens.expires_in * 1000 + tokens.updatedAt.getTime() < Date.now(); + + if (shouldRefresh) { + await refreshFn({ tokens, store, guildId }); + } + + // NOTE: 以降はスコープ内で書き換えることはないため + tokens = Object.freeze(store.getSpotifyTokenByGuildId({ guildId })); + if (tokens === undefined) { + throw new LogicException('tokens is undefined.'); + } + + const requestHeader = { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + } as const; + + const response = await fetchFn(`${apiBaseUrl}/${endpoint}`, { + method, + headers: requestHeader, + body: body ?? null, + }); + + if (!response.ok) { + throw new LogicException( + `Failed to fetch Spotify API: ${response.statusText}` + ); + } + + return response; +}; + +const refresh = async ({ + tokens, + store, + guildId, + config = getConfig(), +}: { + tokens: SpotifyToken; + store: Store; + guildId: Guild['id']; + config?: ReturnType; +}): Promise => { + const token = await fetchSpotifyToken({ + type: GRANT_TYPE.RefreshToken, + code: tokens.refresh_token, + spotifyClientId: config.SPOTIFY_CLIENT_ID, + spotifyClientSecret: config.SPOTIFY_CLIENT_SECRET, + }); + store.putSpotifyTokenByGuildId({ guildId, token }); +}; diff --git a/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.spec.ts b/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.spec.ts new file mode 100644 index 00000000..40f97a97 --- /dev/null +++ b/src/features/others/spotify/internal/spotifyFetcher/internal/spotifyFetcher.spec.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { type SpotifyToken } from '@/features/core/index.js'; + +import { spotifyFetcher } from './spotifyFetcher.func.js'; + +class MockedStore { + tokens: Readonly>; + constructor(initData: any) { + this.tokens = initData; + } + + getSpotifyTokenByGuildId = vi.fn(() => this.tokens); + + putSpotifyTokenByGuildId = vi.fn(({ token }) => { + this.tokens = { + ...this.tokens, + ...token, + updatedAt: new Date(), // 新しい更新時間を設定 + }; + }); +} +// テストケース +describe('spotifyFetcher', () => { + it('should call fetch with correct arguments', async () => { + const mockFetch = vi.fn(() => Promise.resolve({ ok: true })); + const store = new MockedStore({ + access_token: 'token', + expires_in: 3600, + updatedAt: new Date(Date.now() - 1000), + }); + await spotifyFetcher('test/endpoint', { + store: store as any, + guildId: '123', + method: 'GET', + fetchFn: mockFetch as any, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.spotify.com/v1/test/endpoint', + { + method: 'GET', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }, + body: null, + } + ); + }); + + it('should refresh the token if it is expired', async () => { + const mockFetch = vi.fn(() => Promise.resolve({ ok: true })); + const expiredTime = new Date(Date.now() - 3600 * 1000 - 1000); // 3600秒(1時間)+ 1秒前 + + const store = new MockedStore({ + access_token: 'expired_token', + refresh_token: 'refresh_token', + expires_in: 3600, // トークンの有効期間は1時間 + updatedAt: new Date(expiredTime), + }); + + const mockRefresh = vi + .fn() + .mockImplementation(async ({ store, guildId }) => { + // トークンを更新する + store.putSpotifyTokenByGuildId({ + guildId, + token: { + access_token: 'new_token', + refresh_token: 'new_refresh_token', + expires_in: 3600, + updatedAt: new Date(Date.now()), + }, + }); + }); + + await spotifyFetcher('test/endpoint', { + store: store as any, + guildId: '123', + method: 'GET', + fetchFn: mockFetch as any, + refreshFn: mockRefresh as any, + }); + + expect(mockRefresh).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.spotify.com/v1/test/endpoint', + { + method: 'GET', + headers: { + Authorization: 'Bearer new_token', + 'Content-Type': 'application/json', + }, + body: null, + } + ); + }); + + it('should throw an error if authentication is required', async () => { + const mockFetch = vi.fn(() => Promise.resolve({ ok: true })); + const store = new MockedStore(undefined); + + await expect( + spotifyFetcher('test/endpoint', { + store: store as any, + guildId: '123', + method: 'GET', + fetchFn: mockFetch as any, + }) + ).rejects.toThrow('Authentication is required.'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2179b148..f2d284b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { Ping, VoiceChannel, YouTube, - // getSpotifyInstance, + getSpotifyInstance, } from './features/commands/index.js'; import { getConfig } from './features/config/index.js'; import { Client } from './features/core/index.js'; @@ -16,6 +16,6 @@ new Client({ new VoiceChannel(), new YouTube(), // TODO: シングルトンやめる - // getSpotifyInstance(), + getSpotifyInstance(), ], });