diff --git a/package-lock.json b/package-lock.json index e50074f3..f1cc9a14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@kenyip/backoff-strategies": "^1.0.4", "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", + "@minisylar/express-typed-router": "^1.9.3", "@pierre/diffs": "^1.2.4", "@rocksky/sdk": "^0.3.0", "@supercharge/promise-pool": "^3.0.0", @@ -3000,6 +3001,22 @@ "react": ">=16" } }, + "node_modules/@minisylar/express-typed-router": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@minisylar/express-typed-router/-/express-typed-router-1.9.3.tgz", + "integrity": "sha512-Ek4pMAvQSMLoHftqJD5beNTL1dndPCrFUSE2i9WyLVltB4sZ+W6wD1yzgMm3AcjGSfTKTZzVfLaH0ziLh5ECYw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@standard-schema/utils": "^0.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + } + }, "node_modules/@mjackson/form-data-parser": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@mjackson/form-data-parser/-/form-data-parser-0.4.0.tgz", @@ -5329,7 +5346,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "dev": true, "license": "MIT" }, "node_modules/@stdlib/error-tools-fmtprodmsg": { diff --git a/package.json b/package.json index 2371bfc9..02ca4706 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@kenyip/backoff-strategies": "^1.0.4", "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", + "@minisylar/express-typed-router": "^1.9.3", "@pierre/diffs": "^1.2.4", "@rocksky/sdk": "^0.3.0", "@supercharge/promise-pool": "^3.0.0", diff --git a/patches/@minisylar+express-typed-router+1.9.3.patch b/patches/@minisylar+express-typed-router+1.9.3.patch new file mode 100644 index 00000000..4d8bde3e --- /dev/null +++ b/patches/@minisylar+express-typed-router+1.9.3.patch @@ -0,0 +1,58 @@ +diff --git a/node_modules/@minisylar/express-typed-router/dist/schema-router.d.cts b/node_modules/@minisylar/express-typed-router/dist/schema-router.d.cts +index 87de99b..228c288 100644 +--- a/node_modules/@minisylar/express-typed-router/dist/schema-router.d.cts ++++ b/node_modules/@minisylar/express-typed-router/dist/schema-router.d.cts +@@ -319,13 +319,17 @@ declare class TypedRouter = AdditionalReqProps, + */ + docs(options?: DocsOptions): express.Router & express.RequestHandler; + get(path: Path, handler: SchemaRouteHandler): TypedRouter; +- get(path: Path, options: RouteOptions, handler: SchemaRouteHandler): TypedRouter; +- get[]>(path: Path, options: { +- middleware: Middleware; +- }, handler: SchemaRouteHandler, Locals & InferMiddlewareLocals>): TypedRouter; +- get[]>(path: Path, options: RouteOptions & { +- middleware: [...M]; +- }, // Using tuple spread pattern ++ get[] = []>(path: Path, options: { ++ bodySchema?: BodySchema; ++ querySchema?: QuerySchema; ++ middleware?: [...M]; ++ tags?: string[]; ++ description?: string; ++ summary?: string; ++ deprecated?: boolean; ++ responseSchema?: AnyStandardSchema; ++ hidden?: boolean; ++ }, // Using tuple spread pattern; kept as a flat object (not intersected with a generic RouteOptions) so TS can infer the M tuple and BodySchema/QuerySchema from the same object literal + handler: SchemaRouteHandler, // Make it readonly for type inference + // Make it readonly for type inference + Locals & InferMiddlewareLocals>): TypedRouter; +diff --git a/node_modules/@minisylar/express-typed-router/dist/schema-router.d.mts b/node_modules/@minisylar/express-typed-router/dist/schema-router.d.mts +index 87de99b..228c288 100644 +--- a/node_modules/@minisylar/express-typed-router/dist/schema-router.d.mts ++++ b/node_modules/@minisylar/express-typed-router/dist/schema-router.d.mts +@@ -319,13 +319,17 @@ declare class TypedRouter = AdditionalReqProps, + */ + docs(options?: DocsOptions): express.Router & express.RequestHandler; + get(path: Path, handler: SchemaRouteHandler): TypedRouter; +- get(path: Path, options: RouteOptions, handler: SchemaRouteHandler): TypedRouter; +- get[]>(path: Path, options: { +- middleware: Middleware; +- }, handler: SchemaRouteHandler, Locals & InferMiddlewareLocals>): TypedRouter; +- get[]>(path: Path, options: RouteOptions & { +- middleware: [...M]; +- }, // Using tuple spread pattern ++ get[] = []>(path: Path, options: { ++ bodySchema?: BodySchema; ++ querySchema?: QuerySchema; ++ middleware?: [...M]; ++ tags?: string[]; ++ description?: string; ++ summary?: string; ++ deprecated?: boolean; ++ responseSchema?: AnyStandardSchema; ++ hidden?: boolean; ++ }, // Using tuple spread pattern; kept as a flat object (not intersected with a generic RouteOptions) so TS can infer the M tuple and BodySchema/QuerySchema from the same object literal + handler: SchemaRouteHandler, // Make it readonly for type inference + // Make it readonly for type inference + Locals & InferMiddlewareLocals>): TypedRouter; diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 10c24c12..0dcc8e1f 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -30,7 +30,7 @@ import { setupAuthRoutes } from "./auth.ts"; import { setupDeezerRoutes } from "./deezerRoutes.ts"; import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.ts"; import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.ts"; -import { type ComponentAwareRequest, makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle, type SourceAwareRequest } from "./middleware.ts"; +import { makeClientCheckMiddle, makeClientNextMiddle, makeComponentMiddle, makeSourceCheckMiddle, makeSourceNextMiddle } from "./middleware.ts"; import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.ts"; import type ScrobbleSources from "../sources/ScrobbleSources.ts"; import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts"; @@ -40,15 +40,19 @@ import { DrizzlePlayRepository, type QueryPlaysOpts, type QueryPlaysOptsJson } f import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.ts"; import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.ts"; import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.ts"; -import type {ComponentClientApiJson, ComponentSourceApiJson} from "../../core/Api.ts"; +import {componentStateBodySchema, type ComponentClientApiJson, type ComponentSourceApiJson} from "../../core/Api.ts"; import { asDayjsHydratedObject } from "../../core/DataUtils.ts"; import type {Dayjs} from "dayjs"; import { asSerializablePlaySelect } from "../../core/PlayMarshalUtils.ts"; import { serializeError } from "serialize-error"; +import { z } from 'zod'; +import type { createTypedRouter } from "@minisylar/express-typed-router"; const maxBufferSize = 300; const output: Record> = {}; +const jsonParser = bodyParser.json({type: ['text/*', 'application/json']}); + const createAddToLogBuffer = (levelMap: {[p: number]: string}) => (log: LogDataPretty) => { output[log.level].add({...log, levelLabel: levelMap[log.level]}); } @@ -66,7 +70,7 @@ const getLogs = (minLevel: number, limit: number = maxBufferSize, sort: 'asc' | return allLogs.flat(1).sort((a, b) => a.time - b.time).slice(0, limit); } -export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { +export const setupApi = (app: Express, router: ReturnType, logger: Logger, appLoggerStream: PassThrough, initialLogOutput: LogDataPretty[] = [], scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { for(const level of Object.keys(logger.levels.labels)) { output[level] = new FixedSizeList(maxBufferSize); } @@ -150,7 +154,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json({data: slicedLog, settings: logConfig}); }); - app.get('/api/events', async (req, res) => { + router.get('/events', async (req, res) => { const { query: { next: nextQs @@ -184,19 +188,19 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro setupWebscrobblerRoutes(app, logger, scrobbleSources); setupLZEndpointRoutes(app, logger, scrobbleSources, scrobbleClients); setupLastfmEndpointRoutes(app, logger, scrobbleSources); - setupAuthRoutes(app, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients); + setupAuthRoutes(app, router, logger, sourceRequiredMiddle, clientRequiredMiddle, scrobbleSources, scrobbleClients); - app.put('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { + router.put('webscrobbler', {middleware: [jsonParser]}, async (req, res) => { logger.info(req.body); res.sendStatus(200); }); - app.get('/api/webscrobbler', bodyParser.json({type: ['text/*', 'application/json']}), async (req, res) => { + router.get('webscrobbler', {middleware: [jsonParser]}, async (req, res) => { logger.info(req.body); res.sendStatus(200); }); - app.get('/api/components', async (req, res, next) => { + router.get('/components', async (req, res, next) => { const sourceData = scrobbleSources.sources.filter(x => x.databaseOK).map((x) => { const { @@ -256,13 +260,13 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json([...sourceData, ...clientData]); }); - app.get('/api/sources/:componentVal/players', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { + router.get('/sources/:componentVal/players', {middleware: [sourceAwareMiddle] }, async (req, res, next) => { if(req.component instanceof MemorySource) { return res.json(req.component.playersToObject()); } return res.json({}); }); - app.get('/api/components/:componentVal/players', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal/players', {middleware: [componentAwareMiddle]}, async (req, res, next) => { if(req.component instanceof MemorySource) { return res.json(req.component.playersToObject()); } else if(req.component instanceof AbstractScrobbleClient && req.component.nowPlayingEnabled) { @@ -271,7 +275,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json({}); }); - app.get('/api/sources/:componentVal/players/:platformId', sourceAwareMiddle, async (req: SourceAwareRequest, res, next) => { + router.get('/sources/:componentVal/players/:platformId', {middleware: [sourceAwareMiddle]}, async (req, res, next) => { if(req.component instanceof MemorySource) { const { params: { @@ -286,7 +290,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } return res.json({}); }); - app.get('/api/components/:componentVal/players/:platformId', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal/players/:platformId', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { params: { platformId @@ -309,14 +313,18 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.status(400).json({error: `Component does not support players`}); }); - app.get('/api/components/:componentVal', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal', {middleware: [componentAwareMiddle]}, async (req, res) => { const { component, } = req; return res.json(component.getApiData()); }); - - app.post('/api/components/:componentVal/state', componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] }), async (req: ComponentAwareRequest, res, next) => { + //type TA = TypedMiddleware<{component: ComponentAwareRequest['component']}>; +// componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] }) + router.post('/components/:componentVal/state', + // {bodySchema: componentStateBodySchema}, + {middleware: [componentAwareMiddle, bodyParser.json({ type: ['text/*', 'application/json'] })], bodySchema: componentStateBodySchema}, + async (req, res) => { const { component, body: { @@ -352,7 +360,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.sendStatus(200); }); - app.post('/api/components/:componentVal/auth', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.post('/components/:componentVal/auth', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, } = req; @@ -376,7 +384,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } }); - app.get('/api/components/:componentVal/plays', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal/plays', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, query @@ -385,14 +393,14 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro const hydratedQuery = asDayjsHydratedObject>(query); const playRes = await component.getPlaysPaginated(hydratedQuery); - // @ts-expect-error + // @ts-expect-error its fine playRes.data = playRes.data.map(x => asSerializablePlaySelect(x)) //PlayApiCommonDetailed // plus paginatioon return res.json(playRes); }); - app.get('/api/components/:componentVal/plays/:playUid', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal/plays/:playUid', {middleware: [componentAwareMiddle]}, async (req, res, next) => { const { component, query, @@ -407,7 +415,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json(asSerializablePlaySelect(playRes)); }); - app.delete('/api/cache/:cacheType', async (req, res) => { + router.delete('/cache/:cacheType', async (req, res) => { const cache = await getRoot().items.cache(); logger.verbose(`User request cache deletion for ${req.params.cacheType}`); switch(req.params.cacheType) { @@ -432,7 +440,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro * * */ - app.get('/api/status', async (req, res, next) => { + router.get('/status', async (req, res, next) => { const sourceData = scrobbleSources.sources.map((x) => { const { @@ -526,9 +534,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json({sources: sourceData, clients: clientData}); }); - app.get('/api/recent', sourceMiddleFunc(false), async (req, res, next) => { + router.get('/recent', {middleware: [sourceMiddleFunc(false)], querySchema: z.any()}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleSource: source, query: { upstream = 'false', @@ -560,9 +567,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json(result); }); - app.get('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => { + router.get('/source/art', {middleware: [sourceMiddleFunc(false)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleSource, query: { data @@ -588,9 +594,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } }); - app.get('/api/dead', clientMiddleFunc(true), async (req, res, next) => { + router.get('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, query = {} } = req; @@ -605,15 +610,13 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro ] } - // @ts-ignore const result: DeadLetterScrobble[] = (await (client as AbstractScrobbleClient).getPlaysPaginated(deadQuery)).data.map(x => playSelectToDeadScrobble(x, true)); return res.json(result); }); - app.put('/api/dead', clientMiddleFunc(true), async (req, res, next) => { + router.put('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, } = req; @@ -624,9 +627,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro await ((client as AbstractScrobbleClient).processDeadLetterQueue(1000)); }); - app.put('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => { + router.put('/dead/:id', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, params: { id @@ -653,9 +655,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } }); - app.delete('/api/dead', clientMiddleFunc(true), async (req, res, next) => { + router.delete('/dead', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, } = req; @@ -666,9 +667,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.sendStatus(200); }); - app.delete('/api/dead/:id', clientMiddleFunc(true), async (req, res, next) => { + router.delete('/dead/:id', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleClient: client, params: { id @@ -692,9 +692,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } }); - app.get('/api/scrobbled', clientMiddleFunc(false), async (req, res, next) => { + router.get('/scrobbled', {middleware: [clientMiddleFunc(true)]}, async (req, res, next) => { const { - // @ts-expect-error scrobbleClient not part of req scrobbleClient: client, query } = req; @@ -711,9 +710,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro return res.json(result); }); - app.use('/api/source/init', sourceRequiredMiddle); - app.post('/api/source/init', async (req, res) => { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + router.post('/source/init', {middleware: [sourceRequiredMiddle]}, async (req, res) => { const source = req.scrobbleSource as AbstractSource; const { @@ -739,9 +736,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro } }); - app.use('/api/source/listen', sourceRequiredMiddle); - app.post('/api/source/listen', async (req, res) => { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + router.post('/source/listen', {middleware: [sourceRequiredMiddle]}, async (req, res) => { const source = req.scrobbleSource as AbstractSource; const { @@ -761,9 +756,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro res.status(200).json({listening}); }); - app.use('/api/client/listen', clientRequiredMiddle); - app.post('/api/client/listen', async (req, res) => { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + router.post('/client/listen', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient; const { @@ -783,9 +776,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro res.status(200).json({listening}); }); - app.use('/api/client/init', clientRequiredMiddle); - app.post('/api/client/init', async (req, res) => { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + router.post('/client/init', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient; const { @@ -808,8 +799,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro res.status(200).send('OK'); }); - app.post('/api/client/historical', clientRequiredMiddle, async (req, res) => { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + router.post('/client/historical', {middleware: [clientRequiredMiddle]}, async (req, res) => { const client = req.scrobbleClient as AbstractScrobbleClient; if(client instanceof AbstractHistoricalScrobbleClient) { client.logger.info('User requested historical play hydration'); @@ -822,7 +812,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro }); app.get('/health', async (req, res) => res.redirect(307, `/api/${req.url.slice(1)}`)); - app.get('/api/health', async (req, res) => { + router.get('/health', async (req, res) => { const { type, name diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index 282cd8e2..b151da92 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -1,7 +1,6 @@ import type {Express} from 'express'; import type {Logger} from "@foxxmd/logging"; import passport from "passport"; -import type {ExpressHandler} from "../common/infrastructure/Atomic.ts"; import type LastfmScrobbler from "../scrobblers/LastfmScrobbler.ts"; import type ScrobbleClients from "../scrobblers/ScrobbleClients.ts"; import type LastfmSource from "../sources/LastfmSource.ts"; @@ -11,13 +10,15 @@ import type YTMusicSource from "../sources/YTMusicSource.ts"; import type LibrefmScrobbler from "../scrobblers/LibrefmScrobbler.ts"; import type LibrefmSource from "../sources/LibrefmSource.ts"; import AbstractSource from "../sources/AbstractSource.ts"; -import { makeComponentMiddle, type ComponentAwareRequest } from './middleware.ts'; +import { makeComponentMiddle, type ClientCheckedMiddleTypedMiddleware, type SourceCheckMiddleTypedMiddleware } from './middleware.ts'; import { findAuthIssue, SimpleError } from '../common/errors/MSErrors.ts'; +import type { createTypedRouter } from '@minisylar/express-typed-router'; +import * as z from 'zod'; -export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { +export const setupAuthRoutes = (app: Express, router: ReturnType, logger: Logger, sourceMiddle: SourceCheckMiddleTypedMiddleware, clientMiddle: ClientCheckedMiddleTypedMiddleware, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { const componentAwareMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients); - app.get('/api/components/:componentVal/auth', componentAwareMiddle, async (req: ComponentAwareRequest, res, next) => { + router.get('/components/:componentVal/auth', {middleware: [componentAwareMiddle]}, async (req, res, next) => { switch(req.component.type) { case 'lastfm': case 'librefm': @@ -35,8 +36,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr } }); - app.use('/api/client/auth', clientMiddle); - app.get('/api/client/auth', async (req, res) => { + router.get('/api/client/auth', {middleware: [clientMiddle]}, async (req, res) => { const { scrobbleClient, } = req as any; @@ -47,35 +47,31 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr res.redirect(scrobbleClient.api.getAuthUrl()); break; case 'tealfm': - const url = await scrobbleClient.getAuthorizeUrl(); - res.redirect(url); + res.redirect(await scrobbleClient.getAuthorizeUrl()); break; default: return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`); } }); - app.use('/api/source/auth', sourceMiddle); - app.get('/api/source/auth', async (req, res, next) => { + router.get('/api/source/auth', {middleware: [sourceMiddle]}, async (req, res, next) => { const { - // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleSource: source, - // @ts-expect-error TS(2339): Property 'sourceName' does not exist on type 'Requ... Remove this comment to see the full error message sourceName: name, } = req; switch (source.type) { case 'spotify': - if (source.spotifyApi === undefined) { + if ((source as SpotifySource).spotifyApi === undefined) { res.status(400).send('Spotify configuration is not valid'); } else { logger.info('Redirecting to spotify authorization url'); - res.redirect(source.createAuthUrl()); + res.redirect((source as SpotifySource).createAuthUrl()); } break; case 'lastfm': case 'librefm': - res.redirect(source.api.getAuthUrl()); + res.redirect((source as LastfmSource).api.getAuthUrl()); break; case 'deezer': // @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message @@ -90,7 +86,7 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr } }); - app.get(/.*callback$/, async (req, res, next) => { + router.get('/.*callback$/', {querySchema: z.any()}, async (req, res) => { if(req.url.indexOf('/api') !== 0) { return res.redirect(307, `/api${req.url}`); } @@ -143,7 +139,8 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr logger.error(e); } } - return res.redirect('/next'); + res.redirect('/next'); + return; } else if(req.url.includes('ytmusic')) { const entity: YTMusicSource | undefined = scrobbleSources.getByName(name) as (YTMusicSource | undefined); if(entity === undefined) { @@ -157,7 +154,8 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr } else { responseContent = result; } - return res.send(responseContent); + res.send(responseContent); + return; } else { // TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here) // but eventually should update all source callbacks to url specific URLS to avoid ambiguity... @@ -184,7 +182,8 @@ export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: Expr source.replaceErrors(err, {predicate: (x) => err.message === x.message}); source.logger.error(err); } - return res.redirect('/next'); + res.redirect('/next'); + return; } }); } diff --git a/src/backend/server/index.ts b/src/backend/server/index.ts index 9f9745a2..c71eea0d 100644 --- a/src/backend/server/index.ts +++ b/src/backend/server/index.ts @@ -16,9 +16,13 @@ import type ScrobbleSources from '../sources/ScrobbleSources.ts'; import type ScrobbleClients from '../scrobblers/ScrobbleClients.ts'; import { qsOptions } from '../../core/Atomic.ts'; import { projectRootDir } from "../common/infrastructure/Atomic.ts"; +import { createTypedRouter } from "@minisylar/express-typed-router"; const app = express(); -const router = Router(); + +const router = createTypedRouter(); + +//const router = Router(); app.set('query parser', (str: string) => qs.parse(str, qsOptions)); export const initServer = async (parentLogger: Logger, appLoggerStream: PassThrough, initialOutput: LogDataPretty[] = [], sources: ScrobbleSources, clients: ScrobbleClients) => { @@ -26,7 +30,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro const logger = childLogger(parentLogger, 'API'); // parentLogger.child({labels: ['API']}, mergeArr); try { - app.use(router); + app.use('/api', router.getRouter()); app.use(bodyParser.json()); app.use( bodyParser.urlencoded({ @@ -52,7 +56,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro const local = root.get('localUrl'); const localDefined = root.get('hasDefinedBaseUrl'); - setupApi(app, logger, appLoggerStream, initialOutput, sources, clients); + setupApi(app, router, logger, appLoggerStream, initialOutput, sources, clients); const addy = getAddress(); const addresses: string[] = []; diff --git a/src/backend/server/middleware.ts b/src/backend/server/middleware.ts index b19f7bea..6fd85c21 100644 --- a/src/backend/server/middleware.ts +++ b/src/backend/server/middleware.ts @@ -5,8 +5,11 @@ import type ScrobbleSources from "../sources/ScrobbleSources.ts"; import type {Request, Response, NextFunction} from "express"; import type AbstractSource from "../sources/AbstractSource.ts"; import type AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.ts"; +import type { TypedMiddleware } from "@minisylar/express-typed-router"; -export const makeSourceCheckMiddle = (sources: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => { +export type SourceCheckMiddleTypedMiddleware = TypedMiddleware<{sourceName: string, scrobbleSource: AbstractSource}>; + +export const makeSourceCheckMiddle = (sources: any) => (required: boolean): SourceCheckMiddleTypedMiddleware => (req: any, res: any, next: any) => { const { query: { name, @@ -30,7 +33,9 @@ export const makeSourceCheckMiddle = (sources: any) => (required: boolean): Expr next(); } -export const makeClientCheckMiddle = (clients: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => { +export type ClientCheckedMiddleTypedMiddleware = TypedMiddleware<{scrobbleClient: AbstractScrobbleClient}>; + +export const makeClientCheckMiddle = (clients: any) => (required: boolean): ClientCheckedMiddleTypedMiddleware => (req: any, res: any, next: any) => { const { query: { name @@ -75,7 +80,7 @@ export interface ComponentAwareRequest extends Request { component: AbstractSource | AbstractScrobbleClient } -export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { +export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): TypedMiddleware<{component: ComponentAwareRequest['component']}> => (req, res, next) => { const { params: { componentVal @@ -84,7 +89,8 @@ export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleC const componentId = Number.parseInt(componentVal as string); if (isNaN(componentId)) { - return res.status(400).json({ error: 'Component id must be a number' }); + res.status(400).json({ error: 'Component id must be a number' }); + return; } let component: AbstractSource | AbstractScrobbleClient; @@ -94,7 +100,8 @@ export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleC } if(component === undefined) { - return res.status(404).json({error: `No Component with the Id ${componentId} exists`}); + res.status(404).json({error: `No Component with the Id ${componentId} exists`}); + return; } (req as ComponentAwareRequest).component = component; @@ -109,7 +116,7 @@ export interface ClientAwareRequest extends Request { component: AbstractScrobbleClient } -export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { +export const makeSourceNextMiddle = (sources: ScrobbleSources): TypedMiddleware<{component: AbstractSource}> => async (req: Request, res: Response, next: NextFunction) => { const { params: { componentVal @@ -118,13 +125,14 @@ export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler = const componentId = Number.parseInt(componentVal as string); if (isNaN(componentId)) { - return res.status(400).json({ error: 'Source Id must be a number' }); + res.status(400).json({ error: 'Source Id must be a number' }); + return; } - let component: AbstractSource; - component = sources.sources.find(x => x.componentId === componentId); + const component: AbstractSource = sources.sources.find(x => x.componentId === componentId); if(component === undefined) { - return res.status(404).json({error: `No Source with the Id ${componentId} exists`}); + res.status(404).json({error: `No Source with the Id ${componentId} exists`}); + return; } (req as SourceAwareRequest).component = component; @@ -132,7 +140,7 @@ export const makeSourceNextMiddle = (sources: ScrobbleSources): ExpressHandler = next(); } -export const makeClientNextMiddle = (clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { +export const makeClientNextMiddle = (clients: ScrobbleClients): TypedMiddleware<{component: AbstractScrobbleClient}> => async (req: Request, res: Response, next: NextFunction) => { const { params: { componentVal @@ -141,13 +149,14 @@ export const makeClientNextMiddle = (clients: ScrobbleClients): ExpressHandler = const componentId = Number.parseInt(componentVal as string); if (isNaN(componentId)) { - return res.status(400).json({ error: 'Source Id must be a number' }); + res.status(400).json({ error: 'Source Id must be a number' }); + return; } - let component: AbstractScrobbleClient; - component = clients.clients.find(x => x.componentId === componentId); + const component: AbstractScrobbleClient = clients.clients.find(x => x.componentId === componentId); if(component === undefined) { - return res.status(404).json({error: `No Client with the Id ${componentId} exists`}); + res.status(404).json({error: `No Client with the Id ${componentId} exists`}); + return; } (req as ClientAwareRequest).component = component; diff --git a/src/core/Api.ts b/src/core/Api.ts index 5a7dd90f..2bab8d94 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -6,6 +6,7 @@ import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, Que import type { Dayjs } from "dayjs" import type { ErrorIsh } from "./ErrorUtils.ts" import type { PlayEvent } from "./PlayEvent.ts" +import * as z from "zod" export interface PlayApiCommon { uid: string @@ -214,6 +215,9 @@ export type CompareDateSingle = { export type CacheClearType = 'external-api' | 'transforms'; -export interface ComponentStateBody { - state: 'stop' | 'start' | 'restart' -} \ No newline at end of file +export const componentStateBodySchema = z.object({ + state: z.enum(["stop","start","restart","mute","unmute"]), + reason: z.string().optional() +}); + +export type ComponentStateBody = z.infer; \ No newline at end of file