From 5999c8f40147c6a96837f98edc651f0f5b99ca34 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 21:05:26 -0400 Subject: [PATCH 1/7] New API docs --- src/api/createSwagger.ts | 47 ++++++++++++++++++++----------------- src/common/types/generic.ts | 5 ++-- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/api/createSwagger.ts b/src/api/createSwagger.ts index 1404e590..5dbb2ab8 100644 --- a/src/api/createSwagger.ts +++ b/src/api/createSwagger.ts @@ -4,27 +4,32 @@ import { writeFile, mkdir } from "fs/promises"; import init from "./index.js"; // Assuming this is your Fastify app initializer const html = ` - - - - - - - ACM @ UIUC Core API - - - -
- - - + + + + ACM @ UIUC Core API Reference + + + + + +
+ + + + + + + `; /** diff --git a/src/common/types/generic.ts b/src/common/types/generic.ts index ed03ec80..ef9b697f 100644 --- a/src/common/types/generic.ts +++ b/src/common/types/generic.ts @@ -3,13 +3,12 @@ import * as z from "zod/v4"; export const illinoisSemesterId = z .string() - .min(1) - .max(4) + .length(4) .regex(/^(fa|sp|su|wi)\d{2}$/) .meta({ description: "Short semester slug for a given semester.", id: "IllinoisSemesterId", - examples: ["sp25", "fa24"], + example: "fa24", }); export const illinoisNetId = z From 12296b8d02143a4c376b79548391eaee5aa23f8b Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 21:06:51 -0400 Subject: [PATCH 2/7] add timeouts to gh actions --- .github/workflows/deploy-dev.yml | 3 +++ .github/workflows/deploy-prod.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index b50997a5..615ca256 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -10,6 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 name: Run Unit Tests steps: - uses: actions/checkout@v4 @@ -35,6 +36,7 @@ jobs: build: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 name: Build Application steps: - uses: actions/checkout@v4 @@ -74,6 +76,7 @@ jobs: deploy-test-dev: runs-on: ubuntu-latest + timeout-minutes: 30 permissions: id-token: write contents: read diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 806c37d8..d8c01541 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -9,6 +9,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 name: Run Unit Tests steps: - uses: actions/checkout@v4 @@ -34,6 +35,7 @@ jobs: build: runs-on: ubuntu-24.04-arm + timeout-minutes: 15 name: Build Application steps: - uses: actions/checkout@v4 @@ -73,6 +75,7 @@ jobs: deploy-prod: runs-on: ubuntu-latest + timeout-minutes: 30 name: Deploy to Prod and Run Health Check concurrency: group: ${{ github.event.repository.name }}-prod From 654bc408854b7f38f329040a8fc5c0a16ff8c159 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 21:58:07 -0400 Subject: [PATCH 3/7] stuff --- src/api/index.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/api/index.ts b/src/api/index.ts index 286f4c52..7a75ff42 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -100,6 +100,12 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { return event.requestContext.requestId; }, }); + if (!process.env.RunEnvironment) { + process.env.RunEnvironment = "dev"; + } + app.runEnvironment = process.env.RunEnvironment as RunEnvironment; + app.environmentConfig = + environmentConfig[app.runEnvironment as RunEnvironment]; app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); @@ -111,8 +117,9 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { openapi: { info: { title: "ACM @ UIUC Core API", - description: "ACM @ UIUC Core Management Platform", - version: "1.0.0", + description: + "The ACM @ UIUC Core API provides services for managing chapter operations.", + version: "1.1.0", contact: { name: "ACM @ UIUC Infrastructure Team", email: "infra@acm.illinois.edu", @@ -127,12 +134,8 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { }, servers: [ { - url: "https://core.acm.illinois.edu", - description: "Production API server", - }, - { - url: "https://core.aws.qa.acmuiuc.org", - description: "QA API server", + url: app.environmentConfig.UserFacingUrl, + description: "Main API server", }, ], @@ -215,6 +218,7 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { }); isSwaggerServer = true; } catch (e) { + app.log.error(e); app.log.warn("Fastify Swagger not created!"); } } @@ -229,9 +233,6 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { root: path.join(__dirname, "public"), prefix: "/", }); - if (!process.env.RunEnvironment) { - process.env.RunEnvironment = "dev"; - } if (isRunningInLambda && !isSwaggerServer) { // Serve docs from S3 app.get("/api/documentation", (_request, response) => { @@ -264,9 +265,6 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) { "Audit logging to Dynamo is disabled! Audit log statements will be logged to the console.", ); } - app.runEnvironment = process.env.RunEnvironment as RunEnvironment; - app.environmentConfig = - environmentConfig[app.runEnvironment as RunEnvironment]; app.nodeCache = new NodeCache({ checkperiod: 30 }); if (initClients) { app.dynamoClient = new DynamoDBClient({ From a724f9c7cc5c1b4c54f277e2032d7aba30eb1cc4 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 23:05:57 -0400 Subject: [PATCH 4/7] add docs --- src/api/components/index.ts | 86 +++++++++++++++++++++++++++++---- src/api/createLambdaPackage.js | 4 ++ src/api/package.json | 4 +- src/api/routes/events.ts | 63 +++++++++++++++++++++--- src/api/routes/organizations.ts | 13 +++++ 5 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/api/components/index.ts b/src/api/components/index.ts index 7525fa7d..da14ef60 100644 --- a/src/api/components/index.ts +++ b/src/api/components/index.ts @@ -28,16 +28,6 @@ export const acmCoreOrganization = z examples: ["ACM", "Infrastructure Committee"], }); -export function withTags( - tags: string[], - schema: T, -) { - return { - tags, - ...schema, - }; -} - export type RoleSchema = { "x-required-roles": AppRoles[]; "x-disable-api-key-auth": boolean; @@ -48,6 +38,60 @@ type RolesConfig = { disableApiKeyAuth: boolean; }; +export const notAuthenticatedError = z + .object({ + name: z.literal("UnauthenticatedError"), + id: z.literal(102), + message: z.string().min(1), + }) + .meta({ + id: "notAuthenticatedError", + description: "Request not authenticated.", + }); + +export const notFoundError = z + .object({ + name: z.literal("NotFoundError"), + id: z.literal(103), + message: z.string().min(1), + }) + .meta({ + id: "notFoundError", + description: "Resource was not found.", + example: { + name: "NotFoundError", + id: 103, + message: "{url} is not a valid URL.", + }, + }); + +export const notAuthorizedError = z + .object({ + name: z.literal("UnauthorizedError"), + id: z.literal(101), + message: z.string().min(1), + }) + .meta({ + id: "notAuthorizedError", + description: "Request not authorized (lacking permissions).", + }); + +export const internalServerError = z + .object({ + name: z.literal("InternalServerError"), + id: z.literal(100), + message: z + .string() + .min(1) + .default( + "An internal server error occurred. Please try again or contact support.", + ), + }) + .meta({ + id: "internalServerError", + description: "The server encountered an error processing the request.", + }); + export function withRoles( roles: AppRoles[], schema: T, @@ -57,6 +101,11 @@ export function withRoles( if (!disableApiKeyAuth) { security.push({ apiKeyAuth: [] }); } + const responses = { + 401: notAuthorizedError, + 403: notAuthenticatedError, + ...schema.response, + }; return { security, "x-required-roles": roles, @@ -66,5 +115,22 @@ export function withRoles( ? `${disableApiKeyAuth ? "API key authentication is not permitted for this route.\n\n" : ""}Requires one of the following roles: ${roles.join(", ")}.${schema.description ? `\n\n${schema.description}` : ""}` : "Requires valid authentication but no specific role.", ...schema, + response: responses, + }; +} + +export function withTags( + tags: string[], + schema: T, +) { + const responses = { + 200: z.object({}), + 500: internalServerError, + ...schema.response, + }; + return { + tags, + ...schema, + response: responses, }; } diff --git a/src/api/createLambdaPackage.js b/src/api/createLambdaPackage.js index 4d9b542c..31c77450 100644 --- a/src/api/createLambdaPackage.js +++ b/src/api/createLambdaPackage.js @@ -15,6 +15,10 @@ export const packagesToTransfer = [ "passkit-generator", "argon2", "ioredis", + "fastify-zod-openapi", + "@fastify/swagger", + "zod-openapi", + "zod", ]; const filePath = `${getPath().dirname}/package.json`; const writeFilePath = `${getPath().dirname}/package.lambda.json`; diff --git a/src/api/package.json b/src/api/package.json index 3c3d7b24..498fb82f 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -59,10 +59,10 @@ "stripe": "^18.0.0", "uuid": "^11.1.0", "zod": "^3.25.73", - "zod-validation-error": "^3.3.1" + "zod-validation-error": "^3.3.1", + "@fastify/swagger": "^9.5.0" }, "devDependencies": { - "@fastify/swagger": "^9.5.0", "@fastify/swagger-ui": "^5.2.2", "@tsconfig/node22": "^22.0.1", "@types/aws-lambda": "^8.10.149", diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index 84b64653..5addcb6a 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -40,6 +40,7 @@ import { } from "fastify-zod-openapi"; import { acmCoreOrganization, + notFoundError, ts, withRoles, withTags, @@ -178,7 +179,16 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( includeMetadata: zodIncludeMetadata, }), summary: "Retrieve calendar events with applied filters.", - // response: { 200: getEventsSchema }, + response: { + 200: { + content: { + "application/json": { + schema: z.array(getEventSchema), + description: "Event data matching specified filter.", + }, + }, + }, + }, }), }, async (request, reply) => { @@ -315,6 +325,17 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( example: "6667e095-8b04-4877-b361-f636f459ba42", }), }), + response: { + 201: { + description: "Event modified.", + content: { + "application/json": { + schema: z.null(), + }, + }, + }, + 404: notFoundError, + }, summary: "Modify a calendar event.", }), ) satisfies FastifyZodOpenApiSchema, @@ -472,6 +493,17 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( [AppRoles.EVENTS_MANAGER], withTags(["Events"], { body: postRequestSchema, + response: { + 201: { + description: + "Event created. The 'Location' header specifies the URL of the created event.", + content: { + "application/json": { + schema: z.null(), + }, + }, + }, + }, summary: "Create a calendar event.", }), ) satisfies FastifyZodOpenApiSchema, @@ -583,12 +615,17 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( example: "6667e095-8b04-4877-b361-f636f459ba42", }), }), - // response: { - // 201: z.object({ - // id: z.string(), - // resource: z.string(), - // }), - // }, + response: { + 204: { + description: "Event deleted.", + content: { + "application/json": { + schema: z.null(), + }, + }, + }, + 404: notFoundError, + }, summary: "Delete a calendar event.", }), ) satisfies FastifyZodOpenApiSchema, @@ -689,8 +726,18 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( ts, includeMetadata: zodIncludeMetadata, }), + response: { + 200: { + description: "Event data.", + content: { + "application/json": { + schema: getEventSchema, + }, + }, + }, + 404: notFoundError, + }, summary: "Retrieve a calendar event.", - // response: { 200: getEventSchema }, }), }, async (request, reply) => { diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 830d7de1..b958784b 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -3,6 +3,7 @@ import { AllOrganizationList } from "@acm-uiuc/js-shared"; import fastifyCaching from "@fastify/caching"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withTags } from "api/components/index.js"; +import { z } from "zod/v4"; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.register(fastifyCaching, { @@ -20,6 +21,18 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { { schema: withTags(["Generic"], { summary: "Get a list of ACM @ UIUC sub-organizations.", + response: { + 200: { + description: "List of ACM @ UIUC sub-organizations.", + content: { + "application/json": { + schema: z + .enum(AllOrganizationList) + .default(JSON.stringify(AllOrganizationList)), + }, + }, + }, + }, }), }, async (_request, reply) => { From 709082f43da813a35d5193efb9d7a65e84581121 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 23:36:06 -0400 Subject: [PATCH 5/7] fix build --- src/api/components/index.ts | 170 ++++++++++++++++++++++++----------- src/api/routes/membership.ts | 33 +++++++ 2 files changed, 152 insertions(+), 51 deletions(-) diff --git a/src/api/components/index.ts b/src/api/components/index.ts index da14ef60..9430b2d6 100644 --- a/src/api/components/index.ts +++ b/src/api/components/index.ts @@ -38,59 +38,127 @@ type RolesConfig = { disableApiKeyAuth: boolean; }; -export const notAuthenticatedError = z - .object({ - name: z.literal("UnauthenticatedError"), - id: z.literal(102), - message: z.string().min(1), - }) - .meta({ - id: "notAuthenticatedError", - description: "Request not authenticated.", - }); - -export const notFoundError = z - .object({ - name: z.literal("NotFoundError"), - id: z.literal(103), - message: z.string().min(1), - }) - .meta({ - id: "notFoundError", - description: "Resource was not found.", - example: { - name: "NotFoundError", - id: 103, - message: "{url} is not a valid URL.", +export function getCorrectJsonSchema({ + schema, + example, + description, +}: { + schema: T; + example: U; + description: string; +}) { + return { + description, + content: { + "application/json": { + example, + schema, + }, }, - }); + }; +} -export const notAuthorizedError = z - .object({ - name: z.literal("UnauthorizedError"), - id: z.literal(101), - message: z.string().min(1), - }) - .meta({ - id: "notAuthorizedError", - description: "Request not authorized (lacking permissions).", - }); +export const notAuthenticatedError = getCorrectJsonSchema({ + schema: z + .object({ + name: z.literal("UnauthenticatedError"), + id: z.literal(102), + message: z.string().min(1), + }) + .meta({ + id: "notAuthenticatedError", + }), + description: "The request could not be authenticated.", + example: { + name: "UnauthenticatedError", + id: 102, + message: "Token not found.", + }, +}); -export const internalServerError = z - .object({ - name: z.literal("InternalServerError"), - id: z.literal(100), - message: z - .string() - .min(1) - .default( - "An internal server error occurred. Please try again or contact support.", - ), - }) - .meta({ - id: "internalServerError", - description: "The server encountered an error processing the request.", - }); +export const notFoundError = getCorrectJsonSchema({ + schema: z + .object({ + name: z.literal("NotFoundError"), + id: z.literal(103), + message: z.string().min(1), + }) + .meta({ + id: "notFoundError", + }), + description: "The resource could not be found.", + example: { + name: "NotFoundError", + id: 103, + message: "{url} is not a valid URL.", + }, +}); + +export const notAuthorizedError = getCorrectJsonSchema({ + schema: z + .object({ + name: z.literal("UnauthorizedError"), + id: z.literal(101), + message: z.string().min(1), + }) + .meta({ + id: "notAuthorizedError", + }), + description: + "The caller does not have the appropriate permissions for this task.", + example: { + name: "UnauthorizedError", + id: 101, + message: "User does not have the privileges for this task.", + }, +}); + +export const internalServerError = getCorrectJsonSchema({ + schema: { + content: { + "application/json": { + schema: z + .object({ + name: z.literal("InternalServerError"), + id: z.literal(100), + message: z.string().min(1), + }) + .meta({ + id: "internalServerError", + description: + "The server encountered an error processing the request.", + }), + }, + }, + }, + description: "The server encountered an error.", + example: { + name: "InternalServerError", + id: 100, + message: + "An internal server error occurred. Please try again or contact support.", + }, +}); + +export const rateLimitExceededError = getCorrectJsonSchema({ + schema: z + .object({ + name: z.literal("RateLimitExceededError"), + id: z.literal(409), + message: z.literal("Rate limit exceeded."), + }) + .meta({ + id: "RateLimitExceededError", + description: + "You have sent too many requests. Check the response headers and try again.", + }), + description: "The request exceeeds the rate limit.", + example: { + name: "RateLimitExceededError", + id: 409, + message: "Rate limit exceeded.", + }, +}); export function withRoles( roles: AppRoles[], @@ -124,8 +192,8 @@ export function withTags( schema: T, ) { const responses = { - 200: z.object({}), 500: internalServerError, + 429: rateLimitExceededError, ...schema.response, }; return { diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 6b0c682b..7a2c845f 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -78,6 +78,19 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { params: z.object({ netId: illinoisNetId }), summary: "Create a checkout session to purchase an ACM @ UIUC membership.", + response: { + 200: { + description: "Stripe checkout link.", + content: { + "text/plain": { + schema: z.url().meta({ + example: + "https://buy.stripe.com/test_14A00j9Hq9tj9ZfchM3AY0s", + }), + }, + }, + }, + }, }), }, async (request, reply) => { @@ -204,6 +217,26 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { }), summary: "Check ACM @ UIUC paid membership (or partner organization membership) status.", + response: { + 200: { + description: "List membership status.", + content: { + "application/json": { + schema: z + .object({ + netId: illinoisNetId, + isPaidMember: z.boolean(), + }) + .meta({ + example: { + netId: "rjjones", + isPaidMember: false, + }, + }), + }, + }, + }, + }, }), }, async (request, reply) => { From 79b5effb3cc876f251106ac23ae2565bbef08f01 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 23:43:40 -0400 Subject: [PATCH 6/7] fix unit tests --- src/api/routes/membership.ts | 1 + src/api/routes/organizations.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 7a2c845f..229a51f2 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -225,6 +225,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { schema: z .object({ netId: illinoisNetId, + list: z.optional(z.string().min(1)), isPaidMember: z.boolean(), }) .meta({ diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index b958784b..09a419c5 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -27,8 +27,8 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { content: { "application/json": { schema: z - .enum(AllOrganizationList) - .default(JSON.stringify(AllOrganizationList)), + .array(z.enum(AllOrganizationList)) + .default(AllOrganizationList), }, }, }, From d99ffd61dc0c46ab8ab1a6f22079cb39aee208a9 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 11 Jul 2025 23:48:07 -0400 Subject: [PATCH 7/7] add opengraph tags --- src/api/createSwagger.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/api/createSwagger.ts b/src/api/createSwagger.ts index 5dbb2ab8..d71cedae 100644 --- a/src/api/createSwagger.ts +++ b/src/api/createSwagger.ts @@ -7,11 +7,16 @@ const html = ` - ACM @ UIUC Core API Reference + Core API Documentation | ACM @ UIUC + + + + +