Skip to content

Commit 01c3a8d

Browse files
committed
Add native zod schemas and documntation for 3 modules
1 parent b6bb60d commit 01c3a8d

File tree

13 files changed

+583
-301
lines changed

13 files changed

+583
-301
lines changed

src/api/components/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { FastifySchema } from "fastify";
2+
import { z } from "zod";
3+
4+
export const ts = z.coerce
5+
.number()
6+
.min(0)
7+
.optional()
8+
.openapi({ description: "Staleness bound", example: 0 });
9+
export const groupId = z.string().min(1).openapi({
10+
description: "Entra ID Group ID",
11+
example: "d8cbb7c9-2f6d-4b7e-8ba6-b54f8892003b",
12+
});
13+
14+
export function withTags<T>(tags: string[], schema: T) {
15+
return {
16+
tags,
17+
...schema,
18+
};
19+
}

src/api/functions/auditLog.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@ import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
22
import { marshall } from "@aws-sdk/util-dynamodb";
33
import { genericConfig } from "common/config.js";
44
import { Modules } from "common/modules.js";
5-
6-
export type AuditLogEntry = {
7-
module: Modules;
8-
actor: string;
9-
target: string;
10-
requestId?: string;
11-
message: string;
12-
};
5+
import { AuditLogEntry } from "common/types/logs.js";
136

