Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/api/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ export function withRoles<T extends FastifyZodOpenApiSchema>(
schema: T,
): T & RoleSchema {
return {
security: [{ bearerAuth: [] }],
"x-required-roles": roles,
description: `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`,
description:
roles.length > 0
? `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`
: "Requires valid authentication but no specific role.",
...schema,
};
}
11 changes: 11 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ async function init(prettyPrint: boolean = false) {
},
],
openapi: "3.0.3" 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.",
},
},
},
},
transform: fastifyZodOpenApiTransform,
transformObject: fastifyZodOpenApiTransformObject,
Expand Down
72 changes: 37 additions & 35 deletions src/api/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import "zod-openapi/extend";
import { FastifyPluginAsync, FastifyRequest } from "fastify";

Check warning on line 2 in src/api/routes/events.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'FastifyRequest' is defined but never used. Allowed unused vars must match /^_/u
import { AppRoles } from "../../common/roles.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

Check warning on line 5 in src/api/routes/events.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'zodToJsonSchema' is defined but never used. Allowed unused vars must match /^_/u
import { OrganizationList } from "../../common/orgs.js";
import {
DeleteItemCommand,
Expand Down Expand Up @@ -37,10 +37,10 @@
FastifyPluginAsyncZodOpenApi,
FastifyZodOpenApiSchema,
FastifyZodOpenApiTypeProvider,
serializerCompiler,

Check warning on line 40 in src/api/routes/events.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'serializerCompiler' is defined but never used. Allowed unused vars must match /^_/u
validatorCompiler,

Check warning on line 41 in src/api/routes/events.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'validatorCompiler' is defined but never used. Allowed unused vars must match /^_/u
} from "fastify-zod-openapi";
import { ts, withTags } from "api/components/index.js";
import { ts, withRoles, withTags } from "api/components/index.js";

