From 17e0480f8347d1d935f51f539e3c070eab45b6cb Mon Sep 17 00:00:00 2001 From: Kaushik Narayan R Date: Thu, 13 Mar 2025 15:15:43 -0700 Subject: [PATCH] improved API request wrapper, partial completion of operations --- api/spotify.ts | 276 +++++++++++----------- config/dotenv.ts | 4 +- controllers/auth.ts | 22 +- controllers/operations.ts | 137 ++++++----- controllers/playlists.ts | 18 +- index.ts | 8 +- middleware/authCheck.ts | 4 +- types/spotify_manager/endpoints.types.ts | 94 ++++---- types/spotify_manager/shorthands.types.ts | 2 + utils/flake.ts | 6 +- utils/generateRandString.ts | 4 +- utils/graph.ts | 7 +- utils/jsonTransformer.ts | 7 +- utils/logger.ts | 6 +- utils/spotifyUriTransformer.ts | 2 +- validators/index.ts | 5 +- 16 files changed, 320 insertions(+), 282 deletions(-) diff --git a/api/spotify.ts b/api/spotify.ts index b956064..bf5386d 100644 --- a/api/spotify.ts +++ b/api/spotify.ts @@ -6,13 +6,14 @@ import { type RawAxiosRequestHeaders, } from "axios"; import type { - AddItemsToPlaylist, + AddItemsToPlaylistData, EndpointHandlerBaseArgs, - GetCurrentUsersPlaylists, - GetCurrentUsersProfile, - GetPlaylist, - GetPlaylistItems, - RemovePlaylistItems, + EndpointHandlerWithResArgs, + GetCurrentUsersPlaylistsData, + GetCurrentUsersProfileData, + GetPlaylistData, + GetPlaylistItemsData, + RemovePlaylistItemsData, Res, } from "spotify_manager/index.d.ts"; @@ -26,25 +27,41 @@ enum allowedMethods { Delete = "delete", } +type SingleRequestArgs = { + /** Express response object. If set, send error responses from handler itself */ + res?: Res; + /** mainly the `Authorization` header, could be extended later to account for custom headers, maybe rate-limiting stuff? */ + authHeaders: RawAxiosRequestHeaders; + /** HTTP method */ + method?: allowedMethods; + /** relative request path (from `/api/v1`) */ + path: string; + /** request params, headers, etc. */ + config?: AxiosRequestConfig; + /** request body */ + data?: any; + /** true if `data` is to be placed inside config (say, axios' delete method) */ + inlineData?: boolean; +}; + +type SingleRequestResult = Promise<{ + resp?: AxiosResponse; + error?: any; + message: string; +}>; + /** - * Spotify API - one-off request handler - * @param req convenient auto-placing headers from middleware (not a good approach?) - * @param res handle failure responses here itself (not a good approach?) - * @param method HTTP method - * @param path request path - * @param config request params, headers, etc. - * @param data request body - * @param inlineData true if `data` is to be placed inside config (say, axios' delete method) + * Spotify API (v1) - one-off request handler */ -const singleRequest = async ( - authHeaders: RawAxiosRequestHeaders, - res: Res, - method: allowedMethods, - path: string, - config: AxiosRequestConfig = {}, - data: any = null, - inlineData: boolean = false -): Promise | null> => { +const singleRequest = async ({ + res, + authHeaders, + method = allowedMethods.Get, + path, + config = {}, + data = null, + inlineData = false, +}: SingleRequestArgs): SingleRequestResult => { let resp: AxiosResponse; config.headers = { ...config.headers, ...authHeaders }; try { @@ -55,160 +72,138 @@ const singleRequest = async ( resp = await axiosInstance[method](path, data, config); } logger.debug(logPrefix + "Successful response received."); - return resp; + return { resp, message: "" }; } catch (error: any) { + let message = logPrefix; if (error.response) { // Non 2XX response received - let logMsg; - if (error.response.status >= 400 && error.response.status < 600) { - res.status(error.response.status).send(error.response.data); - logMsg = "" + error.response.status; - } else { - res.sendStatus(error.response.status); - logMsg = "???"; - } - logger.warn(logPrefix + logMsg, { + message = message.concat( + `${error.response.status} - ${error.response.data?.message}` + ); + res?.status(error.response.status).send(error.response.data); + logger.warn(message, { response: { data: error.response.data, status: error.response.status, }, }); + return { error, message }; } else if (error.request) { - // No response received - res.status(504).send({ message: "No response from Spotify" }); - logger.error(logPrefix + "No response", { error }); + // Request sent, but no response received + message = message.concat("No response"); + res?.status(504).send({ message }); + logger.error(message, { error }); + return { error, message }; } else { // Something happened in setting up the request that triggered an Error - res.status(500).send({ message: "Internal Server Error" }); - logger.error(logPrefix + "Request failed?", { error }); + message = message.concat("Request failed"); + res?.status(500).send({ message: "Internal Server Error" }); + logger.error(message, { error }); + return { error, message }; } - - return null; } }; -interface GetCurrentUsersProfileArgs extends EndpointHandlerBaseArgs {} +interface GetCurrentUsersProfileArgs extends EndpointHandlerWithResArgs {} +type GetCurrentUsersProfile = SingleRequestResult; const getCurrentUsersProfile: ( opts: GetCurrentUsersProfileArgs -) => Promise = async ({ authHeaders, res }) => { - const response = await singleRequest( - authHeaders, +) => GetCurrentUsersProfile = async ({ res, authHeaders }) => { + return await singleRequest({ res, - allowedMethods.Get, - "/me" - ); - return response ? response.data : null; + authHeaders, + path: "/me", + }); }; interface GetCurrentUsersPlaylistsFirstPageArgs - extends EndpointHandlerBaseArgs {} + extends EndpointHandlerWithResArgs {} +type GetCurrentUsersPlaylists = + SingleRequestResult; const getCurrentUsersPlaylistsFirstPage: ( opts: GetCurrentUsersPlaylistsFirstPageArgs -) => Promise = async ({ - authHeaders, - res, -}) => { - const response = await singleRequest( - authHeaders, +) => GetCurrentUsersPlaylists = async ({ res, authHeaders }) => { + return await singleRequest({ res, - allowedMethods.Get, - `/me/playlists`, - { + authHeaders, + path: `/me/playlists`, + config: { params: { offset: 0, limit: 50, }, - } - ); - return response?.data ?? null; + }, + }); }; -interface GetCurrentUsersPlaylistsNextPageArgs extends EndpointHandlerBaseArgs { +interface GetCurrentUsersPlaylistsNextPageArgs + extends EndpointHandlerWithResArgs { nextURL: string; } const getCurrentUsersPlaylistsNextPage: ( opts: GetCurrentUsersPlaylistsNextPageArgs -) => Promise = async ({ - authHeaders, - res, - nextURL, -}) => { - const response = await singleRequest( - authHeaders, +) => GetCurrentUsersPlaylists = async ({ res, authHeaders, nextURL }) => { + return await singleRequest({ res, - allowedMethods.Get, - nextURL - ); - return response?.data ?? null; + authHeaders, + path: nextURL, + }); }; -interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs { +interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerWithResArgs { initialFields: string; playlistID: string; } +type GetPlaylistDetailsFirstPage = SingleRequestResult; const getPlaylistDetailsFirstPage: ( opts: GetPlaylistDetailsFirstPageArgs -) => Promise = async ({ - authHeaders, +) => GetPlaylistDetailsFirstPage = async ({ res, + authHeaders, initialFields, playlistID, }) => { - const response = await singleRequest( + return await singleRequest({ authHeaders, res, - allowedMethods.Get, - `/playlists/${playlistID}/`, - { + path: `/playlists/${playlistID}/`, + config: { params: { fields: initialFields, }, - } - ); - return response?.data ?? null; + }, + }); }; -interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerBaseArgs { +interface GetPlaylistDetailsNextPageArgs extends EndpointHandlerWithResArgs { nextURL: string; } +type GetPlaylistItems = SingleRequestResult; const getPlaylistDetailsNextPage: ( opts: GetPlaylistDetailsNextPageArgs -) => Promise = async ({ - authHeaders, - res, - nextURL, -}) => { - const response = await singleRequest( - authHeaders, +) => GetPlaylistItems = async ({ res, authHeaders, nextURL }) => { + return await singleRequest({ res, - allowedMethods.Get, - nextURL - ); - return response?.data ?? null; + authHeaders, + path: nextURL, + }); }; interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs { nextBatch: string[]; playlistID: string; } +type AddItemsToPlaylist = SingleRequestResult; const addItemsToPlaylist: ( opts: AddItemsToPlaylistArgs -) => Promise = async ({ - authHeaders, - res, - nextBatch, - playlistID, -}) => { - const response = await singleRequest( +) => AddItemsToPlaylist = async ({ authHeaders, nextBatch, playlistID }) => { + return await singleRequest({ authHeaders, - res, - allowedMethods.Post, - `/playlists/${playlistID}/tracks`, - {}, - { uris: nextBatch }, - false - ); - return response?.data ?? null; + method: allowedMethods.Post, + path: `/playlists/${playlistID}/tracks`, + data: { uris: nextBatch }, + inlineData: false, + }); }; interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs { @@ -216,66 +211,67 @@ interface RemovePlaylistItemsArgs extends EndpointHandlerBaseArgs { playlistID: string; snapshotID: string; } +type RemovePlaylistItems = SingleRequestResult; const removePlaylistItems: ( opts: RemovePlaylistItemsArgs -) => Promise = async ({ +) => RemovePlaylistItems = async ({ authHeaders, - res, nextBatch, playlistID, snapshotID, }) => { // API doesn't document this kind of deletion via the 'positions' field - // but see here: https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801 - const response = await singleRequest( + // but see here: https://web.archive.org/web/20250313173723/https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801 + return await singleRequest({ authHeaders, - res, - allowedMethods.Delete, - `/playlists/${playlistID}/tracks`, - {}, + method: allowedMethods.Delete, + path: `/playlists/${playlistID}/tracks`, // axios delete method doesn't have separate arg for body so hv to put it in config - { positions: nextBatch, snapshot_id: snapshotID }, - true - ); - return response?.data ?? null; + data: { positions: nextBatch, snapshot_id: snapshotID }, + inlineData: true, + }); }; // --------- // non-endpoints, i.e. convenience wrappers // --------- -interface CheckPlaylistEditableArgs extends EndpointHandlerBaseArgs { +interface CheckPlaylistEditableArgs extends EndpointHandlerWithResArgs { playlistID: string; userID: string; } +type CheckPlaylistEditable = Promise<{ + status: boolean; + error?: any; + message: string; +}>; const checkPlaylistEditable: ( opts: CheckPlaylistEditableArgs -) => Promise = async ({ authHeaders, res, playlistID, userID }) => { +) => CheckPlaylistEditable = async ({ + res, + authHeaders, + playlistID, + userID, +}) => { let checkFields = ["collaborative", "owner(id)"]; - - const checkFromData = await getPlaylistDetailsFirstPage({ - authHeaders, + const { resp, error, message } = await getPlaylistDetailsFirstPage({ res, + authHeaders, initialFields: checkFields.join(), playlistID, }); - if (!checkFromData) return false; + if (!resp) return { status: false, error, message }; // https://web.archive.org/web/20241226081630/https://developer.spotify.com/documentation/web-api/concepts/playlists#:~:text=A%20playlist%20can%20also%20be%20made%20collaborative // playlist is editable if it's collaborative (and thus private) or owned by the user - if ( - checkFromData.collaborative !== true && - checkFromData.owner?.id !== userID - ) { - res.status(403).send({ - message: - "You cannot edit this playlist, you must be the owner/the playlist must be collaborative", - playlistID, - }); - logger.info("user cannot edit target playlist", { playlistID }); - return false; + if (resp.data.collaborative !== true && resp.data.owner.id !== userID) { + return { + status: false, + error: { playlistID, playlistName: resp.data.name }, + message: "Cannot edit playlist: " + resp.data.name, + }; } else { - return true; + return { status: true, message: "" }; } }; diff --git a/config/dotenv.ts b/config/dotenv.ts index de90ac8..a5eccf7 100644 --- a/config/dotenv.ts +++ b/config/dotenv.ts @@ -1,6 +1,6 @@ // https://github.com/motdotla/dotenv/issues/133#issuecomment-255298822 -// explanation: ESM import statements execute first -// so the .config gets called after all other imports in index.ts +// explanation: in ESM, import statements execute first, unlike CJS where it's line order +// so if placed directly in index.ts, the .config gets called after all other imports in index.ts // and one of those imports is the sequelize loader, which depends on env being loaded // soln: raise the priority of dotenv to match by placing it in a separate module like this diff --git a/controllers/auth.ts b/controllers/auth.ts index 9801209..1001720 100644 --- a/controllers/auth.ts +++ b/controllers/auth.ts @@ -21,14 +21,12 @@ const login: RequestHandler = async (_req, res) => { const state = generateRandString(16); res.cookie(stateKey, state); - const scope = Object.values(requiredScopes).join(" "); - res.redirect( `${accountsAPIURL}/authorize?` + new URLSearchParams({ response_type: "code", client_id: process.env["CLIENT_ID"], - scope: scope, + scope: Object.values(requiredScopes).join(" "), redirect_uri: process.env["REDIRECT_URI"], state: state, } as Record).toString() @@ -88,17 +86,20 @@ const callback: RequestHandler = async (req, res) => { return null; } - const userData = await getCurrentUsersProfile({ authHeaders, res }); - if (!userData) return null; + const { resp } = await getCurrentUsersProfile({ + res, + authHeaders, + }); + if (!resp) return null; req.session.user = { - username: userData.display_name ?? "", - id: userData.id, + username: resp.data.display_name ?? "", + id: resp.data.id, }; // res.status(200).send({ message: "OK" }); res.redirect(process.env["APP_URI"] + "?login=success"); - logger.debug("New login.", { username: userData.display_name }); + logger.debug("New login.", { username: resp.data.display_name }); return null; } } catch (error) { @@ -141,7 +142,10 @@ const refresh: RequestHandler = async (req, res) => { res .status(response.status) .send({ message: "Error: Refresh token flow failed." }); - logger.error("refresh failed", { statusCode: response.status }); + logger.error("refresh failed", { + statusCode: response.status, + data: response.data, + }); return null; } } catch (error) { diff --git a/controllers/operations.ts b/controllers/operations.ts index b182713..e257b8a 100644 --- a/controllers/operations.ts +++ b/controllers/operations.ts @@ -12,7 +12,7 @@ import { import type { RequestHandler } from "express"; import type { - EndpointHandlerBaseArgs, + EndpointHandlerWithResArgs, LinkModel_Edge, PlaylistModel_Pl, URIObject, @@ -47,11 +47,12 @@ const updateUser: RequestHandler = async (req, res) => { let currentPlaylists: PlaylistModel_Pl[] = []; // get first 50 - const respData = await getCurrentUsersPlaylistsFirstPage({ + const { resp } = await getCurrentUsersPlaylistsFirstPage({ authHeaders, res, }); - if (!respData) return null; + if (!resp) return null; + const respData = resp.data; currentPlaylists = respData.items.map((playlist) => { return { @@ -63,12 +64,13 @@ const updateUser: RequestHandler = async (req, res) => { // keep getting batches of 50 till exhausted while (nextURL) { - const nextData = await getCurrentUsersPlaylistsNextPage({ + const { resp } = await getCurrentUsersPlaylistsNextPage({ authHeaders, res, nextURL, }); - if (!nextData) return null; + if (!resp) return null; + const nextData = resp.data; currentPlaylists.push( ...nextData.items.map((playlist) => { @@ -411,7 +413,7 @@ const removeLink: RequestHandler = async (req, res) => { } }; -interface _GetPlaylistTracksArgs extends EndpointHandlerBaseArgs { +interface _GetPlaylistTracksArgs extends EndpointHandlerWithResArgs { playlistID: string; } interface _GetPlaylistTracks { @@ -431,13 +433,14 @@ const _getPlaylistTracks: ( let initialFields = ["snapshot_id,tracks(next,items(is_local,track(uri)))"]; let mainFields = ["next", "items(is_local,track(uri))"]; - const respData = await getPlaylistDetailsFirstPage({ + const { resp } = await getPlaylistDetailsFirstPage({ authHeaders, res, initialFields: initialFields.join(), playlistID, }); - if (!respData) return null; + if (!resp) return null; + const respData = resp.data; // check cache const cachedSnapshotID = await redisClient.get( @@ -470,12 +473,13 @@ const _getPlaylistTracks: ( // keep getting batches of 50 till exhausted while (nextURL) { - const nextData = await getPlaylistDetailsNextPage({ + const { resp } = await getPlaylistDetailsNextPage({ authHeaders, res, nextURL, }); - if (!nextData) return null; + if (!resp) return null; + const nextData = resp.data; pl.tracks.push( ...nextData.items.map((playlist_item) => { @@ -499,7 +503,7 @@ const _getPlaylistTracks: ( return pl; }; -interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs { +interface _PopulateSingleLinkCoreArgs extends EndpointHandlerWithResArgs { link: { from: URIObject; to: URIObject; @@ -522,29 +526,29 @@ interface _PopulateSingleLinkCoreArgs extends EndpointHandlerBaseArgs { * * CANNOT populate local files; Spotify API does not support it yet. */ -const _populateSingleLinkCore: ( - opts: _PopulateSingleLinkCoreArgs -) => Promise<{ toAddNum: number; localNum: number } | null> = async ({ - authHeaders, - res, - link, -}) => { +const _populateSingleLinkCore: (opts: _PopulateSingleLinkCoreArgs) => Promise<{ + toAddNum: number; + addedNum: number; + localNum: number; +} | null> = async ({ res, authHeaders, link }) => { try { const fromPl = link.from, toPl = link.to; const fromPlaylist = await _getPlaylistTracks({ - authHeaders, res, + authHeaders, playlistID: fromPl.id, }); + if (!fromPlaylist) return null; + const toPlaylist = await _getPlaylistTracks({ - authHeaders, res, + authHeaders, playlistID: toPl.id, }); + if (!toPlaylist) return null; - if (!fromPlaylist || !toPlaylist) return null; const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri); let toTrackURIs = toPlaylist.tracks .filter((track) => !track.is_local) // API doesn't support adding local files to playlists yet @@ -553,20 +557,21 @@ const _populateSingleLinkCore: ( const toAddNum = toTrackURIs.length; const localNum = toPlaylist.tracks.filter((track) => track.is_local).length; + let addedNum = 0; // append to end in batches of 100 while (toTrackURIs.length > 0) { const nextBatch = toTrackURIs.splice(0, 100); - const addData = await addItemsToPlaylist({ + const { resp } = await addItemsToPlaylist({ authHeaders, - res, nextBatch, playlistID: fromPl.id, }); - if (!addData) return null; + if (!resp) break; + addedNum += nextBatch.length; } - return { toAddNum, localNum }; + return { toAddNum, addedNum, localNum }; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("_populateSingleLinkCore", { error }); @@ -612,12 +617,14 @@ const populateSingleLink: RequestHandler = async (req, res) => { } if ( - !(await checkPlaylistEditable({ - authHeaders, - res, - playlistID: fromPl.id, - userID: uID, - })) + !( + await checkPlaylistEditable({ + authHeaders, + res, + playlistID: fromPl.id, + userID: uID, + }) + ).status ) return null; @@ -627,15 +634,18 @@ const populateSingleLink: RequestHandler = async (req, res) => { link: { from: fromPl, to: toPl }, }); if (result) { - const { toAddNum, localNum } = result; - let logMsg; - logMsg = - toAddNum > 0 ? "Added " + toAddNum + " tracks" : "No tracks to add"; - logMsg += - localNum > 0 ? "; could not process " + localNum + " local files" : "."; + const { toAddNum, addedNum, localNum } = result; + let message; + message = + toAddNum > 0 ? "Added " + addedNum + " tracks" : "No tracks to add"; + message += + addedNum < toAddNum + ? ", failed to add " + (toAddNum - addedNum) + " tracks" + : ""; + message += localNum > 0 ? ", skipped " + localNum + " local files" : "."; - res.status(200).send({ message: logMsg }); - logger.debug(logMsg, { toAddNum, localNum }); + res.status(200).send({ message, toAddNum, addedNum, localNum }); + logger.debug(message, { toAddNum, localNum }); } return null; } catch (error) { @@ -645,7 +655,7 @@ const populateSingleLink: RequestHandler = async (req, res) => { } }; -interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs { +interface _PruneSingleLinkCoreArgs extends EndpointHandlerWithResArgs { link: { from: URIObject; to: URIObject }; } /** @@ -665,7 +675,7 @@ interface _PruneSingleLinkCoreArgs extends EndpointHandlerBaseArgs { */ const _pruneSingleLinkCore: ( opts: _PruneSingleLinkCoreArgs -) => Promise<{ toDelNum: number } | null> = async ({ +) => Promise<{ toDelNum: number; deletedNum: number } | null> = async ({ authHeaders, res, link, @@ -679,13 +689,15 @@ const _pruneSingleLinkCore: ( res, playlistID: fromPl.id, }); + if (!fromPlaylist) return null; + const toPlaylist = await _getPlaylistTracks({ authHeaders, res, playlistID: toPl.id, }); + if (!toPlaylist) return null; - if (!fromPlaylist || !toPlaylist) return null; const fromTrackURIs = fromPlaylist.tracks.map((track) => track.uri); const indexedToTrackURIs = toPlaylist.tracks.map((track, index) => { return { ...track, position: index }; @@ -696,23 +708,24 @@ const _pruneSingleLinkCore: ( .map((track) => track.position); // get track positions const toDelNum = indexes.length; + let deletedNum = 0; // remove in batches of 100 (from reverse, to preserve positions while modifying) let currentSnapshot = toPlaylist.snapshotID; while (indexes.length > 0) { const nextBatch = indexes.splice(Math.max(indexes.length - 100, 0), 100); - const delResponse = await removePlaylistItems({ + const { resp } = await removePlaylistItems({ authHeaders, - res, nextBatch, playlistID: toPl.id, snapshotID: currentSnapshot, }); - if (!delResponse) return null; - currentSnapshot = delResponse.snapshot_id; + if (!resp) break; + deletedNum += nextBatch.length; + currentSnapshot = resp.data.snapshot_id; } - return { toDelNum }; + return { toDelNum, deletedNum }; } catch (error) { res.status(500).send({ message: "Internal Server Error" }); logger.error("_pruneSingleLinkCore", { error }); @@ -758,12 +771,14 @@ const pruneSingleLink: RequestHandler = async (req, res) => { } if ( - !(await checkPlaylistEditable({ - authHeaders, - res, - playlistID: toPl.id, - userID: uID, - })) + !( + await checkPlaylistEditable({ + authHeaders, + res, + playlistID: toPl.id, + userID: uID, + }) + ).status ) return null; @@ -776,9 +791,19 @@ const pruneSingleLink: RequestHandler = async (req, res) => { }, }); if (result) { - const { toDelNum } = result; - res.status(200).send({ message: `Removed ${toDelNum} tracks.` }); - logger.debug(`Pruned ${toDelNum} tracks`, { toDelNum }); + const { toDelNum, deletedNum } = result; + let message; + message = + toDelNum > 0 + ? "Removed " + deletedNum + " tracks" + : "No tracks to remove"; + message += + deletedNum < toDelNum + ? ", failed to remove " + (toDelNum - deletedNum) + " tracks" + : "."; + + res.status(200).send({ message, toDelNum, deletedNum }); + logger.debug(message, { toDelNum, deletedNum }); } return null; } catch (error) { diff --git a/controllers/playlists.ts b/controllers/playlists.ts index fc3bf82..35aa932 100644 --- a/controllers/playlists.ts +++ b/controllers/playlists.ts @@ -20,29 +20,29 @@ const fetchUserPlaylists: RequestHandler = async (req, res) => { if (!authHeaders) throw new ReferenceError("session does not have auth headers"); // get first 50 - const respData = await getCurrentUsersPlaylistsFirstPage({ - authHeaders, + const { resp } = await getCurrentUsersPlaylistsFirstPage({ res, + authHeaders, }); - if (!respData) return null; + if (!resp) return null; - let tmpData = structuredClone(respData); const userPlaylists: Pick< Pagination, "items" | "total" > = { - items: [...tmpData.items], - total: tmpData.total, + items: [...resp.data.items], + total: resp.data.total, }; - let nextURL = respData.next; + let nextURL = resp.data.next; // keep getting batches of 50 till exhausted while (nextURL) { - const nextData = await getCurrentUsersPlaylistsNextPage({ + const { resp } = await getCurrentUsersPlaylistsNextPage({ authHeaders, res, nextURL, }); - if (!nextData) return null; + if (!resp) return null; + const nextData = resp.data; userPlaylists.items.push(...nextData.items); nextURL = nextData.next; diff --git a/index.ts b/index.ts index 67c4951..744c912 100644 --- a/index.ts +++ b/index.ts @@ -94,8 +94,8 @@ app.use("/auth-health", isAuthenticated, async (req, res) => { const { authHeaders } = req.session; if (!authHeaders) throw new ReferenceError("session does not have auth headers"); - const respData = await getCurrentUsersProfile({ authHeaders, res }); - if (!respData) return null; + const { resp } = await getCurrentUsersProfile({ authHeaders, res }); + if (!resp) return null; res.status(200).send({ message: "OK" }); return null; } catch (error) { @@ -130,9 +130,7 @@ const server = app.listen(port, () => { const cleanupFunc = (signal?: string) => { if (signal) logger.debug(`${signal} signal received, shutting down now...`); - Promise.allSettled([ - promisify(server.close), - ]).then(() => { + Promise.allSettled([promisify(server.close)]).then(() => { logger.info("Cleaned up, exiting."); process.exit(0); }); diff --git a/middleware/authCheck.ts b/middleware/authCheck.ts index 81db7f1..25e2d9f 100644 --- a/middleware/authCheck.ts +++ b/middleware/authCheck.ts @@ -4,7 +4,7 @@ import { sessionName } from "../constants.ts"; import logger from "../utils/logger.ts"; -export const isAuthenticated: RequestHandler = (req, res, next) => { +const isAuthenticated: RequestHandler = (req, res, next) => { if (req.session.accessToken) { req.session.authHeaders = { Authorization: `Bearer ${req.session.accessToken}`, @@ -27,3 +27,5 @@ export const isAuthenticated: RequestHandler = (req, res, next) => { }); } }; + +export { isAuthenticated }; diff --git a/types/spotify_manager/endpoints.types.ts b/types/spotify_manager/endpoints.types.ts index 2b713ad..e83f342 100644 --- a/types/spotify_manager/endpoints.types.ts +++ b/types/spotify_manager/endpoints.types.ts @@ -32,52 +32,54 @@ import type { // GET method // Albums -export type GetAlbum = AlbumObject; -export type GetSeveralAlbums = { albums: AlbumObject[] }; -export type GetAlbumTracks = Pagination; -export type GetUsersSavedAlbums = Pagination; -export type CheckUsersSavedAlbums = boolean[]; -export type GetNewReleases = { albums: Pagination }; +export type GetAlbumData = AlbumObject; +export type GetSeveralAlbumsData = { albums: AlbumObject[] }; +export type GetAlbumTracksData = Pagination; +export type GetUsersSavedAlbumsData = Pagination; +export type CheckUsersSavedAlbumsData = boolean[]; +export type GetNewReleasesData = { albums: Pagination }; // Artists -export type GetArtist = ArtistObject; -export type GetSeveralArtists = { artists: ArtistObject[] }; -export type GetArtistsAlbums = Pagination; -export type GetArtistsTopTracks = { tracks: TrackObject[] }; +export type GetArtistData = ArtistObject; +export type GetSeveralArtistsData = { artists: ArtistObject[] }; +export type GetArtistsAlbumsData = Pagination; +export type GetArtistsTopTracksData = { tracks: TrackObject[] }; // Episodes -export type GetEpisode = EpisodeObject; -export type GetSeveralEpisodes = { episodes: EpisodeObject[] }; -export type GetUsersSavedEpisodes = Pagination; +export type GetEpisodeData = EpisodeObject; +export type GetSeveralEpisodesData = { episodes: EpisodeObject[] }; +export type GetUsersSavedEpisodesData = Pagination; // Shows -export type GetShow = ShowObject; -export type GetSeveralShows = { shows: SimplifiedShowObject[] }; -export type GetShowEpisodes = Pagination; -export type GetUsersSavedShows = Pagination; +export type GetShowData = ShowObject; +export type GetSeveralShowsData = { shows: SimplifiedShowObject[] }; +export type GetShowEpisodesData = Pagination; +export type GetUsersSavedShowsData = Pagination; // Playlists -export type GetPlaylist = PlaylistObject; -export type GetPlaylistItems = Pagination; -export type GetCurrentUsersPlaylists = Pagination; -export type GetUsersPlaylists = GetCurrentUsersPlaylists; -export type GetPlaylistCoverImage = ImageObject[]; +export type GetPlaylistData = PlaylistObject; +export type GetPlaylistItemsData = Pagination; +export type GetCurrentUsersPlaylistsData = Pagination; +export type GetUsersPlaylistsData = Pagination; +export type GetPlaylistCoverImageData = ImageObject[]; // Tracks -export type GetTrack = TrackObject; -export type GetSeveralTracks = { tracks: TrackObject[] }; -export type GetUsersSavedTracks = Pagination; -export type CheckUsersSavedTracks = boolean[]; +export type GetTrackData = TrackObject; +export type GetSeveralTracksData = { tracks: TrackObject[] }; +export type GetUsersSavedTracksData = Pagination; +export type CheckUsersSavedTracksData = boolean[]; // Users -export type GetCurrentUsersProfile = UserObject; -export type GetUsersTopItems = +export type GetCurrentUsersProfileData = UserObject; +export type GetUsersTopItemsData = | Pagination | Pagination; -export type GetUsersProfile = SimplifiedUserObject; -export type GetFollowedArtists = { artists: PaginationByCursor }; -export type CheckIfUserFollowsArtistsOrNot = boolean[]; -export type CheckIfCurrentUserFollowsPlaylist = boolean[]; +export type GetUsersProfileData = SimplifiedUserObject; +export type GetFollowedArtistsData = { + artists: PaginationByCursor; +}; +export type CheckIfUserFollowsArtistsOrNotData = boolean[]; +export type CheckIfCurrentUserFollowsPlaylistData = boolean[]; // POST method // Albums @@ -86,48 +88,48 @@ export type CheckIfCurrentUserFollowsPlaylist = boolean[]; // Shows // Playlists -export type AddItemsToPlaylist = { snapshot_id: string }; -export type CreatePlaylist = PlaylistObject; +export type AddItemsToPlaylistData = { snapshot_id: string }; +export type CreatePlaylistData = PlaylistObject; // Tracks // Users // PUT method // Albums -export type SaveAlbumsForCurrentUser = {}; +export type SaveAlbumsForCurrentUserData = {}; // Artists // Episodes // Shows // Playlists -export type ChangePlaylistDetails = {}; -export type UpdatePlaylistItems = { snapshot_id: string }; -export type AddCustomPlaylistCoverImage = {}; +export type ChangePlaylistDetailsData = {}; +export type UpdatePlaylistItemsData = { snapshot_id: string }; +export type AddCustomPlaylistCoverImageData = {}; // Tracks -export type SaveTracksForCurrentUser = {}; +export type SaveTracksForCurrentUserData = {}; // Users -export type FollowPlaylist = {}; -export type FollowArtistsOrUsers = {}; +export type FollowPlaylistData = {}; +export type FollowArtistsOrUsersData = {}; // DELETE method // Albums -export type RemoveUsersSavedAlbums = {}; +export type RemoveUsersSavedAlbumsData = {}; // Artists // Episodes // Shows // Playlists -export type RemovePlaylistItems = { snapshot_id: string }; +export type RemovePlaylistItemsData = { snapshot_id: string }; // Tracks -export type RemoveUsersSavedTracks = {}; +export type RemoveUsersSavedTracksData = {}; // Users -export type UnfollowPlaylist = {}; -export type UnfollowArtistsOrUsers = {}; +export type UnfollowPlaylistData = {}; +export type UnfollowArtistsOrUsersData = {}; // method // Albums diff --git a/types/spotify_manager/shorthands.types.ts b/types/spotify_manager/shorthands.types.ts index df7a8f9..e285de6 100644 --- a/types/spotify_manager/shorthands.types.ts +++ b/types/spotify_manager/shorthands.types.ts @@ -7,5 +7,7 @@ export type Next = NextFunction; export interface EndpointHandlerBaseArgs { authHeaders: RawAxiosRequestHeaders; +} +export interface EndpointHandlerWithResArgs extends EndpointHandlerBaseArgs { res: Res; } diff --git a/utils/flake.ts b/utils/flake.ts index 369230d..1f91fe8 100644 --- a/utils/flake.ts +++ b/utils/flake.ts @@ -1,5 +1,7 @@ -export const sleep = (ms: number): Promise => +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -export const randomBool = (chance_of_failure = 0.25): boolean => +const randomBool = (chance_of_failure = 0.25): boolean => Math.random() < chance_of_failure; + +export { sleep, randomBool }; diff --git a/utils/generateRandString.ts b/utils/generateRandString.ts index ef139ac..fd14cac 100644 --- a/utils/generateRandString.ts +++ b/utils/generateRandString.ts @@ -1,7 +1,7 @@ /** * Generates a random string containing numbers and letters */ -export const generateRandString = (length: number): string => { +const generateRandString = (length: number): string => { const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let text = ""; @@ -11,3 +11,5 @@ export const generateRandString = (length: number): string => { } return text; }; + +export { generateRandString }; diff --git a/utils/graph.ts b/utils/graph.ts index 00dfcd0..ca02d8f 100644 --- a/utils/graph.ts +++ b/utils/graph.ts @@ -1,5 +1,5 @@ -export type GNode = string; -export type GEdge = { from: string; to: string }; +type GNode = string; +type GEdge = { from: string; to: string }; /** * Directed graph, may or may not be connected. @@ -20,7 +20,7 @@ export type GEdge = { from: string; to: string }; * console.log(g.detectCycle()); // true * ``` */ -export class myGraph { +class myGraph { nodes: GNode[]; edges: GEdge[]; /** @@ -135,4 +135,5 @@ export class myGraph { } } +export { type GNode, type GEdge, myGraph }; export default myGraph; diff --git a/utils/jsonTransformer.ts b/utils/jsonTransformer.ts index 2056719..ed05aef 100644 --- a/utils/jsonTransformer.ts +++ b/utils/jsonTransformer.ts @@ -1,8 +1,5 @@ /** Stringifies only values of a JSON object, including nested ones */ -export const getNestedValuesString = ( - obj: any, - delimiter: string = ", " -): string => { +const getNestedValuesString = (obj: any, delimiter: string = ", "): string => { let values: string[] = []; for (const key in obj) { if (typeof obj[key] !== "object") { @@ -14,3 +11,5 @@ export const getNestedValuesString = ( return values.join(delimiter); }; + +export { getNestedValuesString }; diff --git a/utils/logger.ts b/utils/logger.ts index 11b22b6..1d59400 100644 --- a/utils/logger.ts +++ b/utils/logger.ts @@ -54,12 +54,16 @@ const winstonLogger: Logger = createLogger({ new transports.File({ filename: path.join(import.meta.dirname, "..", "logs", "error.log"), level: "error", - maxsize: 1048576, }), ], }); winstonLogger.on("error", (error) => winstonLogger.error("Error inside logger", { error }) ); +winstonLogger.exceptions.handle( + new transports.File({ + filename: path.join(import.meta.dirname, "..", "logs", "exceptions.log"), + }) +); export default winstonLogger; diff --git a/utils/spotifyUriTransformer.ts b/utils/spotifyUriTransformer.ts index e8c5b80..3dbc086 100644 --- a/utils/spotifyUriTransformer.ts +++ b/utils/spotifyUriTransformer.ts @@ -4,7 +4,7 @@ const base62Pattern: RegExp = /^[A-Za-z0-9]+$/; /** * Returns type and ID from a Spotify URI - * @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs} + * @see {@link https://web.archive.org/web/20250313174409/https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs} * @param uri Spotify URI - can be of an album, track, playlist, user, episode, etc. * @throws {TypeError} If the input is not a valid Spotify URI */ diff --git a/validators/index.ts b/validators/index.ts index 57dd775..47298a4 100644 --- a/validators/index.ts +++ b/validators/index.ts @@ -6,8 +6,7 @@ import { getNestedValuesString } from "../utils/jsonTransformer.ts"; import logger from "../utils/logger.ts"; -/** Refer: https://stackoverflow.com/questions/58848625/access-messages-in-express-validator */ -export const validate: RequestHandler = (req, res, next) => { +const validate: RequestHandler = (req, res, next) => { const errors = validationResult(req); if (errors.isEmpty()) { return next(); @@ -35,3 +34,5 @@ export const validate: RequestHandler = (req, res, next) => { logger.warn("invalid request", { extractedErrors }); return null; }; + +export { validate };