147
type AuditLogParams = {
158
dynamoClient?: DynamoDBClient;

src/api/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ import membershipPlugin from "./routes/membership.js";
2929
import path from "path"; // eslint-disable-line import/no-nodejs-modules
3030
import roomRequestRoutes from "./routes/roomRequests.js";
3131
import logsPlugin from "./routes/logs.js";
32+
import fastifySwagger from "@fastify/swagger";
33+
import fastifySwaggerUI from "@fastify/swagger-ui";
34+
import {
35+
type FastifyZodOpenApiSchema,
36+
type FastifyZodOpenApiTypeProvider,
37+
fastifyZodOpenApiPlugin,
38+
fastifyZodOpenApiTransform,
39+
fastifyZodOpenApiTransformObject,
40+
serializerCompiler,
41+
validatorCompiler,
42+
} from "fastify-zod-openapi";
43+
import { ZodOpenApiVersion } from "zod-openapi";
3244

3345
dotenv.config();
3446

@@ -85,6 +97,21 @@ async function init(prettyPrint: boolean = false) {
8597
await app.register(fastifyZodValidationPlugin);
8698
await app.register(FastifyAuthProvider);
8799
await app.register(errorHandlerPlugin);
100+
await app.register(fastifyZodOpenApiPlugin);
101+
await app.register(fastifySwagger, {
102+
openapi: {
103+
info: {
104+
title: "ACM @ UIUC Core API",
105+
version: "1.0.0",
106+
},
107+
openapi: "3.0.3" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0
108+
},
109+
transform: fastifyZodOpenApiTransform,
110+
transformObject: fastifyZodOpenApiTransformObject,
111+
});
112+
await app.register(fastifySwaggerUI, {
113+
routePrefix: "/api/documentation",
114+
});
88115
await app.register(fastifyStatic, {
89116
root: path.join(__dirname, "public"),
90117
prefix: "/",

src/api/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"@fastify/caching": "^9.0.1",
3030
"@fastify/cors": "^10.0.1",
3131
"@fastify/static": "^8.1.1",
32+
"@fastify/swagger": "^9.5.0",
33+
"@fastify/swagger-ui": "^5.2.2",
3234
"@middy/core": "^6.0.0",
3335
"@middy/event-normalizer": "^6.0.0",
3436
"@middy/sqs-partial-batch-failure": "^6.0.0",
@@ -40,6 +42,7 @@
4042
"fastify": "^5.1.0",
4143
"fastify-plugin": "^4.5.1",
4244
"fastify-raw-body": "^5.0.0",
45+
"fastify-zod-openapi": "^4.1.1",
4346
"ical-generator": "^7.2.0",
4447
"jsonwebtoken": "^9.0.2",
4548
"jwks-rsa": "^3.1.0",
@@ -53,6 +56,7 @@
5356
"stripe": "^17.6.0",
5457
"uuid": "^11.0.5",
5558
"zod": "^3.23.8",
59+
"zod-openapi": "^4.2.4",
5660
"zod-to-json-schema": "^3.23.2",
5761
"zod-validation-error": "^3.3.1"
5862
},

src/api/routes/events.ts

Lines changed: 75 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import "zod-openapi/extend";
12
import { FastifyPluginAsync, FastifyRequest } from "fastify";
23
import { AppRoles } from "../../common/roles.js";
34
import { z } from "zod";
@@ -32,6 +33,14 @@ import {
3233
} from "api/functions/cache.js";
3334
import { createAuditLogEntry } from "api/functions/auditLog.js";
3435
import { Modules } from "common/modules.js";
36+
import {
37+
FastifyPluginAsyncZodOpenApi,
38+
FastifyZodOpenApiSchema,
39+
FastifyZodOpenApiTypeProvider,
40+
serializerCompiler,
41+
validatorCompiler,
42+
} from "fastify-zod-openapi";
43+
import { ts, withTags } from "api/components/index.js";
3544

3645
const repeatOptions = ["weekly", "biweekly"] as const;
3746
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
@@ -54,77 +63,57 @@ const requestSchema = baseSchema.extend({
5463
repeatEnds: z.string().optional(),
5564
});
5665

57-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5866
const postRequestSchema = requestSchema.refine(
5967
(data) => (data.repeatEnds ? data.repeats !== undefined : true),
6068
{
6169
message: "repeats is required when repeatEnds is defined",
6270
},
6371
);
64-
6572
export type EventPostRequest = z.infer<typeof postRequestSchema>;
66-
type EventGetRequest = {
67-
Params: { id: string };
68-
Querystring: { ts?: number };
69-
Body: undefined;
70-
};
71-
72-
type EventDeleteRequest = {
73-
Params: { id: string };
74-
Querystring: undefined;
75-
Body: undefined;
76-
};
77-
78-
const responseJsonSchema = zodToJsonSchema(
79-
z.object({
80-
id: z.string(),
81-
resource: z.string(),
82-
}),
83-
);
8473

85-
// GET
8674
const getEventSchema = requestSchema.extend({
8775
id: z.string(),
8876
});
89-
9077
export type EventGetResponse = z.infer<typeof getEventSchema>;
91-
const getEventJsonSchema = zodToJsonSchema(getEventSchema);
9278

9379
const getEventsSchema = z.array(getEventSchema);
9480
export type EventsGetResponse = z.infer<typeof getEventsSchema>;
95-
type EventsGetRequest = {
96-
Body: undefined;
97-
Querystring?: {
98-
upcomingOnly?: boolean;
99-
featuredOnly?: boolean;
100-
host?: string;
101-
ts?: number;
102-
};
103-
};
10481

105-
const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
82+
const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
83+
fastify,
84+
_options,
85+
) => {
86+
fastify.setValidatorCompiler(validatorCompiler);
87+
fastify.setSerializerCompiler(serializerCompiler);
10688
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
10789
fastify.register(rateLimiter, {
10890
limit: 30,
10991
duration: 60,
11092
rateLimitIdentifier: "events",
11193
});
112-
fastify.get<EventsGetRequest>(
94+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
11395
"/",
11496
{
115-
schema: {
116-
querystring: {
117-
type: "object",
118-
properties: {
119-
upcomingOnly: { type: "boolean" },
120-
host: { type: "string" },
121-
ts: { type: "number" },
122-
},
123-
},
97+
schema: withTags(["Events"], {
98+
querystring: z.object({
99+
upcomingOnly: z.coerce.boolean().optional().openapi({
100+
description:
101+
"If true, only get events which end after the current time.",
102+
}),
103+
featuredOnly: z.coerce.boolean().optional().openapi({
104+
description:
105+
"If true, only get events which are marked as featured.",
106+
}),
107+
host: z
108+
.enum(OrganizationList as [string, ...string[]])
109+
.optional()
110+
.openapi({ description: "Event host filter." }),
111+
ts,
112+
}),
124113
response: { 200: getEventsSchema },
125-
},
114+
}),
126115
},
127-
async (request: FastifyRequest<EventsGetRequest>, reply) => {
116+
async (request, reply) => {
128117
const upcomingOnly = request.query?.upcomingOnly || false;
129118
const featuredOnly = request.query?.featuredOnly || false;
130119
const host = request.query?.host;
@@ -230,15 +219,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
230219
);
231220
};
232221

233-
fastify.post<{ Body: EventPostRequest }>(
222+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
234223
"/:id?",
235224
{
236-
schema: {
237-
response: { 201: responseJsonSchema },
238-
},
239-
preValidation: async (request, reply) => {
240-
await fastify.zodValidateBody(request, reply, postRequestSchema);
241-
},
225+
schema: withTags(["Events"], {
226+
response: {
227+
201: z.object({
228+
id: z.string(),
229+
resource: z.string(),
230+
}),
231+
},
232+
body: postRequestSchema,
233+
}) satisfies FastifyZodOpenApiSchema,
242234
onRequest: async (request, reply) => {
243235
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
244236
},
@@ -361,17 +353,28 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
361353
}
362354
},
363355
);
364-
fastify.delete<EventDeleteRequest>(
356+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().delete(
365357
"/:id",
366358
{
367-
schema: {
368-
response: { 201: responseJsonSchema },
369-
},
359+
schema: withTags(["Events"], {
360+
params: z.object({
361+
id: z.string().min(1).openapi({
362+
description: "Event ID to delete.",
363+
example: "6667e095-8b04-4877-b361-f636f459ba42",
364+
}),
365+
}),
366+
response: {
367+
201: z.object({
368+
id: z.string(),
369+
resource: z.string(),
370+
}),
371+
},
372+
}) satisfies FastifyZodOpenApiSchema,
370373
onRequest: async (request, reply) => {
371374
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
372375
},
373376
},
374-
async (request: FastifyRequest<EventDeleteRequest>, reply) => {
377+
async (request, reply) => {
375378
const id = request.params.id;
376379
if (!request.username) {
377380
throw new UnauthenticatedError({ message: "Username not found." });
@@ -421,20 +424,23 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
421424
);
422425
},
423426
);
424-
fastify.get<EventGetRequest>(
427+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
425428
"/:id",
426429
{
427-
schema: {
428-
querystring: {
429-
type: "object",
430-
properties: {
431-
ts: { type: "number" },
432-
},
433-
},
434-
response: { 200: getEventJsonSchema },
435-
},
430+
schema: withTags(["Events"], {
431+
params: z.object({
432+
id: z.string().min(1).openapi({
433+
description: "Event ID to delete.",
434+
example: "6667e095-8b04-4877-b361-f636f459ba42",
435+
}),
436+
}),
437+
querystring: z.object({
438+
ts,
439+
}),
440+
response: { 200: getEventSchema },
441+
}),
436442
},
437-
async (request: FastifyRequest<EventGetRequest>, reply) => {
443+
async (request, reply) => {
438444
const id = request.params.id;
439445
const ts = request.query?.ts;
440446

@@ -485,7 +491,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
485491
reply.header("etag", etag);
486492
}
487493

488-
return reply.send(item);
494+
return reply.send(item as z.infer<typeof getEventSchema>);
489495
} catch (e) {
490496
if (e instanceof BaseError) {
491497
throw e;

0 commit comments

Comments
 (0)