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 diff --git a/src/api/components/index.ts b/src/api/components/index.ts index 7525fa7d..9430b2d6 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,128 @@ type RolesConfig = { disableApiKeyAuth: boolean; }; +export function getCorrectJsonSchema({ + schema, + example, + description, +}: { + schema: T; + example: U; + description: string; +}) { + return { + description, + content: { + "application/json": { + example, + schema, + }, + }, + }; +} + +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 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[], schema: T, @@ -57,6 +169,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 +183,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 = { + 500: internalServerError, + 429: rateLimitExceededError, + ...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/createSwagger.ts b/src/api/createSwagger.ts index 1404e590..d71cedae 100644 --- a/src/api/createSwagger.ts +++ b/src/api/createSwagger.ts @@ -4,27 +4,37 @@ import { writeFile, mkdir } from "fs/promises"; import init from "./index.js"; // Assuming this is your Fastify app initializer const html = ` - - - - - - - ACM @ UIUC Core API - - - -
- - - + + + + Core API Documentation | ACM @ UIUC + + + + + + + + + + +
+ + + + + + + `; /** 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({ 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/membership.ts b/src/api/routes/membership.ts index 6b0c682b..229a51f2 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,27 @@ 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, + list: z.optional(z.string().min(1)), + isPaidMember: z.boolean(), + }) + .meta({ + example: { + netId: "rjjones", + isPaidMember: false, + }, + }), + }, + }, + }, + }, }), }, async (request, reply) => { diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 830d7de1..09a419c5 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 + .array(z.enum(AllOrganizationList)) + .default(AllOrganizationList), + }, + }, + }, + }, }), }, async (_request, reply) => { 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