diff --git a/packages/api-server/src/routes/ebay.ts b/packages/api-server/src/routes/ebay.ts index db78882..1fc6a6c 100644 --- a/packages/api-server/src/routes/ebay.ts +++ b/packages/api-server/src/routes/ebay.ts @@ -1,5 +1,10 @@ import { fetchEbayItems } from "@marketplace-scrapers/core"; import { logger } from "../logger"; +import { + emptySearchResponse, + getRequiredSearchQuery, + parseNonNegativeIntegerParam, +} from "./helpers"; /** * GET /api/ebay?q={query}&minPrice={minPrice}&maxPrice={maxPrice}&strictMode={strictMode}&exclusions={exclusions}&keywords={keywords}&buyItNowOnly={buyItNowOnly}&canadaOnly={canadaOnly} @@ -9,32 +14,24 @@ export async function ebayRoute(req: Request): Promise { try { const reqUrl = new URL(req.url); - const SEARCH_QUERY = - req.headers.get("query") || reqUrl.searchParams.get("q") || null; - if (!SEARCH_QUERY) - return Response.json( - { - message: - "Request didn't have 'query' header or 'q' search parameter!", - }, - { status: 400 }, - ); - - const minPriceParam = reqUrl.searchParams.get("minPrice"); - const minPrice = minPriceParam ? parseInt(minPriceParam, 10) : undefined; - if (minPriceParam && (Number.isNaN(minPrice) || (minPrice ?? 0) < 0)) { - return Response.json( - { message: "Invalid minPrice parameter" }, - { status: 400 }, - ); + const SEARCH_QUERY = getRequiredSearchQuery(req); + if (SEARCH_QUERY instanceof Response) { + return SEARCH_QUERY; } - const maxPriceParam = reqUrl.searchParams.get("maxPrice"); - const maxPrice = maxPriceParam ? parseInt(maxPriceParam, 10) : undefined; - if (maxPriceParam && (Number.isNaN(maxPrice) || (maxPrice ?? 0) < 0)) { - return Response.json( - { message: "Invalid maxPrice parameter" }, - { status: 400 }, - ); + + const minPrice = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "minPrice", + ); + if (minPrice instanceof Response) { + return minPrice; + } + const maxPrice = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxPrice", + ); + if (maxPrice instanceof Response) { + return maxPrice; } const strictMode = reqUrl.searchParams.get("strictMode") === "true"; const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false"; @@ -48,13 +45,12 @@ export async function ebayRoute(req: Request): Promise { ? keywordsParam.split(",").map((s) => s.trim()) : [SEARCH_QUERY]; - const maxItemsParam = reqUrl.searchParams.get("maxItems"); - const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : undefined; - if (maxItemsParam && (Number.isNaN(maxItems) || (maxItems ?? 0) < 0)) { - return Response.json( - { message: "Invalid maxItems parameter" }, - { status: 400 }, - ); + const maxItems = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxItems", + ); + if (maxItems instanceof Response) { + return maxItems; } const hideUnstableResults = reqUrl.searchParams.get("unstableFilter") === "true"; @@ -73,10 +69,7 @@ export async function ebayRoute(req: Request): Promise { hideUnstableResults: true, }); if (items.results.length === 0 && items.unstableResults.length === 0) { - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + return emptySearchResponse(); } return Response.json(items, { status: 200 }); } @@ -84,11 +77,9 @@ export async function ebayRoute(req: Request): Promise { const items = await fetchEbayItems(SEARCH_QUERY, 1, opts); const isEmpty = !items || items.length === 0; - if (isEmpty) - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + if (isEmpty) { + return emptySearchResponse(); + } return Response.json(items, { status: 200 }); } catch (error) { logger.error("eBay scraping error:", error); diff --git a/packages/api-server/src/routes/facebook.ts b/packages/api-server/src/routes/facebook.ts index 59f2ff1..3e34b38 100644 --- a/packages/api-server/src/routes/facebook.ts +++ b/packages/api-server/src/routes/facebook.ts @@ -1,5 +1,10 @@ import { fetchFacebookItems } from "@marketplace-scrapers/core"; import { logger } from "../logger"; +import { + emptySearchResponse, + getRequiredSearchQuery, + parseNonNegativeIntegerParam, +} from "./helpers"; /** * GET /api/facebook?q={query}&location={location} @@ -8,24 +13,19 @@ import { logger } from "../logger"; export async function facebookRoute(req: Request): Promise { const reqUrl = new URL(req.url); - const SEARCH_QUERY = - req.headers.get("query") || reqUrl.searchParams.get("q") || null; - if (!SEARCH_QUERY) - return Response.json( - { - message: "Request didn't have 'query' header or 'q' search parameter!", - }, - { status: 400 }, - ); + const SEARCH_QUERY = getRequiredSearchQuery(req); + if (SEARCH_QUERY instanceof Response) { + return SEARCH_QUERY; + } const LOCATION = reqUrl.searchParams.get("location") || "toronto"; - const maxItemsParam = reqUrl.searchParams.get("maxItems"); - const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : 25; - if (maxItemsParam && (Number.isNaN(maxItems) || maxItems < 0)) { - return Response.json( - { message: "Invalid maxItems parameter" }, - { status: 400 }, - ); + const maxItems = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxItems", + 25, + ); + if (maxItems instanceof Response) { + return maxItems; } const hideUnstableResults = reqUrl.searchParams.get("unstableFilter") === "true"; @@ -42,20 +42,15 @@ export async function facebookRoute(req: Request): Promise { }, ); if (items.results.length === 0 && items.unstableResults.length === 0) { - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + return emptySearchResponse(); } return Response.json(items, { status: 200 }); } const items = await fetchFacebookItems(SEARCH_QUERY, 1, LOCATION, maxItems); - if (!items || items.length === 0) - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + if (!items || items.length === 0) { + return emptySearchResponse(); + } return Response.json(items, { status: 200 }); } catch (error) { logger.error("Facebook scraping error:", error); diff --git a/packages/api-server/src/routes/helpers.ts b/packages/api-server/src/routes/helpers.ts new file mode 100644 index 0000000..3b13092 --- /dev/null +++ b/packages/api-server/src/routes/helpers.ts @@ -0,0 +1,39 @@ +export function getRequiredSearchQuery(req: Request): string | Response { + const reqUrl = new URL(req.url); + const query = req.headers.get("query") || reqUrl.searchParams.get("q"); + if (!query) { + return Response.json( + { + message: "Request didn't have 'query' header or 'q' search parameter!", + }, + { status: 400 }, + ); + } + return query; +} + +export function parseNonNegativeIntegerParam( + searchParams: URLSearchParams, + name: string, + defaultValue?: number, +): number | undefined | Response { + const rawValue = searchParams.get(name); + if (rawValue === null) { + return defaultValue; + } + const value = Number(rawValue); + if (!Number.isInteger(value) || value < 0) { + return Response.json( + { message: `Invalid ${name} parameter` }, + { status: 400 }, + ); + } + return value; +} + +export function emptySearchResponse(): Response { + return Response.json( + { message: "Search didn't return any results!" }, + { status: 404 }, + ); +} diff --git a/packages/api-server/src/routes/kijiji.ts b/packages/api-server/src/routes/kijiji.ts index bcbff63..0ccfede 100644 --- a/packages/api-server/src/routes/kijiji.ts +++ b/packages/api-server/src/routes/kijiji.ts @@ -1,5 +1,10 @@ import { fetchKijijiItems } from "@marketplace-scrapers/core"; import { logger } from "../logger"; +import { + emptySearchResponse, + getRequiredSearchQuery, + parseNonNegativeIntegerParam, +} from "./helpers"; /** * GET /api/kijiji?q={query} @@ -8,39 +13,32 @@ import { logger } from "../logger"; export async function kijijiRoute(req: Request): Promise { const reqUrl = new URL(req.url); - const SEARCH_QUERY = - req.headers.get("query") || reqUrl.searchParams.get("q") || null; - if (!SEARCH_QUERY) - return Response.json( - { - message: "Request didn't have 'query' header or 'q' search parameter!", - }, - { status: 400 }, - ); + const SEARCH_QUERY = getRequiredSearchQuery(req); + if (SEARCH_QUERY instanceof Response) { + return SEARCH_QUERY; + } - const maxPagesParam = reqUrl.searchParams.get("maxPages"); - const maxPages = maxPagesParam ? parseInt(maxPagesParam, 10) : 5; - if (maxPagesParam && (Number.isNaN(maxPages) || maxPages < 0)) { - return Response.json( - { message: "Invalid maxPages parameter" }, - { status: 400 }, - ); + const maxPages = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "maxPages", + 5, + ); + if (maxPages instanceof Response) { + return maxPages; } - const priceMinParam = reqUrl.searchParams.get("priceMin"); - const priceMin = priceMinParam ? parseInt(priceMinParam, 10) : undefined; - if (priceMinParam && (Number.isNaN(priceMin) || (priceMin ?? 0) < 0)) { - return Response.json( - { message: "Invalid priceMin parameter" }, - { status: 400 }, - ); + const priceMin = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "priceMin", + ); + if (priceMin instanceof Response) { + return priceMin; } - const priceMaxParam = reqUrl.searchParams.get("priceMax"); - const priceMax = priceMaxParam ? parseInt(priceMaxParam, 10) : undefined; - if (priceMaxParam && (Number.isNaN(priceMax) || (priceMax ?? 0) < 0)) { - return Response.json( - { message: "Invalid priceMax parameter" }, - { status: 400 }, - ); + const priceMax = parseNonNegativeIntegerParam( + reqUrl.searchParams, + "priceMax", + ); + if (priceMax instanceof Response) { + return priceMax; } const hideUnstableResults = reqUrl.searchParams.get("unstableFilter") === "true"; @@ -75,10 +73,7 @@ export async function kijijiRoute(req: Request): Promise { { hideUnstableResults: true }, ); if (items.results.length === 0 && items.unstableResults.length === 0) { - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + return emptySearchResponse(); } return Response.json(items, { status: 200 }); } @@ -90,11 +85,9 @@ export async function kijijiRoute(req: Request): Promise { searchOptions, {}, ); - if (!items || items.length === 0) - return Response.json( - { message: "Search didn't return any results!" }, - { status: 404 }, - ); + if (!items || items.length === 0) { + return emptySearchResponse(); + } return Response.json(items, { status: 200 }); } catch (error) { logger.error("Kijiji scraping error:", error); diff --git a/packages/api-server/test/routes.test.ts b/packages/api-server/test/routes.test.ts index 6804d50..64909ad 100644 --- a/packages/api-server/test/routes.test.ts +++ b/packages/api-server/test/routes.test.ts @@ -513,6 +513,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxItems parameter"); }); + test("ebayRoute rejects non-integer minPrice", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&minPrice=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid minPrice parameter"); + }); + test("ebayRoute returns 400 for invalid minPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); @@ -525,6 +537,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid minPrice parameter"); }); + test("ebayRoute rejects non-integer maxPrice", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + const response = await ebayRoute( + new Request("http://localhost/api/ebay?q=laptop&maxPrice=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPrice parameter"); + }); + test("ebayRoute returns 400 for invalid maxPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); @@ -537,6 +561,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxPrice parameter"); }); + test("kijijiRoute rejects decimal maxPages", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + const response = await kijijiRoute( + new Request("http://localhost/api/kijiji?q=laptop&maxPages=1.5"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxPages parameter"); + }); + test("kijijiRoute returns 400 for invalid maxPages", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); @@ -573,6 +609,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid priceMax parameter"); }); + test("facebookRoute rejects non-integer maxItems", async () => { + const { facebookRoute } = await import("../src/routes/facebook"); + + const response = await facebookRoute( + new Request("http://localhost/api/facebook?q=laptop&maxItems=10abc"), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("Invalid maxItems parameter"); + }); + test("facebookRoute returns 400 for negative maxItems", async () => { const { facebookRoute } = await import("../src/routes/facebook");