diff --git a/Makefile b/Makefile
index 9d35f7a3..be9eb7d1 100644
--- a/Makefile
+++ b/Makefile
@@ -28,6 +28,8 @@ common_params = --no-confirm-changeset \
s3_bucket_prefix = "$(current_aws_account)-$(region)-$(application_key)"
ui_s3_bucket = "$(s3_bucket_prefix)-ui"
+docs_s3_bucket = "$(s3_bucket_prefix)-docs"
+
GIT_HASH := $(shell git rev-parse --short HEAD)
@@ -66,19 +68,21 @@ build: src/ cloudformation/
local:
VITE_BUILD_HASH=$(GIT_HASH) yarn run dev
+
+postdeploy:
+ @echo "Syncing S3 UI bucket..."
+ aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete
+ make invalidate_cloudfront
+
deploy_prod: check_account_prod
@echo "Deploying CloudFormation stack..."
sam deploy $(common_params) --parameter-overrides $(run_env)=prod $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)"
- @echo "Syncing S3 bucket..."
- aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete
- make invalidate_cloudfront
+ make postdeploy
deploy_dev: check_account_dev
@echo "Deploying CloudFormation stack..."
sam deploy $(common_params) --parameter-overrides $(run_env)=dev $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)"
- @echo "Syncing S3 bucket..."
- aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete
- make invalidate_cloudfront
+ make postdeploy
invalidate_cloudfront:
@echo "Creating CloudFront invalidation..."
diff --git a/cloudformation/main.yml b/cloudformation/main.yml
index 93cce72a..b42bbb80 100644
--- a/cloudformation/main.yml
+++ b/cloudformation/main.yml
@@ -608,14 +608,15 @@ Resources:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ${S3BucketPrefix}-ui
- WebsiteConfiguration:
- IndexDocument: index.html
- CloudFrontOriginAccessIdentity:
- Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
+ AppCloudfrontS3OAC:
+ Type: AWS::CloudFront::OriginAccessControl
Properties:
- CloudFrontOriginAccessIdentityConfig:
- Comment: !Sub "Access identity for ${AppFrontendS3Bucket}"
+ OriginAccessControlConfig:
+ Name: InfraCoreApi OAC
+ OriginAccessControlOriginType: s3
+ SigningBehavior: always
+ SigningProtocol: sigv4
AppFrontendCloudfrontDistribution:
Type: AWS::CloudFront::Distribution
@@ -626,7 +627,8 @@ Resources:
- Id: S3WebsiteOrigin
DomainName: !GetAtt AppFrontendS3Bucket.RegionalDomainName
S3OriginConfig:
- OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
+ OriginAccessIdentity: ''
+ OriginAccessControlId: !GetAtt AppCloudfrontS3OAC.Id
- Id: LambdaOrigin
DomainName: !Select [0, !Split ['/', !Select [1, !Split ['https://', !GetAtt AppLambdaUrl.FunctionUrl]]]]
CustomOriginConfig:
@@ -697,23 +699,6 @@ Resources:
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6"
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
Compress: true
- - PathPattern: "/api/documentation*"
- TargetOriginId: LambdaOrigin
- ViewerProtocolPolicy: redirect-to-https
- AllowedMethods:
- - GET
- - HEAD
- - OPTIONS
- - PUT
- - POST
- - DELETE
- - PATCH
- CachedMethods:
- - GET
- - HEAD
- CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6"
- OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
- Compress: true
- PathPattern: "/api/*"
TargetOriginId: LambdaOrigin
ViewerProtocolPolicy: redirect-to-https
@@ -750,9 +735,12 @@ Resources:
Statement:
- Effect: Allow
Principal:
- CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId
+ Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub "${AppFrontendS3Bucket.Arn}/*"
+ Condition:
+ StringEquals:
+ AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${AppFrontendCloudfrontDistribution}"
CloudfrontNoCachePolicy:
Type: AWS::CloudFront::CachePolicy
@@ -812,6 +800,9 @@ Resources:
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const uri = request.uri;
+ if (uri === '/docs') {
+ request.uri = "/docs/index.html";
+ }
if (!uri.startsWith('/api') && !uri.match(/\.\w+$/)) {
request.uri = "/index.html";
}
diff --git a/src/api/build.js b/src/api/build.js
index def5ba0b..1e11bd72 100644
--- a/src/api/build.js
+++ b/src/api/build.js
@@ -34,13 +34,6 @@ const commonParams = {
`.trim(),
}, // Banner for compatibility with CommonJS
plugins: [
- copy({
- resolveFrom: "cwd",
- assets: {
- from: ["../../node_modules/@fastify/swagger-ui/static/*"],
- to: ["../../dist/lambda/static"],
- },
- }),
copy({
resolveFrom: "cwd",
assets: {
diff --git a/src/api/createLambdaPackage.js b/src/api/createLambdaPackage.js
index 82d7f546..d59f1fca 100644
--- a/src/api/createLambdaPackage.js
+++ b/src/api/createLambdaPackage.js
@@ -13,8 +13,6 @@ export const packagesToTransfer = [
"moment-timezone",
"passkit-generator",
"fastify",
- "@fastify/swagger",
- "@fastify/swagger-ui",
"zod",
"argon2",
"ioredis",
diff --git a/src/api/createSwagger.ts b/src/api/createSwagger.ts
new file mode 100644
index 00000000..1404e590
--- /dev/null
+++ b/src/api/createSwagger.ts
@@ -0,0 +1,56 @@
+import { fileURLToPath } from "url";
+import path from "node:path";
+import { writeFile, mkdir } from "fs/promises";
+import init from "./index.js"; // Assuming this is your Fastify app initializer
+
+const html = `
+
+
+
+
+
+
+ ACM @ UIUC Core API
+
+
+
+
+
+
+
+
+`;
+/**
+ * Generates and saves Swagger/OpenAPI specification files.
+ */
+async function createSwaggerFiles() {
+ try {
+ const app = await init(false, false);
+ await app.ready();
+ console.log("App is ready. Generating specs...");
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+ const outputDir = path.resolve(__dirname, "..", "..", "dist_ui", "docs");
+ await mkdir(outputDir, { recursive: true });
+ const jsonSpec = JSON.stringify(app.swagger(), null, 2);
+ const yamlSpec = app.swagger({ yaml: true });
+ await writeFile(path.join(outputDir, "openapi.json"), jsonSpec);
+ await writeFile(path.join(outputDir, "openapi.yaml"), yamlSpec);
+ await writeFile(path.join(outputDir, "index.html"), html);
+
+ console.log(`✅ Swagger files successfully generated in ${outputDir}`);
+ await app.close();
+ } catch (err) {
+ console.error("❌ Failed to generate Swagger files:", err);
+ process.exit(1);
+ }
+}
+
+createSwaggerFiles();
diff --git a/src/api/index.ts b/src/api/index.ts
index 48ec22b0..f425854c 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -15,7 +15,6 @@ import {
environmentConfig,
genericConfig,
SecretConfig,
- SecretTesting,
} from "../common/config.js";
import organizationsPlugin from "./routes/organizations.js";
import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js";
@@ -36,8 +35,7 @@ import membershipPlugin from "./routes/membership.js";
import path from "path"; // eslint-disable-line import/no-nodejs-modules
import roomRequestRoutes from "./routes/roomRequests.js";
import logsPlugin from "./routes/logs.js";
-import fastifySwagger from "@fastify/swagger";
-import fastifySwaggerUI from "@fastify/swagger-ui";
+
import {
fastifyZodOpenApiPlugin,
fastifyZodOpenApiTransform,
@@ -50,19 +48,18 @@ import { withTags } from "./components/index.js";
import apiKeyRoute from "./routes/apiKey.js";
import clearSessionRoute from "./routes/clearSession.js";
import RedisModule from "ioredis";
+import { fileURLToPath } from "url"; // eslint-disable-line import/no-nodejs-modules
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
dotenv.config();
const now = () => Date.now();
-async function init(prettyPrint: boolean = false) {
- const dynamoClient = new DynamoDBClient({
- region: genericConfig.AwsRegion,
- });
-
- const secretsManagerClient = new SecretsManagerClient({
- region: genericConfig.AwsRegion,
- });
+async function init(prettyPrint: boolean = false, initClients: boolean = true) {
+ const isRunningInLambda =
+ process.env.LAMBDA_TASK_ROOT || process.env.AWS_LAMBDA_FUNCTION_NAME;
+ let isSwaggerServer = false;
const transport = prettyPrint
? {
target: "pino-pretty",
@@ -98,111 +95,122 @@ async function init(prettyPrint: boolean = false) {
await app.register(evaluatePoliciesPlugin);
await app.register(errorHandlerPlugin);
await app.register(fastifyZodOpenApiPlugin);
- await app.register(fastifySwagger, {
- openapi: {
- info: {
- title: "ACM @ UIUC Core API",
- description: "ACM @ UIUC Core Management Platform",
- version: "1.0.0",
- contact: {
- name: "ACM @ UIUC Infrastructure Team",
- email: "infra@acm.illinois.edu",
- url: "infra.acm.illinois.edu",
- },
- license: {
- name: "BSD 3-Clause",
- identifier: "BSD-3-Clause",
- url: "https://github.com/acm-uiuc/core/blob/main/LICENSE",
- },
- termsOfService: "https://core.acm.illinois.edu/tos",
- },
- servers: [
- {
- url: "https://core.acm.illinois.edu",
- description: "Production API server",
- },
- {
- url: "https://core.aws.qa.acmuiuc.org",
- description: "QA API server",
- },
- ],
+ if (!isRunningInLambda) {
+ try {
+ const fastifySwagger = import("@fastify/swagger");
+ const fastifySwaggerUI = import("@fastify/swagger-ui");
+ await app.register(fastifySwagger, {
+ openapi: {
+ info: {
+ title: "ACM @ UIUC Core API",
+ description: "ACM @ UIUC Core Management Platform",
+ version: "1.0.0",
+ contact: {
+ name: "ACM @ UIUC Infrastructure Team",
+ email: "infra@acm.illinois.edu",
+ url: "infra.acm.illinois.edu",
+ },
+ license: {
+ name: "BSD 3-Clause",
+ identifier: "BSD-3-Clause",
+ url: "https://github.com/acm-uiuc/core/blob/main/LICENSE",
+ },
+ termsOfService: "https://core.acm.illinois.edu/tos",
+ },
+ servers: [
+ {
+ url: "https://core.acm.illinois.edu",
+ description: "Production API server",
+ },
+ {
+ url: "https://core.aws.qa.acmuiuc.org",
+ description: "QA API server",
+ },
+ ],
- tags: [
- {
- name: "Events",
- description:
- "Retrieve ACM @ UIUC-wide and organization-specific calendars and event metadata.",
- },
- {
- name: "Generic",
- description: "Retrieve metadata about a user or ACM @ UIUC .",
- },
- {
- name: "iCalendar Integration",
- description:
- "Retrieve Events calendars in iCalendar format (for integration with external calendar clients).",
- },
- {
- name: "IAM",
- description: "Identity and Access Management for internal services.",
- },
- { name: "Linkry", description: "Link Shortener." },
- {
- name: "Logging",
- description: "View audit logs for various services.",
- },
- {
- name: "Membership",
- description: "Purchasing or checking ACM @ UIUC membership.",
- },
- {
- name: "Tickets/Merchandise",
- description: "Handling the tickets and merchandise lifecycle.",
- },
- {
- name: "Mobile Wallet",
- description: "Issuing Apple/Google Wallet passes.",
- },
- {
- name: "Stripe",
- description:
- "Collecting payments for ACM @ UIUC invoices and other services.",
- },
- {
- name: "Room Requests",
- description:
- "Creating room reservation requests for ACM @ UIUC within University buildings.",
- },
- {
- name: "API Keys",
- description: "Manage the lifecycle of API keys.",
- },
- ],
+ tags: [
+ {
+ name: "Events",
+ description:
+ "Retrieve ACM @ UIUC-wide and organization-specific calendars and event metadata.",
+ },
+ {
+ name: "Generic",
+ description: "Retrieve metadata about a user or ACM @ UIUC .",
+ },
+ {
+ name: "iCalendar Integration",
+ description:
+ "Retrieve Events calendars in iCalendar format (for integration with external calendar clients).",
+ },
+ {
+ name: "IAM",
+ description:
+ "Identity and Access Management for internal services.",
+ },
+ { name: "Linkry", description: "Link Shortener." },
+ {
+ name: "Logging",
+ description: "View audit logs for various services.",
+ },
+ {
+ name: "Membership",
+ description: "Purchasing or checking ACM @ UIUC membership.",
+ },
+ {
+ name: "Tickets/Merchandise",
+ description: "Handling the tickets and merchandise lifecycle.",
+ },
+ {
+ name: "Mobile Wallet",
+ description: "Issuing Apple/Google Wallet passes.",
+ },
+ {
+ name: "Stripe",
+ description:
+ "Collecting payments for ACM @ UIUC invoices and other services.",
+ },
+ {
+ name: "Room Requests",
+ description:
+ "Creating room reservation requests for ACM @ UIUC within University buildings.",
+ },
+ {
+ name: "API Keys",
+ description: "Manage the lifecycle of API keys.",
+ },
+ ],
- openapi: "3.1.0" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0
- components: {
- securitySchemes: {
- bearerAuth: {
- type: "http",
- scheme: "bearer",
- bearerFormat: "JWT",
- description:
- "Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.",
- },
- apiKeyAuth: {
- type: "apiKey",
- in: "header",
- name: "X-Api-Key",
+ openapi: "3.1.0" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: "http",
+ scheme: "bearer",
+ bearerFormat: "JWT",
+ description:
+ "Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.",
+ },
+ apiKeyAuth: {
+ type: "apiKey",
+ in: "header",
+ name: "X-Api-Key",
+ },
+ },
},
},
- },
- },
- transform: fastifyZodOpenApiTransform,
- transformObject: fastifyZodOpenApiTransformObject,
- });
- await app.register(fastifySwaggerUI, {
- routePrefix: "/api/documentation",
- });
+ transform: fastifyZodOpenApiTransform,
+ transformObject: fastifyZodOpenApiTransformObject,
+ });
+ await app.register(fastifySwaggerUI, {
+ routePrefix: "/api/documentation",
+ });
+ isSwaggerServer = true;
+ } catch (e) {
+ app.log.warn("Fastify Swagger not created!");
+ }
+ }
+
await app.register(fastifyStatic, {
root: path.join(__dirname, "public"),
prefix: "/",
@@ -210,7 +218,18 @@ async function init(prettyPrint: boolean = false) {
if (!process.env.RunEnvironment) {
process.env.RunEnvironment = "dev";
}
-
+ if (isRunningInLambda && !isSwaggerServer) {
+ // Serve docs from S3
+ app.get("/api/documentation", (_request, response) => {
+ response.redirect("/docs/index.html", 308);
+ });
+ app.get("/api/documentation/json", (_request, response) => {
+ response.redirect("/docs/openapi.json", 308);
+ });
+ app.get("/api/documentation/yaml", (_request, response) => {
+ response.redirect("/docs/openapi.yaml", 308);
+ });
+ }
if (!runEnvironments.includes(process.env.RunEnvironment as RunEnvironment)) {
throw new InternalServerError({
message: `Invalid run environment ${app.runEnvironment}.`,
@@ -222,7 +241,7 @@ async function init(prettyPrint: boolean = false) {
message: `Audit log can only be disabled if the run environment is "dev"!`,
});
}
- if (process.env.LAMBDA_TASK_ROOT || process.env.AWS_LAMBDA_FUNCTION_NAME) {
+ if (isRunningInLambda) {
throw new InternalServerError({
message: `Audit log cannot be disabled when running in AWS Lambda environment!`,
});
@@ -235,24 +254,30 @@ async function init(prettyPrint: boolean = false) {
app.environmentConfig =
environmentConfig[app.runEnvironment as RunEnvironment];
app.nodeCache = new NodeCache({ checkperiod: 30 });
- app.dynamoClient = dynamoClient;
- app.secretsManagerClient = secretsManagerClient;
- app.refreshSecretConfig = async () => {
- app.log.debug(
- `Getting secrets: ${JSON.stringify(app.environmentConfig.ConfigurationSecretIds)}.`,
- );
- const allSecrets = await Promise.all(
- app.environmentConfig.ConfigurationSecretIds.map((secretName) =>
- getSecretValue(app.secretsManagerClient, secretName),
- ),
- );
- app.secretConfig = allSecrets.reduce(
- (acc, currentSecret) => ({ ...acc, ...currentSecret }),
- {},
- ) as SecretConfig;
- };
- await app.refreshSecretConfig();
- app.redisClient = new RedisModule.default(app.secretConfig.redis_url);
+ if (initClients) {
+ app.dynamoClient = new DynamoDBClient({
+ region: genericConfig.AwsRegion,
+ });
+ app.secretsManagerClient = new SecretsManagerClient({
+ region: genericConfig.AwsRegion,
+ });
+ app.refreshSecretConfig = async () => {
+ app.log.debug(
+ `Getting secrets: ${JSON.stringify(app.environmentConfig.ConfigurationSecretIds)}.`,
+ );
+ const allSecrets = await Promise.all(
+ app.environmentConfig.ConfigurationSecretIds.map((secretName) =>
+ getSecretValue(app.secretsManagerClient, secretName),
+ ),
+ );
+ app.secretConfig = allSecrets.reduce(
+ (acc, currentSecret) => ({ ...acc, ...currentSecret }),
+ {},
+ ) as SecretConfig;
+ };
+ await app.refreshSecretConfig();
+ app.redisClient = new RedisModule.default(app.secretConfig.redis_url);
+ }
app.addHook("onRequest", (req, _, done) => {
req.startTime = now();
const hostname = req.hostname;
@@ -260,7 +285,6 @@ async function init(prettyPrint: boolean = false) {
req.log.info({ hostname, url, method: req.method }, "received request");
done();
});
-
app.addHook("onResponse", (req, reply, done) => {
req.log.info(
{
diff --git a/src/api/package.json b/src/api/package.json
index 44f945b9..5052fabe 100644
--- a/src/api/package.json
+++ b/src/api/package.json
@@ -7,7 +7,7 @@
"license": "BSD-3-Clause",
"type": "module",
"scripts": {
- "build": "tsc && node build.js",
+ "build": "tsc && node build.js && tsx createSwagger.ts",
"dev": "cross-env DISABLE_AUDIT_LOG=true cross-env LOG_LEVEL=debug concurrently --names 'esbuild,server' 'node esbuild.config.js --watch' 'cd ../../dist_devel && nodemon index.js'",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts --cache",
@@ -30,8 +30,6 @@
"@fastify/caching": "^9.0.1",
"@fastify/cors": "^11.0.1",
"@fastify/static": "^8.1.1",
- "@fastify/swagger": "^9.5.0",
- "@fastify/swagger-ui": "^5.2.2",
"@middy/core": "^6.1.6",
"@middy/event-normalizer": "^6.1.6",
"@middy/sqs-partial-batch-failure": "^6.1.6",
@@ -64,6 +62,8 @@
"zod-validation-error": "^3.3.1"
},
"devDependencies": {
+ "@fastify/swagger": "^9.5.0",
+ "@fastify/swagger-ui": "^5.2.2",
"@tsconfig/node22": "^22.0.1",
"@types/aws-lambda": "^8.10.149",
"@types/qrcode": "^1.5.5",
diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts
index 97d1f6d7..29d70c0c 100644
--- a/src/api/routes/roomRequests.ts
+++ b/src/api/routes/roomRequests.ts
@@ -320,7 +320,6 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
"userId#requestId": `${request.username}#${requestId}`,
semesterId: request.body.semester,
};
- console.log("FUCK", body);
const logStatement = buildAuditLogTransactPut({
entry: {
module: Modules.ROOM_RESERVATIONS,