fix: strictly parse route integers

This commit is contained in:
2026-04-29 00:12:26 -04:00
parent d178f9c9cb
commit 3ea6ee3938
5 changed files with 171 additions and 105 deletions

View File

@@ -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<Response> {
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<Response> {
? 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<Response> {
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<Response> {
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);

View File

@@ -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<Response> {
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<Response> {
},
);
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);

View File

@@ -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 },
);
}

View File

@@ -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<Response> {
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<Response> {
{ 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<Response> {
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);

View File

@@ -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");