Skip to content

Commit faa4ca1

Browse files
committed
Add Zod request and response validation
1 parent d5b055d commit faa4ca1

29 files changed

Lines changed: 994 additions & 349 deletions

dist/templates/api/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,35 @@ LaunchStack CLI
107107

108108
https://www.npmjs.com/package/launchstack-cli
109109

110+
## Request Validation
111+
112+
The starter uses Zod for request and response schemas.
113+
114+
Included validation:
115+
116+
- Registration payloads
117+
- Login payloads
118+
- Refresh-token payloads
119+
- Health responses
120+
- Authentication responses
121+
- Error responses
122+
123+
Fastify uses the Zod type provider for runtime validation and TypeScript inference.
124+
125+
## Validation Behaviour
126+
127+
Invalid request bodies are rejected automatically with HTTP 400 responses.
128+
129+
Examples include:
130+
131+
- Invalid email addresses
132+
- Passwords shorter than eight characters
133+
- Missing login credentials
134+
- Empty refresh tokens
135+
- Invalid response payloads during development and testing
136+
137+
Request types are inferred directly from Zod schemas, reducing duplication between runtime validation and TypeScript types.
138+
110139
## API Documentation
111140

112141
Swagger UI:

dist/templates/api/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"db:up": "docker compose up -d postgres",
1919
"db:down": "docker compose down",
2020
"db:logs": "docker compose logs -f postgres",
21-
"openapi:check": "npm run typecheck && npm test && npm run build"
21+
"openapi:check": "npm run typecheck && npm test && npm run build",
22+
"validation:check": "npm run typecheck && npm test && npm run build"
2223
},
2324
"dependencies": {
2425
"@fastify/cors": "^11.1.0",
@@ -30,7 +31,9 @@
3031
"fastify": "^5.6.1",
3132
"fastify-plugin": "^5.0.1",
3233
"@fastify/swagger": "^9.5.1",
33-
"@fastify/swagger-ui": "^5.2.3"
34+
"@fastify/swagger-ui": "^5.2.3",
35+
"fastify-type-provider-zod": "^5.1.0",
36+
"zod": "^4.1.12"
3437
},
3538
"devDependencies": {
3639
"@types/node": "^25.0.0",

dist/templates/api/src/app.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import Fastify, {
22
type FastifyInstance
33
} from "fastify";
4+
import {
5+
serializerCompiler,
6+
validatorCompiler,
7+
type ZodTypeProvider
8+
} from "fastify-type-provider-zod";
49
import { registerPlugins } from "./plugins";
510
import { registerRoutes } from "./routes";
611

@@ -10,7 +15,10 @@ export async function buildApp(): Promise<FastifyInstance> {
1015
level: process.env.LOG_LEVEL ?? "info"
1116
},
1217
requestIdHeader: "x-request-id"
13-
});
18+
}).withTypeProvider<ZodTypeProvider>();
19+
20+
app.setValidatorCompiler(validatorCompiler);
21+
app.setSerializerCompiler(serializerCompiler);
1422

1523
await registerPlugins(app);
1624
await registerRoutes(app);
Lines changed: 58 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,85 @@
1+
import { z } from "zod";
2+
import {
3+
authResponseSchema,
4+
tokenPairSchema,
5+
userResponseSchema
6+
} from "../../schemas/auth";
7+
import { errorResponseSchema } from "../../schemas/common";
8+
9+
export const registerBodySchema = z.object({
10+
email: z.string().email(),
11+
password: z.string().min(8),
12+
name: z.string().trim().min(1).max(100).optional()
13+
});
14+
15+
export const loginBodySchema = z.object({
16+
email: z.string().email(),
17+
password: z.string().min(1)
18+
});
19+
20+
export const refreshBodySchema = z.object({
21+
refreshToken: z.string().min(1)
22+
});
23+
124
export const registerSchema = {
225
tags: ["Authentication"],
326
summary: "Register a user",
4-
description: "Creates a user account and returns access and refresh tokens.",
5-
body: {
6-
type: "object",
7-
additionalProperties: false,
8-
required: ["email", "password"],
9-
properties: {
10-
email: { type: "string", format: "email", examples: ["user@example.com"] },
11-
password: { type: "string", minLength: 8, examples: ["strong-password"] },
12-
name: { type: "string", minLength: 1, maxLength: 100, examples: ["Example User"] }
13-
}
14-
},
27+
description:
28+
"Creates a user account and returns access and refresh tokens.",
29+
body: registerBodySchema,
1530
response: {
16-
201: { $ref: "AuthResponse#" },
17-
409: { $ref: "ErrorResponse#" }
31+
201: authResponseSchema,
32+
409: errorResponseSchema
1833
}
19-
} as const;
34+
};
2035

2136
export const loginSchema = {
2237
tags: ["Authentication"],
2338
summary: "Log in a user",
24-
description: "Authenticates credentials and returns access and refresh tokens.",
25-
body: {
26-
type: "object",
27-
additionalProperties: false,
28-
required: ["email", "password"],
29-
properties: {
30-
email: { type: "string", format: "email" },
31-
password: { type: "string", minLength: 1 }
32-
}
33-
},
39+
description:
40+
"Authenticates credentials and returns access and refresh tokens.",
41+
body: loginBodySchema,
3442
response: {
35-
200: { $ref: "AuthResponse#" },
36-
401: { $ref: "ErrorResponse#" }
43+
200: authResponseSchema,
44+
401: errorResponseSchema
3745
}
38-
} as const;
46+
};
3947

