Skip to content

Commit

Permalink
Storeに切り出しとfetcher整備
Browse files Browse the repository at this point in the history
  • Loading branch information
na2na-p committed Jan 9, 2024
1 parent 88745fc commit 1c3c4e9
Show file tree
Hide file tree
Showing 15 changed files with 328 additions and 55 deletions.
74 changes: 32 additions & 42 deletions src/features/commands/internal/Spotify/internal/Spotify.class.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<void> {
const store = getStoreInstance();
const guild = getGuildFromInteraction({ interaction });

switch (interaction.options.getSubcommand()) {
case SpotifyAuthCommandOptions.url.name:
const authUrl = getAuthUrl({
Expand All @@ -42,37 +38,40 @@ 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.',
ephemeral: true,
});
return;
}
await this.pauseSpotifyTrack({ token });
await this.pauseSpotifyTrack({ guildId: guild.id });
interaction.reply({
content: 'Stopped!',
ephemeral: true,
});
break;
}
default:
throw new Error('Unexpected subcommand');
}
Expand All @@ -82,26 +81,17 @@ export class Spotify extends CommandBase {
* TODO あとで切り出すか消す
*/
async pauseSpotifyTrack({
token,
guildId,
}: {
token: SpotifyTokenResponse;
guildId: Guild['id'];
}): Promise<void> {
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.');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
export { getAuthUrl } from './internal/getAuthUrl/index.js';
export {
fetchSpotifyToken,
type SpotifyTokenResponse,
GRANT_TYPE,
} from './internal/fetchSpotifyToken/index.js';
5 changes: 5 additions & 0 deletions src/features/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
9 changes: 9 additions & 0 deletions src/features/core/internal/Store/index.ts
Original file line number Diff line number Diff line change
@@ -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);
31 changes: 31 additions & 0 deletions src/features/core/internal/Store/internal/Store.class.ts
Original file line number Diff line number Diff line change
@@ -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];
}
}
5 changes: 5 additions & 0 deletions src/features/core/internal/Store/internal/Store.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { SpotifyTokenResponse } from '@/features/others/spotify/index.js';

export type SpotifyToken = SpotifyTokenResponse & {
updatedAt: Date;
};
6 changes: 6 additions & 0 deletions src/features/others/spotify/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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 =
| {
Expand All @@ -11,6 +12,7 @@ type FetchSpotifyTokenParams =
spotifyClientId: string;
spotifyClientSecret: string;
spotifyCallbackUrl: string;
fetchFn?: typeof fetch;
}
| {
type: typeof GRANT_TYPE.RefreshToken;
Expand All @@ -20,11 +22,16 @@ type FetchSpotifyTokenParams =
code: string;
spotifyClientId: string;
spotifyClientSecret: string;
fetchFn?: typeof fetch;
};

export const fetchSpotifyToken = async (
params: FetchSpotifyTokenParams
): Promise<SpotifyTokenResponse> => {
/**
* TODO: エラーハンドリング
*/
export const fetchSpotifyToken = async ({
fetchFn = fetch,
...params
}: FetchSpotifyTokenParams): Promise<SpotifyTokenResponse> => {
const urlParams = new URLSearchParams();
urlParams.append('grant_type', params.type);
urlParams.append('code', params.code);
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { spotifyFetcher } from './internal/spotifyFetcher.func.js';
Original file line number Diff line number Diff line change
@@ -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<Response>;

/**
* 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<typeof getConfig>;
}): Promise<void> => {
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 });
};
Loading

0 comments on commit 1c3c4e9

Please sign in to comment.