const repeatOptions = ["weekly", "biweekly"] as const;
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
Expand Down Expand Up @@ -221,26 +221,27 @@
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
"/:id?",
{
schema: withTags(["Events"], {
// response: {
// 201: z.object({
// id: z.string(),
// resource: z.string(),
// }),
// },
body: postRequestSchema,
params: z.object({
id: z.string().min(1).optional().openapi({
description:
"Event ID to modify (leave empty to create a new event).",
example: "6667e095-8b04-4877-b361-f636f459ba42",
schema: withRoles(
[AppRoles.EVENTS_MANAGER],
withTags(["Events"], {
// response: {
// 201: z.object({
// id: z.string(),
// resource: z.string(),
// }),
// },
body: postRequestSchema,
params: z.object({
id: z.string().min(1).optional().openapi({
description:
"Event ID to modify (leave empty to create a new event).",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
}),
summary: "Modify a calendar event.",
}),
summary: "Modify a calendar event.",
}) satisfies FastifyZodOpenApiSchema,
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
},
) satisfies FastifyZodOpenApiSchema,
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
if (!request.username) {
Expand Down Expand Up @@ -361,24 +362,25 @@
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().delete(
"/:id",
{
schema: withTags(["Events"], {
params: z.object({
id: z.string().min(1).openapi({
description: "Event ID to delete.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
schema: withRoles(
[AppRoles.EVENTS_MANAGER],
withTags(["Events"], {
params: z.object({
id: z.string().min(1).openapi({
description: "Event ID to delete.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
}),
// response: {
// 201: z.object({
// id: z.string(),
// resource: z.string(),
// }),
// },
summary: "Delete a calendar event.",
}),
// response: {
// 201: z.object({
// id: z.string(),
// resource: z.string(),
// }),
// },
summary: "Delete a calendar event.",
}) satisfies FastifyZodOpenApiSchema,
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]);
},
) satisfies FastifyZodOpenApiSchema,
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
const id = request.params.id;
Expand Down
15 changes: 8 additions & 7 deletions src/api/routes/iam.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FastifyPluginAsync } from "fastify";
import { AppRoles } from "../../common/roles.js";
import { zodToJsonSchema } from "zod-to-json-schema";

Check warning on line 3 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'zodToJsonSchema' is defined but never used. Allowed unused vars must match /^_/u
import {
addToTenant,
getEntraIdToken,
Expand All @@ -23,10 +23,10 @@
import {
invitePostRequestSchema,
groupMappingCreatePostSchema,
entraActionResponseSchema,

Check warning on line 26 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'entraActionResponseSchema' is defined but never used. Allowed unused vars must match /^_/u
groupModificationPatchSchema,
EntraGroupActions,
entraGroupMembershipListResponse,

Check warning on line 29 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'entraGroupMembershipListResponse' is defined but never used. Allowed unused vars must match /^_/u
entraProfilePatchRequest,
} from "../../common/types/iam.js";
import {
Expand All @@ -40,8 +40,8 @@
import { groupId, withRoles, withTags } from "api/components/index.js";
import {
FastifyZodOpenApiTypeProvider,
serializerCompiler,

Check warning on line 43 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'serializerCompiler' is defined but never used. Allowed unused vars must match /^_/u
validatorCompiler,

Check warning on line 44 in src/api/routes/iam.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'validatorCompiler' is defined but never used. Allowed unused vars must match /^_/u
} from "fastify-zod-openapi";
import { z } from "zod";

Expand Down Expand Up @@ -79,13 +79,14 @@
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().patch(
"/profile",
{
schema: withTags(["IAM"], {
body: entraProfilePatchRequest,
summary: "Update user's profile.",
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, []);
},
schema: withRoles(
[],
withTags(["IAM"], {
body: entraProfilePatchRequest,
summary: "Update user's profile.",
}),
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
if (!request.tokenPayload || !request.username) {
Expand Down
11 changes: 7 additions & 4 deletions src/api/routes/protected.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FastifyPluginAsync } from "fastify";
import rateLimiter from "api/plugins/rateLimiter.js";
import { withTags } from "api/components/index.js";
import { withRoles, withTags } from "api/components/index.js";

const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
await fastify.register(rateLimiter, {
Expand All @@ -11,9 +11,12 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
fastify.get(
"",
{
schema: withTags(["Generic"], {
summary: "Get a user's username and roles.",
}),
schema: withRoles(
[],
withTags(["Generic"], {
summary: "Get a user's username and roles.",
}),
),
},
async (request, reply) => {
const roles = await fastify.authorize(request, reply, []);
Expand Down
103 changes: 52 additions & 51 deletions src/api/routes/roomRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { genericConfig, notificationRecipients } from "common/config.js";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { withTags } from "api/components/index.js";
import { withRoles, withTags } from "api/components/index.js";
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
import { z } from "zod";

Expand All @@ -37,29 +37,27 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
duration: 30,
rateLimitIdentifier: "roomRequests",
});
fastify.post<{
Body: RoomRequestStatusUpdatePostBody;
Params: { requestId: string; semesterId: string };
}>(
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
"/:semesterId/:requestId/status",
{
schema: withTags(["Room Requests"], {
summary: "Create status update for a room request.",
params: z.object({
requestId: z.string().min(1).openapi({
description: "Room request ID.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
schema: withRoles(
[AppRoles.ROOM_REQUEST_UPDATE],
withTags(["Room Requests"], {
summary: "Create status update for a room request.",
params: z.object({
requestId: z.string().min(1).openapi({
description: "Room request ID.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
}),
}),
body: roomRequestStatusUpdateRequest,
}),
body: roomRequestStatusUpdateRequest,
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_UPDATE]);
},
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
if (!request.username) {
Expand Down Expand Up @@ -154,18 +152,19 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/:semesterId",
{
schema: withTags(["Room Requests"], {
summary: "Get room requests for a specific semester.",
params: z.object({
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
schema: withRoles(
[AppRoles.ROOM_REQUEST_CREATE],
withTags(["Room Requests"], {
summary: "Get room requests for a specific semester.",
params: z.object({
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
}),
}),
}),
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
},
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
const semesterId = request.params.semesterId;
Expand Down Expand Up @@ -252,13 +251,14 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
"",
{
schema: withTags(["Room Requests"], {
summary: "Create a room request.",
body: roomRequestSchema,
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
},
schema: withRoles(
[AppRoles.ROOM_REQUEST_CREATE],
withTags(["Room Requests"], {
summary: "Create a room request.",
body: roomRequestSchema,
}),
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
const requestId = request.id;
Expand Down Expand Up @@ -350,22 +350,23 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/:semesterId/:requestId",
{
schema: withTags(["Room Requests"], {
summary: "Get specific room request data.",
params: z.object({
requestId: z.string().min(1).openapi({
description: "Room request ID.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
schema: withRoles(
[AppRoles.ROOM_REQUEST_CREATE],
withTags(["Room Requests"], {
summary: "Get specific room request data.",
params: z.object({
requestId: z.string().min(1).openapi({
description: "Room request ID.",
example: "6667e095-8b04-4877-b361-f636f459ba42",
}),
semesterId: z.string().min(1).openapi({
description: "Short semester slug for a given semester.",
example: "sp25",
}),
}),
}),
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]);
},
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
const requestId = request.params.requestId;
Expand Down
30 changes: 16 additions & 14 deletions src/api/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ScanCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { withTags } from "api/components/index.js";
import { withRoles, withTags } from "api/components/index.js";
import { createAuditLogEntry } from "api/functions/auditLog.js";
import {
createStripeLink,
Expand Down Expand Up @@ -36,12 +36,13 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/paymentLinks",
{
schema: withTags(["Stripe"], {
summary: "Get available Stripe payment links.",
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]);
},
schema: withRoles(
[AppRoles.STRIPE_LINK_CREATOR],
withTags(["Stripe"], {
summary: "Get available Stripe payment links.",
}),
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
let dynamoCommand;
Expand Down Expand Up @@ -91,13 +92,14 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
"/paymentLinks",
{
schema: withTags(["Stripe"], {
summary: "Create a Stripe payment link.",
body: invoiceLinkPostRequestSchema,
}),
onRequest: async (request, reply) => {
await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]);
},
schema: withRoles(
[AppRoles.STRIPE_LINK_CREATOR],
withTags(["Stripe"], {
summary: "Create a Stripe payment link.",
body: invoiceLinkPostRequestSchema,
}),
),
onRequest: fastify.authorizeFromSchema,
},
async (request, reply) => {
if (!request.username) {
Expand Down
19 changes: 19 additions & 0 deletions tests/live/documentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from "vitest";

const baseEndpoint = `https://core.aws.qa.acmuiuc.org`;

test("Get OpenAPI JSON", async () => {
const response = await fetch(`${baseEndpoint}/api/documentation/json`);
expect(response.status).toBe(200);

const responseDataJson = await response.json();
expect(responseDataJson).toHaveProperty("openapi");
expect(responseDataJson["openapi"]).toEqual("3.0.3");
});

test("Get OpenAPI UI", async () => {
const response = await fetch(`${baseEndpoint}/api/documentation`);
expect(response.status).toBe(200);
const contentType = response.headers.get("content-type");
expect(contentType).toContain("text/html");
});
Loading
Loading