From 33370ae0a3c58e3787eca003b553596896a6681f Mon Sep 17 00:00:00 2001 From: Ru Chern Chong Date: Wed, 25 Dec 2024 01:11:28 +0800 Subject: [PATCH] Add zod validator --- package.json | 4 +- pnpm-lock.yaml | 22 +++++++++ src/config/index.ts | 2 +- src/schemas/index.ts | 98 +++++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 2 +- src/v1/routes/cars.ts | 10 +++-- src/v1/routes/coe.ts | 26 +++++------ src/v1/routes/makes.ts | 63 ++++++++++++++++---------- src/v1/routes/months.ts | 4 +- 9 files changed, 184 insertions(+), 47 deletions(-) create mode 100644 src/schemas/index.ts diff --git a/package.json b/package.json index b0aad8a..491b314 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,15 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hono/zod-validator": "^0.4.2", "@neondatabase/serverless": "^0.10.4", "@upstash/ratelimit": "^2.0.3", "@upstash/redis": "^1.34.0", "date-fns": "^2.30.0", "drizzle-orm": "^0.38.2", "hono": "^4.6.5", - "sst": "3.4.27" + "sst": "3.4.27", + "zod": "^3.24.1" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d52fe7..324e403 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@hono/zod-validator': + specifier: ^0.4.2 + version: 0.4.2(hono@4.6.5)(zod@3.24.1) '@neondatabase/serverless': specifier: ^0.10.4 version: 0.10.4 @@ -29,6 +32,9 @@ importers: sst: specifier: 3.4.27 version: 3.4.27(hono@4.6.5) + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -551,6 +557,12 @@ packages: cpu: [x64] os: [win32] + '@hono/zod-validator@0.4.2': + resolution: {integrity: sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.19.1 + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1326,6 +1338,9 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + snapshots: '@ampproject/remapping@2.3.0': @@ -1603,6 +1618,11 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@hono/zod-validator@0.4.2(hono@4.6.5)(zod@3.24.1)': + dependencies: + hono: 4.6.5 + zod: 3.24.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2309,3 +2329,5 @@ snapshots: strip-ansi: 7.1.0 yallist@4.0.0: {} + + zod@3.24.1: {} diff --git a/src/config/index.ts b/src/config/index.ts index 132e87d..4b53811 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,6 @@ import { FuelType } from "@/types"; -export const DEFAULT_CACHE_TTL = 24 * 60 * 60; +export const CACHE_TTL = 24 * 60 * 60; export const HYBRID_REGEX = new RegExp( `^(${FuelType.Diesel}|${FuelType.Petrol})-${FuelType.Electric}(\s\(Plug-In\))?$`, diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 0000000..1d637de --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +// Common schemas +export const MonthSchema = z.string().regex(/^\d{4}-\d{2}$/); // YYYY-MM format + +// Makes routes +export const MakeParamSchema = z + .object({ + make: z.string(), + }) + .strict(); + +export const MakeQuerySchema = z + .object({ + month: MonthSchema.optional(), + fuel_type: z.string().optional(), + vehicle_type: z.string().optional(), + }) + .strict(); + +// Cars routes +export const CarQuerySchema = z + .object({ + month: MonthSchema.optional(), + make: z.string().optional(), + fuel_type: z.string().optional(), + vehicle_type: z.string().optional(), + }) + .strict(); + +export const MonthsQuerySchema = z + .object({ + grouped: z.string().optional(), + }) + .strict(); + +// COE routes +export const COEQuerySchema = z + .object({ + sort: z.string().optional(), + orderBy: z.string().optional(), + month: MonthSchema.optional(), + from: MonthSchema.optional(), + to: MonthSchema.optional(), + }) + .strict(); + +// Months routes +export const LatestMonthQuerySchema = z + .object({ + type: z.enum(["cars", "coe"]).optional(), + }) + .strict(); + +// Response schemas +export const MakeArraySchema = z.array(z.string()); + +export const CarSchema = z + .object({ + make: z.string(), + model: z.string(), + fuel_type: z.string(), + vehicle_type: z.string(), + month: z.string(), + }) + .strict(); + +export const COESchema = z + .object({ + month: z.string(), + bidding_no: z.number(), + vehicle_class: z.string(), + quota: z.number(), + bids_received: z.number(), + premium: z.number(), + }) + .strict(); + +export const LatestMonthResponseSchema = z + .object({ + cars: MonthSchema.optional(), + coe: MonthSchema.optional(), + }) + .strict(); + +export const MonthsByYearSchema = z.record(z.string(), z.array(z.string())); + +export type MakeParams = z.infer; +export type MakeQuery = z.infer; +export type CarQuery = z.infer; +export type MonthsQuery = z.infer; +export type COEQuery = z.infer; +export type LatestMonthQuery = z.infer; + +export type Car = z.infer; +export type COE = z.infer; +export type LatestMonthResponse = z.infer; +export type MonthsByYear = z.infer; diff --git a/src/types/index.ts b/src/types/index.ts index 64f0be2..3b4d065 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,7 +30,7 @@ export interface Car { selected?: boolean; } -export interface COEResult { +export interface COE { month: string; bidding_no: string; vehicle_class: string; diff --git a/src/v1/routes/cars.ts b/src/v1/routes/cars.ts index e688ae5..c6232d9 100644 --- a/src/v1/routes/cars.ts +++ b/src/v1/routes/cars.ts @@ -1,17 +1,20 @@ +import { CACHE_TTL } from "@/config"; import db from "@/config/db"; import redis from "@/config/redis"; import { getLatestMonth } from "@/lib/getLatestMonth"; import { getUniqueMonths } from "@/lib/getUniqueMonths"; import { groupMonthsByYear } from "@/lib/groupMonthsByYear"; import { cars } from "@/schema"; +import { CarQuerySchema, MonthsQuerySchema } from "@/schemas"; import type { Make } from "@/types"; import getTrailingTwelveMonths from "@/utils/getTrailingTwelveMonths"; +import { zValidator } from "@hono/zod-validator"; import { and, asc, between, desc, eq, ilike } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); -app.get("/", async (c) => { +app.get("/", zValidator("query", CarQuerySchema), async (c) => { const query = c.req.query(); const { month, ...queries } = query; @@ -60,7 +63,7 @@ app.get("/", async (c) => { } }); -app.get("/months", async (c) => { +app.get("/months", zValidator("query", MonthsQuerySchema), async (c) => { const { grouped } = c.req.query(); const months = await getUniqueMonths(cars); @@ -73,9 +76,8 @@ app.get("/months", async (c) => { app.get("/makes", async (c) => { const CACHE_KEY = "makes"; - const CACHE_TTL = 60 * 60 * 24; // 1 day in seconds - let makes: Make[] = await redis.smembers(CACHE_KEY); + let makes = await redis.smembers(CACHE_KEY); if (makes.length === 0) { makes = await db diff --git a/src/v1/routes/coe.ts b/src/v1/routes/coe.ts index 1d3e0b2..e98173b 100644 --- a/src/v1/routes/coe.ts +++ b/src/v1/routes/coe.ts @@ -1,28 +1,24 @@ -import { DEFAULT_CACHE_TTL } from "@/config"; +import { CACHE_TTL } from "@/config"; import db from "@/config/db"; import redis from "@/config/redis"; import { getLatestMonth } from "@/lib/getLatestMonth"; import { getUniqueMonths } from "@/lib/getUniqueMonths"; import { groupMonthsByYear } from "@/lib/groupMonthsByYear"; import { coe } from "@/schema"; -import type { COEResult } from "@/types"; +import { type COE, COEQuerySchema, MonthsQuerySchema } from "@/schemas"; +import { zValidator } from "@hono/zod-validator"; import { and, asc, desc, eq, gte, lte } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); -const getCachedData = (cacheKey: string) => redis.get(cacheKey); - -const setCachedData = (cacheKey: string, data: T) => - redis.set(cacheKey, data, { ex: DEFAULT_CACHE_TTL }); - -app.get("/", async (c) => { +app.get("/", zValidator("query", COEQuerySchema), async (c) => { const query = c.req.query(); const { sort, orderBy, month, from, to } = query; const CACHE_KEY = `coe:${JSON.stringify(query)}`; - const cachedData = await getCachedData(CACHE_KEY); + const cachedData = await redis.get(CACHE_KEY); if (cachedData) { return c.json(cachedData); } @@ -39,12 +35,12 @@ app.get("/", async (c) => { .where(and(...filters)) .orderBy(desc(coe.month), asc(coe.bidding_no), asc(coe.vehicle_class)); - await setCachedData(CACHE_KEY, result); + await redis.set(CACHE_KEY, results, { ex: CACHE_TTL }); - return c.json(result); + return c.json(results); }); -app.get("/months", async (c) => { +app.get("/months", zValidator("query", MonthsQuerySchema), async (c) => { const { grouped } = c.req.query(); const months = await getUniqueMonths(coe); @@ -58,7 +54,7 @@ app.get("/months", async (c) => { app.get("/latest", async (c) => { const CACHE_KEY = "coe:latest"; - const cachedData = await getCachedData(CACHE_KEY); + const cachedData = await redis.get(CACHE_KEY); if (cachedData) { return c.json(cachedData); } @@ -70,9 +66,9 @@ app.get("/latest", async (c) => { .where(eq(coe.month, latestMonth)) .orderBy(asc(coe.bidding_no), asc(coe.vehicle_class)); - await setCachedData(CACHE_KEY, result); + await redis.set(CACHE_KEY, results, { ex: CACHE_TTL }); - return c.json(result); + return c.json(results); }); export default app; diff --git a/src/v1/routes/makes.ts b/src/v1/routes/makes.ts index 37e9812..02df94d 100644 --- a/src/v1/routes/makes.ts +++ b/src/v1/routes/makes.ts @@ -1,7 +1,9 @@ -import { DEFAULT_CACHE_TTL } from "@/config"; +import { CACHE_TTL } from "@/config"; import db from "@/config/db"; import redis from "@/config/redis"; import { cars } from "@/schema"; +import { MakeParamSchema, MakeQuerySchema } from "@/schemas"; +import { zValidator } from "@hono/zod-validator"; import { and, asc, desc, eq, ilike } from "drizzle-orm"; import { Hono } from "hono"; @@ -19,7 +21,7 @@ app.get("/", async (c) => { .then((res) => res.map(({ make }) => make)); await redis.sadd(CACHE_KEY, ...makes); - await redis.expire(CACHE_KEY, DEFAULT_CACHE_TTL); + await redis.expire(CACHE_KEY, CACHE_TTL); } makes.sort((a, b) => a.localeCompare(b)); @@ -27,33 +29,46 @@ app.get("/", async (c) => { return c.json(makes); }); -app.get("/:make", async (c) => { - const { make } = c.req.param(); - const { month, fuel_type, vehicle_type } = c.req.query(); +app.get( + "/:make", + zValidator("param", MakeParamSchema), + zValidator("query", MakeQuerySchema), + async (c) => { + try { + const param = c.req.valid("param"); + const { make } = param; + const query = c.req.valid("query"); + const { month, fuel_type, vehicle_type } = query; - const CACHE_KEY = `make:${make}`; + const CACHE_KEY = `make:${make}:${JSON.stringify(query)}`; - const cachedData = await redis.get(CACHE_KEY); - if (cachedData) { - return c.json(cachedData); - } + const cachedData = await redis.get(CACHE_KEY); + if (cachedData) { + return c.json(cachedData); + } - const filters = [ - ilike(cars.make, make.split("-").join("%")), - month && eq(cars.month, month), - fuel_type && ilike(cars.fuel_type, fuel_type.split("-").join("%")), - vehicle_type && ilike(cars.vehicle_type, vehicle_type.split("-").join("%")), - ].filter(Boolean); + const filters = [ + ilike(cars.make, make.split("-").join("%")), + month && eq(cars.month, month), + fuel_type && ilike(cars.fuel_type, fuel_type.split("-").join("%")), + vehicle_type && + ilike(cars.vehicle_type, vehicle_type.split("-").join("%")), + ].filter(Boolean); - const results = await db - .select() - .from(cars) - .where(and(...filters)) - .orderBy(desc(cars.month), asc(cars.fuel_type), asc(cars.vehicle_type)); + const results = await db + .select() + .from(cars) + .where(and(...filters)) + .orderBy(desc(cars.month), asc(cars.fuel_type), asc(cars.vehicle_type)); - await redis.set(CACHE_KEY, JSON.stringify(result), { ex: 86400 }); + await redis.set(CACHE_KEY, JSON.stringify(results), { ex: 86400 }); - return c.json(result); -}); + return c.json(results); + } catch (e) { + console.error(e); + return c.json({ error: e.message }, 500); + } + }, +); export default app; diff --git a/src/v1/routes/months.ts b/src/v1/routes/months.ts index 0ac2eee..48adc45 100644 --- a/src/v1/routes/months.ts +++ b/src/v1/routes/months.ts @@ -1,5 +1,7 @@ import { getLatestMonth } from "@/lib/getLatestMonth"; import { cars, coe } from "@/schema"; +import { LatestMonthQuerySchema } from "@/schemas"; +import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; const app = new Hono(); @@ -11,7 +13,7 @@ const TABLES_MAP = { const TABLES = Object.keys(TABLES_MAP); -app.get("/latest", async (c) => { +app.get("/latest", zValidator("query", LatestMonthQuerySchema), async (c) => { const { type } = c.req.query(); const tablesToCheck = type && TABLES.includes(type) ? [type] : TABLES;