mirror of
https://github.com/20kaushik02/spotify-manager.git
synced 2025-12-06 07:54:07 +00:00
improved API request wrapper, partial completion of operations
This commit is contained in:
parent
7eec2adc7a
commit
17e0480f83
272
api/spotify.ts
272
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<RespDataType> = Promise<{
|
||||
resp?: AxiosResponse<RespDataType, any>;
|
||||
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 <RespDataType>(
|
||||
authHeaders: RawAxiosRequestHeaders,
|
||||
res: Res,
|
||||
method: allowedMethods,
|
||||
path: string,
|
||||
config: AxiosRequestConfig = {},
|
||||
data: any = null,
|
||||
inlineData: boolean = false
|
||||
): Promise<AxiosResponse<RespDataType, any> | null> => {
|
||||
const singleRequest = async <RespDataType>({
|
||||
res,
|
||||
authHeaders,
|
||||
method = allowedMethods.Get,
|
||||
path,
|
||||
config = {},
|
||||
data = null,
|
||||
inlineData = false,
|
||||
}: SingleRequestArgs): SingleRequestResult<RespDataType> => {
|
||||
let resp: AxiosResponse<RespDataType, any>;
|
||||
config.headers = { ...config.headers, ...authHeaders };
|
||||
try {
|
||||
@ -55,160 +72,138 @@ const singleRequest = async <RespDataType>(
|
||||
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<GetCurrentUsersProfileData>;
|
||||
const getCurrentUsersProfile: (
|
||||
opts: GetCurrentUsersProfileArgs
|
||||
) => Promise<GetCurrentUsersProfile | null> = async ({ authHeaders, res }) => {
|
||||
const response = await singleRequest<GetCurrentUsersProfile>(
|
||||
authHeaders,
|
||||
) => GetCurrentUsersProfile = async ({ res, authHeaders }) => {
|
||||
return await singleRequest<GetCurrentUsersProfileData>({
|
||||
res,
|
||||
allowedMethods.Get,
|
||||
"/me"
|
||||
);
|
||||
return response ? response.data : null;
|
||||
authHeaders,
|
||||
path: "/me",
|
||||
});
|
||||
};
|
||||
|
||||
interface GetCurrentUsersPlaylistsFirstPageArgs
|
||||
extends EndpointHandlerBaseArgs {}
|
||||
extends EndpointHandlerWithResArgs {}
|
||||
type GetCurrentUsersPlaylists =
|
||||
SingleRequestResult<GetCurrentUsersPlaylistsData>;
|
||||
const getCurrentUsersPlaylistsFirstPage: (
|
||||
opts: GetCurrentUsersPlaylistsFirstPageArgs
|
||||
) => Promise<GetCurrentUsersPlaylists | null> = async ({
|
||||
authHeaders,
|
||||
) => GetCurrentUsersPlaylists = async ({ res, authHeaders }) => {
|
||||
return await singleRequest<GetCurrentUsersPlaylistsData>({
|
||||
res,
|
||||
}) => {
|
||||
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
||||
authHeaders,
|
||||
res,
|
||||
allowedMethods.Get,
|
||||
`/me/playlists`,
|
||||
{
|
||||
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<GetCurrentUsersPlaylists | null> = async ({
|
||||
authHeaders,
|
||||
) => GetCurrentUsersPlaylists = async ({ res, authHeaders, nextURL }) => {
|
||||
return await singleRequest<GetCurrentUsersPlaylistsData>({
|
||||
res,
|
||||
nextURL,
|
||||
}) => {
|
||||
const response = await singleRequest<GetCurrentUsersPlaylists>(
|
||||
authHeaders,
|
||||
res,
|
||||
allowedMethods.Get,
|
||||
nextURL
|
||||
);
|
||||
return response?.data ?? null;
|
||||
path: nextURL,
|
||||
});
|
||||
};
|
||||
|
||||
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerBaseArgs {
|
||||
interface GetPlaylistDetailsFirstPageArgs extends EndpointHandlerWithResArgs {
|
||||
initialFields: string;
|
||||
playlistID: string;
|
||||
}
|
||||
type GetPlaylistDetailsFirstPage = SingleRequestResult<GetPlaylistData>;
|
||||
const getPlaylistDetailsFirstPage: (
|
||||
opts: GetPlaylistDetailsFirstPageArgs
|
||||
) => Promise<GetPlaylist | null> = async ({
|
||||
authHeaders,
|
||||
) => GetPlaylistDetailsFirstPage = async ({
|
||||
res,
|
||||
authHeaders,
|
||||
initialFields,
|
||||
playlistID,
|
||||
}) => {
|
||||
const response = await singleRequest<GetPlaylist>(
|
||||
return await singleRequest<GetPlaylistData>({
|
||||
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<GetPlaylistItemsData>;
|
||||
const getPlaylistDetailsNextPage: (
|
||||
opts: GetPlaylistDetailsNextPageArgs
|
||||
) => Promise<GetPlaylistItems | null> = async ({
|
||||
authHeaders,
|
||||
) => GetPlaylistItems = async ({ res, authHeaders, nextURL }) => {
|
||||
return await singleRequest<GetPlaylistItemsData>({
|
||||
res,
|
||||
nextURL,
|
||||
}) => {
|
||||
const response = await singleRequest<GetPlaylistItems>(
|
||||
authHeaders,
|
||||
res,
|
||||
allowedMethods.Get,
|
||||
nextURL
|
||||
);
|
||||
return response?.data ?? null;
|
||||
path: nextURL,
|
||||
});
|
||||
};
|
||||
|
||||
interface AddItemsToPlaylistArgs extends EndpointHandlerBaseArgs {
|
||||
nextBatch: string[];
|
||||
playlistID: string;
|
||||
}
|
||||
type AddItemsToPlaylist = SingleRequestResult<AddItemsToPlaylistData>;
|
||||
const addItemsToPlaylist: (
|
||||
opts: AddItemsToPlaylistArgs
|
||||
) => Promise<AddItemsToPlaylist | null> = async ({
|
||||
) => AddItemsToPlaylist = async ({ authHeaders, nextBatch, playlistID }) => {
|
||||
return await singleRequest<AddItemsToPlaylistData>({
|
||||
authHeaders,
|
||||
res,
|
||||
nextBatch,
|
||||
playlistID,
|
||||
}) => {
|
||||
const response = await singleRequest<AddItemsToPlaylist>(
|
||||
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<RemovePlaylistItemsData>;
|
||||
const removePlaylistItems: (
|
||||
opts: RemovePlaylistItemsArgs
|
||||
) => Promise<RemovePlaylistItems | null> = 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<RemovePlaylistItems>(
|
||||
// but see here: https://web.archive.org/web/20250313173723/https://github.com/spotipy-dev/spotipy/issues/95#issuecomment-2263634801
|
||||
return await singleRequest<RemovePlaylistItemsData>({
|
||||
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<boolean> = async ({ authHeaders, res, playlistID, userID }) => {
|
||||
let checkFields = ["collaborative", "owner(id)"];
|
||||
|
||||
const checkFromData = await getPlaylistDetailsFirstPage({
|
||||
authHeaders,
|
||||
) => CheckPlaylistEditable = async ({
|
||||
res,
|
||||
authHeaders,
|
||||
playlistID,
|
||||
userID,
|
||||
}) => {
|
||||
let checkFields = ["collaborative", "owner(id)"];
|
||||
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: "" };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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<string, string>).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) {
|
||||
|
||||
@ -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({
|
||||
!(
|
||||
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({
|
||||
!(
|
||||
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) {
|
||||
|
||||
@ -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<SimplifiedPlaylistObject>,
|
||||
"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;
|
||||
|
||||
8
index.ts
8
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);
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -32,52 +32,54 @@ import type {
|
||||
|
||||
// GET method
|
||||
// Albums
|
||||
export type GetAlbum = AlbumObject;
|
||||
export type GetSeveralAlbums = { albums: AlbumObject[] };
|
||||
export type GetAlbumTracks = Pagination<SimplifiedTrackObject>;
|
||||
export type GetUsersSavedAlbums = Pagination<SavedAlbumObject>;
|
||||
export type CheckUsersSavedAlbums = boolean[];
|
||||
export type GetNewReleases = { albums: Pagination<SimplifiedAlbumObject> };
|
||||
export type GetAlbumData = AlbumObject;
|
||||
export type GetSeveralAlbumsData = { albums: AlbumObject[] };
|
||||
export type GetAlbumTracksData = Pagination<SimplifiedTrackObject>;
|
||||
export type GetUsersSavedAlbumsData = Pagination<SavedAlbumObject>;
|
||||
export type CheckUsersSavedAlbumsData = boolean[];
|
||||
export type GetNewReleasesData = { albums: Pagination<SimplifiedAlbumObject> };
|
||||
|
||||
// Artists
|
||||
export type GetArtist = ArtistObject;
|
||||
export type GetSeveralArtists = { artists: ArtistObject[] };
|
||||
export type GetArtistsAlbums = Pagination<ArtistsAlbumObject>;
|
||||
export type GetArtistsTopTracks = { tracks: TrackObject[] };
|
||||
export type GetArtistData = ArtistObject;
|
||||
export type GetSeveralArtistsData = { artists: ArtistObject[] };
|
||||
export type GetArtistsAlbumsData = Pagination<ArtistsAlbumObject>;
|
||||
export type GetArtistsTopTracksData = { tracks: TrackObject[] };
|
||||
|
||||
// Episodes
|
||||
export type GetEpisode = EpisodeObject;
|
||||
export type GetSeveralEpisodes = { episodes: EpisodeObject[] };
|
||||
export type GetUsersSavedEpisodes = Pagination<SavedEpisodeObject>;
|
||||
export type GetEpisodeData = EpisodeObject;
|
||||
export type GetSeveralEpisodesData = { episodes: EpisodeObject[] };
|
||||
export type GetUsersSavedEpisodesData = Pagination<SavedEpisodeObject>;
|
||||
|
||||
// Shows
|
||||
export type GetShow = ShowObject;
|
||||
export type GetSeveralShows = { shows: SimplifiedShowObject[] };
|
||||
export type GetShowEpisodes = Pagination<SimplifiedEpisodeObject>;
|
||||
export type GetUsersSavedShows = Pagination<SavedShowObject>;
|
||||
export type GetShowData = ShowObject;
|
||||
export type GetSeveralShowsData = { shows: SimplifiedShowObject[] };
|
||||
export type GetShowEpisodesData = Pagination<SimplifiedEpisodeObject>;
|
||||
export type GetUsersSavedShowsData = Pagination<SavedShowObject>;
|
||||
|
||||
// Playlists
|
||||
export type GetPlaylist = PlaylistObject;
|
||||
export type GetPlaylistItems = Pagination<PlaylistTrackObject>;
|
||||
export type GetCurrentUsersPlaylists = Pagination<SimplifiedPlaylistObject>;
|
||||
export type GetUsersPlaylists = GetCurrentUsersPlaylists;
|
||||
export type GetPlaylistCoverImage = ImageObject[];
|
||||
export type GetPlaylistData = PlaylistObject;
|
||||
export type GetPlaylistItemsData = Pagination<PlaylistTrackObject>;
|
||||
export type GetCurrentUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
|
||||
export type GetUsersPlaylistsData = Pagination<SimplifiedPlaylistObject>;
|
||||
export type GetPlaylistCoverImageData = ImageObject[];
|
||||
|
||||
// Tracks
|
||||
export type GetTrack = TrackObject;
|
||||
export type GetSeveralTracks = { tracks: TrackObject[] };
|
||||
export type GetUsersSavedTracks = Pagination<SavedTrackObject>;
|
||||
export type CheckUsersSavedTracks = boolean[];
|
||||
export type GetTrackData = TrackObject;
|
||||
export type GetSeveralTracksData = { tracks: TrackObject[] };
|
||||
export type GetUsersSavedTracksData = Pagination<SavedTrackObject>;
|
||||
export type CheckUsersSavedTracksData = boolean[];
|
||||
|
||||
// Users
|
||||
export type GetCurrentUsersProfile = UserObject;
|
||||
export type GetUsersTopItems =
|
||||
export type GetCurrentUsersProfileData = UserObject;
|
||||
export type GetUsersTopItemsData =
|
||||
| Pagination<ArtistObject>
|
||||
| Pagination<TrackObject>;
|
||||
export type GetUsersProfile = SimplifiedUserObject;
|
||||
export type GetFollowedArtists = { artists: PaginationByCursor<ArtistObject> };
|
||||
export type CheckIfUserFollowsArtistsOrNot = boolean[];
|
||||
export type CheckIfCurrentUserFollowsPlaylist = boolean[];
|
||||
export type GetUsersProfileData = SimplifiedUserObject;
|
||||
export type GetFollowedArtistsData = {
|
||||
artists: PaginationByCursor<ArtistObject>;
|
||||
};
|
||||
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 = {};
|
||||
|
||||
// <insert other method> method
|
||||
// Albums
|
||||
|
||||
@ -7,5 +7,7 @@ export type Next = NextFunction;
|
||||
|
||||
export interface EndpointHandlerBaseArgs {
|
||||
authHeaders: RawAxiosRequestHeaders;
|
||||
}
|
||||
export interface EndpointHandlerWithResArgs extends EndpointHandlerBaseArgs {
|
||||
res: Res;
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
export const sleep = (ms: number): Promise<unknown> =>
|
||||
const sleep = (ms: number): Promise<unknown> =>
|
||||
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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user