Upgrade dependencies and migrate from Joi to Zod for validation

This commit is contained in:
Juan Sebastián Montoya 2025-02-10 22:41:08 -05:00
parent 4fa0190e4b
commit a26d0c77a1
12 changed files with 498 additions and 287 deletions

View file

@ -1,18 +1,13 @@
import joi from "joi";
import { z } from "zod";
import { Config } from "../../types/config";
import { server } from "./server.config";
const envSchema = joi
.object({
NODE_ENV: joi
.string()
.allow("development", "production", "test")
.required(),
})
.unknown()
.required();
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
});
const { error, data: envVars } = envSchema.safeParse(process.env);
const { error, value: envVars } = envSchema.validate(process.env);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}

View file

@ -1,17 +1,14 @@
import joi from "joi";
import { z } from "zod";
import { ServerConfig } from "../../types/config";
const envSchema = joi
.object({
HOST: joi.string().default("localhost"),
PORT: joi.number().default(3000),
API_VERSION: joi.number().default(1),
ALLOWED_ORIGINS: joi.string().default("http://localhost:3000"),
})
.unknown()
.required();
const envSchema = z.object({
HOST: z.string().default("localhost"),
PORT: z.coerce.number().default(3000),
API_VERSION: z.coerce.number().default(1),
ALLOWED_ORIGINS: z.string().default("http://localhost:3000"),
});
const { error, value: envVars } = envSchema.validate(process.env);
const { error, data: envVars } = envSchema.safeParse(process.env);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}

View file

@ -4,7 +4,7 @@ import logger from "koa-logger";
import config from "./config";
import corsMiddleware from "./middlewares/cors.middleware";
import errorMiddleware from "./middlewares/error.middleware";
import avatarRoute from "./routes/avatar.route";
import routes, { healthRoute } from "./routes";
import { startServerLog } from "./utils/server";
const { server } = config;
@ -15,6 +15,7 @@ app.use(corsMiddleware());
app.use(logger());
app.use(errorMiddleware());
app.use(avatarRoute.routes()).use(avatarRoute.allowedMethods());
app.use(healthRoute.routes()).use(healthRoute.allowedMethods());
app.use(routes.routes()).use(routes.allowedMethods());
app.listen(server.port, server.host, startServerLog(server.port));

View file

@ -1,5 +1,5 @@
import Router from "koa-router";
import { isValidationError } from "../utils/type-guards";
import { ZodError } from "zod";
const errorMiddleware: () => Router.IMiddleware<any, {}> =
() => async (ctx, next) => {
@ -8,7 +8,7 @@ const errorMiddleware: () => Router.IMiddleware<any, {}> =
} catch (err) {
console.log("error middleware", err);
if (err && (err as any).status >= 500) console.log("Error handler:", err);
if (isValidationError(err)) {
if (err instanceof ZodError) {
ctx.status = 400;
} else {
ctx.status = (err as any).status || 500;

View file

@ -7,8 +7,7 @@ import * as openPeeps from "@dicebear/open-peeps";
import * as pixelArt from "@dicebear/pixel-art";
import * as thumbs from "@dicebear/thumbs";
import * as botttsNeutral from "@dicebear/bottts-neutral";
import { isValue, isSize, isStyle } from "../utils/type-guards";
import { z } from "zod";
const DENSITY = 72;
const avatars: Record<string, Style<any> & { defaultSize: number }> = {
@ -18,13 +17,19 @@ const avatars: Record<string, Style<any> & { defaultSize: number }> = {
thumbs: { ...thumbs, defaultSize: 100 },
};
const validStyles = Object.keys(avatars);
const isValidStyle = isStyle(validStyles);
const schema = z.object({
style: z.enum(Object.keys(avatars) as [string, ...string[]]).default("thumbs"),
size: z.number().min(16).max(512).default(128),
seed: z.string().min(1).max(256).default(uuidv4()),
});
const router = new Router();
router.get("/list", async (ctx) => {
const styles = validStyles.map((key) => ({ ...avatars[key].meta, key }));
const styles = Object.keys(avatars).map((key) => ({
...avatars[key].meta,
key,
}));
ctx.body = { styles };
});
@ -37,12 +42,8 @@ router.use(async (ctx, next) => {
});
router.get("/random", async (ctx) => {
const { style = "thumbs", size = "100" } = ctx.query;
if (!isValidStyle(style) || !isSize(size)) {
throw Error("Invalid query");
}
const seed = uuidv4();
const density = (DENSITY * parseInt(size)) / avatars[style].defaultSize;
const { style, size, seed } = schema.parse(ctx.query);
const density = (DENSITY * size) / avatars[style].defaultSize;
ctx.body = sharp(
Buffer.from(createAvatar(avatars[style], { seed }).toString()),
{ density }
@ -50,12 +51,11 @@ router.get("/random", async (ctx) => {
});
router.get("/:seed", async (ctx) => {
const { seed } = ctx.params;
const { style = "thumbs", size = "100" } = ctx.query;
if (!isValue(seed) || !isValidStyle(style) || !isSize(size)) {
throw Error("Invalid query");
}
const density = (DENSITY * parseInt(size)) / avatars[style].defaultSize;
const { style, size, seed } = schema.parse({
...ctx.query,
...ctx.params,
});
const density = (DENSITY * size) / avatars[style].defaultSize;
ctx.body = sharp(
Buffer.from(createAvatar(avatars[style], { seed }).toString()),
{

View file

@ -0,0 +1,10 @@
import Router from 'koa-router';
const router = new Router();
router.get("/health", async (ctx) => {
ctx.status = 200;
ctx.body = "healthy";
});
export default router;

15
src/routes/index.ts Normal file
View file

@ -0,0 +1,15 @@
import Router from 'koa-router';
import { readdirSync } from 'fs';
const router = new Router<any, any>();
readdirSync(__dirname)
.filter((file) => file.includes('.route.') && !file.startsWith('health.'))
.forEach((file) => {
const routesFile = require(`${__dirname}/${file}`).default;
router.use(routesFile.routes(), routesFile.allowedMethods());
});
export default router;
export { default as healthRoute } from './health.route';

View file

@ -1,19 +0,0 @@
import Joi from "joi";
export const isValidationError = (
error: unknown
): error is Joi.ValidationError => error instanceof Joi.ValidationError;
export const isValue = <T>(value: T | Array<T> | undefined): value is T =>
!!value && !Array.isArray(value);
export const isSize = (value: unknown): value is string =>
!Array.isArray(value) &&
!isNaN(parseInt(value as string)) &&
parseInt(value as string) <= 512 &&
parseInt(value as string) >= 16;
export const isStyle =
(validStyles: string[]) =>
<T>(value: T | Array<T> | undefined): value is T =>
!!value && !Array.isArray(value) && validStyles.includes(value as string);