Skip to content

Commit 0ccac57

Browse files
committed
feat(api-docs): enhance route documentation with Zod schema validation and OpenAPI integration
1 parent e314480 commit 0ccac57

File tree

2 files changed

+218
-77
lines changed

2 files changed

+218
-77
lines changed

docs/development/backend/api-security.mdx

Lines changed: 137 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,62 @@ Understanding Fastify's hook execution order is essential for proper security im
5757

5858
```typescript
5959
import { requireGlobalAdmin } from '../../../middleware/roleMiddleware';
60+
import { z } from 'zod';
61+
import { createSchema } from 'zod-openapi';
6062

61-
export default async function secureRoute(fastify: FastifyInstance) {
62-
fastify.post<{ Body: RequestInput }>('/protected-endpoint', {
63+
// Define Zod schemas
64+
const RequestSchema = z.object({
65+
name: z.string().min(1, 'Name is required'),
66+
value: z.string()
67+
});
68+
69+
const SuccessResponseSchema = z.object({
70+
success: z.boolean(),
71+
message: z.string()
72+
});
73+
74+
const ErrorResponseSchema = z.object({
75+
success: z.boolean().default(false),
76+
error: z.string()
77+
});
78+
79+
export default async function secureRoute(server: FastifyInstance) {
80+
server.post('/protected-endpoint', {
81+
preValidation: requireGlobalAdmin(), // ✅ CORRECT: Runs before validation
6382
schema: {
6483
tags: ['Protected'],
6584
summary: 'Protected endpoint',
6685
description: 'Requires admin permissions',
6786
security: [{ cookieAuth: [] }],
68-
body: createSchema(RequestSchema),
87+
88+
// Fastify validation schema
89+
body: {
90+
type: 'object',
91+
properties: {
92+
name: { type: 'string', minLength: 1 },
93+
value: { type: 'string' }
94+
},
95+
required: ['name', 'value'],
96+
additionalProperties: false
97+
},
98+
99+
// createSchema() for OpenAPI documentation
100+
requestBody: {
101+
required: true,
102+
content: {
103+
'application/json': {
104+
schema: createSchema(RequestSchema)
105+
}
106+
}
107+
},
108+
69109
response: {
70110
200: createSchema(SuccessResponseSchema.describe('Success')),
71111
401: createSchema(ErrorResponseSchema.describe('Unauthorized')),
72112
403: createSchema(ErrorResponseSchema.describe('Forbidden')),
73113
400: createSchema(ErrorResponseSchema.describe('Bad Request'))
74114
}
75-
},
76-
preValidation: requireGlobalAdmin(), // ✅ CORRECT: Runs before validation,
115+
}
77116
}, async (request, reply) => {
78117
// If we reach here, user is authorized AND input is validated
79118
const validatedData = request.body;
@@ -85,14 +124,19 @@ export default async function secureRoute(fastify: FastifyInstance) {
85124
### ❌ Insecure Pattern: preHandler for Authorization
86125

87126
```typescript
88-
export default async function insecureRoute(fastify: FastifyInstance) {
89-
fastify.post<{ Body: RequestInput }>('/protected-endpoint', {
127+
export default async function insecureRoute(server: FastifyInstance) {
128+
server.post('/protected-endpoint', {
90129
schema: {
91130
// Schema definition...
92-
body: zodToJsonSchema(RequestSchema, {
93-
$refStrategy: 'none',
94-
target: 'openApi3'
95-
})
131+
body: {
132+
type: 'object',
133+
properties: {
134+
name: { type: 'string', minLength: 1 },
135+
value: { type: 'string' }
136+
},
137+
required: ['name', 'value'],
138+
additionalProperties: false
139+
}
96140
},
97141
preHandler: requireGlobalAdmin(), // ❌ WRONG: Runs after validation
98142
}, async (request, reply) => {
@@ -161,22 +205,24 @@ For endpoints that support both web users (cookies) and CLI users (OAuth2 Bearer
161205
```typescript
162206
import { requireAuthenticationAny, requireOAuthScope } from '../../middleware/oauthMiddleware';
163207

164-
fastify.get('/dual-auth-endpoint', {
165-
schema: {
166-
security: [
167-
{ cookieAuth: [] }, // Cookie authentication
168-
{ bearerAuth: [] } // OAuth2 Bearer token
169-
]
170-
},
171-
preValidation: [
172-
requireAuthenticationAny(), // Accept either auth method
173-
requireOAuthScope('your:scope') // Enforce OAuth2 scope
174-
]
175-
}, async (request, reply) => {
176-
// Endpoint accessible via both authentication methods
177-
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
178-
const userId = request.user!.id;
179-
});
208+
export default async function dualAuthRoute(server: FastifyInstance) {
209+
server.get('/dual-auth-endpoint', {
210+
preValidation: [
211+
requireAuthenticationAny(), // Accept either auth method
212+
requireOAuthScope('your:scope') // Enforce OAuth2 scope
213+
],
214+
schema: {
215+
security: [
216+
{ cookieAuth: [] }, // Cookie authentication
217+
{ bearerAuth: [] } // OAuth2 Bearer token
218+
]
219+
}
220+
}, async (request, reply) => {
221+
// Endpoint accessible via both authentication methods
222+
const authType = request.tokenPayload ? 'oauth2' : 'cookie';
223+
const userId = request.user!.id;
224+
});
225+
}
180226
```
181227

182228
For detailed OAuth2 implementation, see the [Backend OAuth Implementation Guide](/development/backend/oauth-providers) and [Backend Security Policy](/development/backend/security#oauth2-server-security).
@@ -187,29 +233,68 @@ For endpoints that operate within team contexts (e.g., `/teams/:teamId/resource`
187233

188234
```typescript
189235
import { requireTeamPermission } from '../../../middleware/roleMiddleware';
236+
import { z } from 'zod';
237+
import { createSchema } from 'zod-openapi';
238+
239+
const CreateResourceSchema = z.object({
240+
name: z.string().min(1, 'Name is required'),
241+
description: z.string().optional()
242+
});
243+
244+
const SuccessResponseSchema = z.object({
245+
success: z.boolean(),
246+
message: z.string()
247+
});
248+
249+
const ErrorResponseSchema = z.object({
250+
success: z.boolean().default(false),
251+
error: z.string()
252+
});
190253

191-
export default async function teamResourceRoute(fastify: FastifyInstance) {
192-
fastify.post<{
193-
Params: { teamId: string };
194-
Body: CreateResourceRequest;
195-
}>('/teams/:teamId/resources', {
254+
export default async function teamResourceRoute(server: FastifyInstance) {
255+
server.post('/teams/:teamId/resources', {
256+
preValidation: requireTeamPermission('resources.create'), // ✅ Team-aware authorization
196257
schema: {
197258
tags: ['Team Resources'],
198259
summary: 'Create team resource',
199260
description: 'Creates a resource within the specified team context',
200261
security: [{ cookieAuth: [] }],
201-
params: zodToJsonSchema(z.object({
202-
teamId: z.string().min(1, 'Team ID is required')
203-
})),
204-
body: zodToJsonSchema(CreateResourceSchema),
262+
263+
params: {
264+
type: 'object',
265+
properties: {
266+
teamId: { type: 'string', minLength: 1 }
267+
},
268+
required: ['teamId'],
269+
additionalProperties: false
270+
},
271+
272+
body: {
273+
type: 'object',
274+
properties: {
275+
name: { type: 'string', minLength: 1 },
276+
description: { type: 'string' }
277+
},
278+
required: ['name'],
279+
additionalProperties: false
280+
},
281+
282+
requestBody: {
283+
required: true,
284+
content: {
285+
'application/json': {
286+
schema: createSchema(CreateResourceSchema)
287+
}
288+
}
289+
},
290+
205291
response: {
206-
201: zodToJsonSchema(SuccessResponseSchema),
207-
401: zodToJsonSchema(ErrorResponseSchema.describe('Unauthorized')),
208-
403: zodToJsonSchema(ErrorResponseSchema.describe('Forbidden - Not team member or insufficient permissions')),
209-
400: zodToJsonSchema(ErrorResponseSchema.describe('Bad Request'))
292+
201: createSchema(SuccessResponseSchema),
293+
401: createSchema(ErrorResponseSchema.describe('Unauthorized')),
294+
403: createSchema(ErrorResponseSchema.describe('Forbidden - Not team member or insufficient permissions')),
295+
400: createSchema(ErrorResponseSchema.describe('Bad Request'))
210296
}
211-
},
212-
preValidation: requireTeamPermission('resources.create'), // ✅ Team-aware authorization
297+
}
213298
}, async (request, reply) => {
214299
const { teamId } = request.params;
215300
const resourceData = request.body;
@@ -358,21 +443,21 @@ team_user: [
358443

359444
```typescript
360445
// Global admin only
361-
fastify.delete('/admin/users/:id', {
362-
schema: { /* ... */ },
446+
server.delete('/admin/users/:id', {
363447
preValidation: requireGlobalAdmin(),
448+
schema: { /* ... */ }
364449
}, handler);
365450

366451
// Specific permission required
367-
fastify.post('/settings/bulk', {
368-
schema: { /* ... */ },
452+
server.post('/settings/bulk', {
369453
preValidation: requirePermission('settings.edit'),
454+
schema: { /* ... */ }
370455
}, handler);
371456

372457
// User can access own data OR admin can access any
373-
fastify.get('/users/:id/profile', {
374-
schema: { /* ... */ },
458+
server.get('/users/:id/profile', {
375459
preValidation: requireOwnershipOrAdmin(getUserIdFromParams),
460+
schema: { /* ... */ }
376461
}, handler);
377462
```
378463

@@ -441,13 +526,13 @@ For complex authorization requirements:
441526

442527
```typescript
443528
// Multiple checks in sequence
444-
fastify.post('/complex-endpoint', {
445-
schema: { /* ... */ },
529+
server.post('/complex-endpoint', {
446530
preValidation: [
447531
requireAuthentication(), // Must be logged in
448532
requireRole('team_member'), // Must have team role
449533
requirePermission('data.write') // Must have write permission
450534
],
535+
schema: { /* ... */ }
451536
}, handler);
452537
```
453538

@@ -465,9 +550,9 @@ async function conditionalAuth(request: FastifyRequest, reply: FastifyReply) {
465550
}
466551
}
467552

468-
fastify.post('/conditional-endpoint', {
469-
schema: { /* ... */ },
553+
server.post('/conditional-endpoint', {
470554
preValidation: conditionalAuth,
555+
schema: { /* ... */ }
471556
}, handler);
472557
```
473558

0 commit comments

Comments
 (0)