4048
export const refreshSchema = {
4149
tags: ["Authentication"],
4250
summary: "Refresh authentication tokens",
43-
description: "Rotates a valid refresh token and returns a new token pair.",
44-
body: {
45-
type: "object",
46-
additionalProperties: false,
47-
required: ["refreshToken"],
48-
properties: { refreshToken: { type: "string", minLength: 1 } }
49-
},
51+
description:
52+
"Rotates a valid refresh token and returns a new token pair.",
53+
body: refreshBodySchema,
5054
response: {
51-
200: { $ref: "TokenPair#" },
52-
401: { $ref: "ErrorResponse#" }
55+
200: tokenPairSchema,
56+
401: errorResponseSchema
5357
}
54-
} as const;
58+
};
5559

5660
export const logoutSchema = {
5761
tags: ["Authentication"],
5862
summary: "Log out a user",
59-
description: "Revokes the supplied refresh token.",
60-
body: {
61-
type: "object",
62-
additionalProperties: false,
63-
required: ["refreshToken"],
64-
properties: { refreshToken: { type: "string", minLength: 1 } }
65-
},
66-
response: { 204: { type: "null" } }
67-
} as const;
63+
description:
64+
"Revokes the supplied refresh token.",
65+
body: refreshBodySchema,
66+
response: {
67+
204: z.null()
68+
}
69+
};
6870

6971
export const meSchema = {
7072
tags: ["Authentication"],
7173
summary: "Get the authenticated user",
72-
description: "Returns the currently authenticated user's public profile.",
73-
security: [{ bearerAuth: [] }],
74+
description:
75+
"Returns the currently authenticated user's public profile.",
76+
security: [
77+
{
78+
bearerAuth: []
79+
}
80+
],
7481
response: {
75-
200: { $ref: "UserResponse#" },
76-
401: { $ref: "ErrorResponse#" }
82+
200: userResponseSchema,
83+
401: errorResponseSchema
7784
}
78-
} as const;
85+
};

dist/templates/api/src/modules/auth/auth.types.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import type { UserRole } from "@prisma/client";
2+
import type { z } from "zod";
3+
import type {
4+
loginBodySchema,
5+
refreshBodySchema,
6+
registerBodySchema
7+
} from "./auth.schemas";
28

3-
export type RegisterInput = {
4-
email: string;
5-
password: string;
6-
name?: string;
7-
};
9+
export type RegisterInput = z.infer<
10+
typeof registerBodySchema
11+
>;
812

9-
export type LoginInput = {
10-
email: string;
11-
password: string;
12-
};
13+
export type LoginInput = z.infer<
14+
typeof loginBodySchema
15+
>;
1316

14-
export type RefreshInput = {
15-
refreshToken: string;
16-
};
17+
export type RefreshInput = z.infer<
18+
typeof refreshBodySchema
19+
>;
1720

1821
export type AuthenticatedUser = {
1922
id: string;

dist/templates/api/src/plugins/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { configPlugin } from "./config";
44
import { corsPlugin } from "./cors";
55
import { databasePlugin } from "./database";
66
import { errorHandlerPlugin } from "./error-handler";
7-
import { schemasPlugin } from "./schemas";
87
import { sensiblePlugin } from "./sensible";
98
import { swaggerPlugin } from "./swagger";
109

11-
export async function registerPlugins(app: FastifyInstance): Promise<void> {
10+
export async function registerPlugins(
11+
app: FastifyInstance
12+
): Promise<void> {
1213
await app.register(configPlugin);
13-
await app.register(schemasPlugin);
1414
await app.register(swaggerPlugin);
1515
await app.register(databasePlugin);
1616
await app.register(sensiblePlugin);

dist/templates/api/src/plugins/schemas.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

dist/templates/api/src/plugins/swagger.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import swagger from "@fastify/swagger";
22
import swaggerUi from "@fastify/swagger-ui";
33
import fp from "fastify-plugin";
4+
import {
5+
jsonSchemaTransform,
6+
jsonSchemaTransformObject
7+
} from "fastify-type-provider-zod";
48

59
export const swaggerPlugin = fp(
610
async (app) => {
711
await app.register(swagger, {
12+
transform: jsonSchemaTransform,
13+
transformObject: jsonSchemaTransformObject,
814
openapi: {
915
info: {
1016
title: "{{PROJECT_DISPLAY_NAME}} API",
11-
description: "API documentation generated by LaunchStack CLI.",
17+
description:
18+
"API documentation generated by LaunchStack CLI.",
1219
version: "0.1.0"
1320
},
1421
servers: [
@@ -18,8 +25,14 @@ export const swaggerPlugin = fp(
1825
}
1926
],
2027
tags: [
21-
{ name: "System", description: "Health and operational endpoints" },
22-
{ name: "Authentication", description: "User authentication and token lifecycle" }
28+
{
29+
name: "System",
30+
description: "Health and operational endpoints"
31+
},
32+
{
33+
name: "Authentication",
34+
description: "User authentication and token lifecycle"
35+
}
2336
],
2437
components: {
2538
securitySchemes: {
@@ -44,5 +57,8 @@ export const swaggerPlugin = fp(
4457
transformStaticCSP: (header) => header
4558
});
4659
},
47-
{ name: "swagger", dependencies: ["config"] }
60+
{
61+
name: "swagger",
62+
dependencies: ["config"]
63+
}
4864
);

0 commit comments

Comments
 (0)