diff --git a/package-lock.json b/package-lock.json index 5f8be52..0a27c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2202,16 +2201,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -2473,9 +2462,8 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2484,9 +2472,8 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2637,7 +2624,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2855,7 +2841,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3155,12 +3140,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bintrees": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3246,7 +3225,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4124,7 +4102,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5275,7 +5252,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6464,7 +6440,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -6725,19 +6700,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prom-client": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", - "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.4.0", - "tdigest": "^0.1.1" - }, - "engines": { - "node": "^16 || ^18 || >=20" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7396,15 +7358,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tdigest": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", - "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", - "license": "MIT", - "dependencies": { - "bintrees": "1.0.2" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7492,7 +7445,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7624,7 +7576,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8290,7 +8241,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8341,7 +8291,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/src/api/v1/streams.ts b/src/api/v1/streams.ts index df1b234..4a41507 100644 --- a/src/api/v1/streams.ts +++ b/src/api/v1/streams.ts @@ -1,53 +1,52 @@ import { Router, Request, Response } from "express"; -import { StreamRepository, FindAllParams } from "../../repositories/streamRepository"; +import { StreamRepository } from "../../repositories/streamRepository"; +import { validate } from "../../middleware/validate"; +import { + getStreamsQuerySchema, + uuidParamSchema, +} from "../../validation/schemas"; const router = Router(); const streamRepository = new StreamRepository(); // GET /api/v1/streams/:id -router.get("/:id", async (req: Request, res: Response) => { - try { - const { id } = req.params; - - // Basic UUID validation (regex) - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(id)) { - return res.status(400).json({ error: "Invalid stream ID format" }); +router.get( + "/:id", + validate({ params: uuidParamSchema }), + async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const stream = await streamRepository.findById(id); + + if (!stream) { + return res.status(404).json({ error: "Stream not found" }); + } + + res.json(stream); + } catch (error) { + console.error("Error fetching stream:", error); + res.status(500).json({ error: "Internal server error" }); } - - const stream = await streamRepository.findById(id); - - if (!stream) { - return res.status(404).json({ error: "Stream not found" }); - } - - res.json(stream); - } catch (error) { - console.error("Error fetching stream:", error); - res.status(500).json({ error: "Internal server error" }); - } -}); + }, +); // GET /api/v1/streams -router.get("/", async (req: Request, res: Response) => { - try { - const { payer, recipient, status, limit, offset } = req.query; - - const params: FindAllParams = { - payer: payer as string | undefined, - recipient: recipient as string | undefined, - status: status as FindAllParams["status"], - limit: limit ? parseInt(limit as string, 10) : undefined, - offset: offset ? parseInt(offset as string, 10) : undefined, - }; - - const result = await streamRepository.findAll(params); - - res.json(result); - } catch (error) { - console.error("Error fetching streams:", error); - res.status(500).json({ error: "Internal server error" }); - } -}); +router.get( + "/", + validate({ query: getStreamsQuerySchema }), + async (req: Request, res: Response) => { + try { + const params = req.query; + + const result = await streamRepository.findAll(params); + + res.json(result); + } catch (error) { + console.error("Error fetching streams:", error); + res.status(500).json({ error: "Internal server error" }); + } + }, +); export default router; diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts new file mode 100644 index 0000000..074067d --- /dev/null +++ b/src/middleware/validate.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from "express"; +import { ZodError, ZodObject } from "zod"; + +type SchemaBundle = { + body?: ZodObject; + query?: ZodObject; + params?: ZodObject; +}; + +type ValidationError = { + path: string; + message: string; +}; + +export const validate = + (schemas: SchemaBundle) => + (req: Request, res: Response, next: NextFunction) => { + const errors: ValidationError[] = []; + + const validatePart = ( + schema: ZodObject | undefined, + data: unknown, + assign: (parsed: T) => void, + ) => { + if (!schema) return; + + const result = schema.safeParse(data); + + if (!result.success) { + const formatted = formatZodErrors(result.error); + errors.push(...formatted); + } else { + assign(result.data as T); + } + }; + + validatePart(schemas.body, req.body, (data) => { + req.body = data; + }); + + validatePart(schemas.query, req.query, (data) => { + req.query = data as Request["query"]; + }); + + validatePart(schemas.params, req.params, (data) => { + req.params = data as Request["params"]; + }); + + if (errors.length > 0) { + return res.status(400).json({ + error: "validation_error", + details: errors, + }); + } + + next(); + }; + +function formatZodErrors(error: ZodError): ValidationError[] { + return error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); +} diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts new file mode 100644 index 0000000..d35c63a --- /dev/null +++ b/src/validation/schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +/** + * Streams query validation + */ +export const getStreamsQuerySchema = z.object({ + payer: z.string().optional(), + recipient: z.string().optional(), + status: z.enum(["active", "paused", "cancelled", "completed"]).optional(), + limit: z.coerce.number().min(1).max(100).optional(), + offset: z.coerce.number().min(0).optional(), +}); + +/** + * UUID param validation + */ +export const uuidParamSchema = z.object({ + id: z.string().uuid(), +});