Skip to content

Commit

Permalink
Add zod validator
Browse files Browse the repository at this point in the history
  • Loading branch information
ruchernchong committed Dec 24, 2024
1 parent 8a4935e commit 33370ae
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 47 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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\))?$`,
Expand Down
98 changes: 98 additions & 0 deletions src/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MakeParamSchema>;
export type MakeQuery = z.infer<typeof MakeQuerySchema>;
export type CarQuery = z.infer<typeof CarQuerySchema>;
export type MonthsQuery = z.infer<typeof MonthsQuerySchema>;
export type COEQuery = z.infer<typeof COEQuerySchema>;
export type LatestMonthQuery = z.infer<typeof LatestMonthQuerySchema>;

export type Car = z.infer<typeof CarSchema>;
export type COE = z.infer<typeof COESchema>;
export type LatestMonthResponse = z.infer<typeof LatestMonthResponseSchema>;
export type MonthsByYear = z.infer<typeof MonthsByYearSchema>;
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface Car {
selected?: boolean;
}

export interface COEResult {
export interface COE {
month: string;
bidding_no: string;
vehicle_class: string;
Expand Down
10 changes: 6 additions & 4 deletions src/v1/routes/cars.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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<Make[]>(CACHE_KEY);

if (makes.length === 0) {
makes = await db
Expand Down
26 changes: 11 additions & 15 deletions src/v1/routes/coe.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(cacheKey: string) => redis.get<T>(cacheKey);

const setCachedData = <T>(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<COEResult[]>(CACHE_KEY);
const cachedData = await redis.get<COE[]>(CACHE_KEY);
if (cachedData) {
return c.json(cachedData);
}
Expand All @@ -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);
Expand All @@ -58,7 +54,7 @@ app.get("/months", async (c) => {
app.get("/latest", async (c) => {
const CACHE_KEY = "coe:latest";

const cachedData = await getCachedData<COEResult[]>(CACHE_KEY);
const cachedData = await redis.get<COE[]>(CACHE_KEY);
if (cachedData) {
return c.json(cachedData);
}
Expand All @@ -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;
63 changes: 39 additions & 24 deletions src/v1/routes/makes.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -19,41 +21,54 @@ 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));

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;
Loading

0 comments on commit 33370ae

Please sign in to comment.