Skip to content
This repository has been archived by the owner on Oct 18, 2024. It is now read-only.

Commit

Permalink
perf: ⚡️ optimize courses/instructors by ID (#117)
Browse files Browse the repository at this point in the history
Co-authored-by: Aponia <[email protected]>
  • Loading branch information
ecxyzzy and ap0nia authored Dec 21, 2023
1 parent 35149ae commit ffe387c
Show file tree
Hide file tree
Showing 26 changed files with 213 additions and 225 deletions.
2 changes: 1 addition & 1 deletion .github/actions/setup-node-and-pnpm/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ runs:
- name: Setup Node.js environment
uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4
with:
node-version: lts/hydrogen
node-version: lts/*

- name: Install pnpm
uses: pnpm/action-setup@v2
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/hydrogen
lts/*
45 changes: 42 additions & 3 deletions apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
import { Api, type ApiConstructProps } from "@bronya.js/api-construct";
import { createApiCliPlugins } from "@bronya.js/api-construct/plugins/cli";
import { isCdk } from "@bronya.js/core";
import { PrismaClient } from "@libs/db";
import { logger, warmingRequestBody } from "@libs/lambda";
import { LambdaIntegration, ResponseType } from "aws-cdk-lib/aws-apigateway";
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
Expand All @@ -16,6 +17,10 @@ import { App, Stack, Duration } from "aws-cdk-lib/core";
import { config } from "dotenv";
import type { BuildOptions } from "esbuild";

import { normalizeCourse } from "./src/lib/utils";

const prisma = new PrismaClient();

/**
* Whether we're executing in CDK.
*
Expand Down Expand Up @@ -77,7 +82,12 @@ const prismaSchema = resolve(libsDbDirectory, "prisma", prismaSchemaFile);
/**
* Name of the Prisma query engine file that's used on AWS Lambda.
*/
const prismaQueryEngineFile = "libquery_engine-linux-arm64-openssl-1.0.x.so.node";
const prismaQueryEngineFile = "libquery_engine-linux-arm64-openssl-3.0.x.so.node";

/**
* Namespace for virtual files.
*/
const namespace = "peterportal-api-next:virtual";

/**
* Shared ESBuild options.
Expand All @@ -86,7 +96,7 @@ export const esbuildOptions: BuildOptions = {
format: "esm",
platform: "node",
bundle: true,
// minify: true,
minify: true,
banner: { js },

/**
Expand All @@ -97,12 +107,41 @@ export const esbuildOptions: BuildOptions = {
* @RFC What would be the best way to resolve these two values?
*/
outExtension: { ".js": ".mjs" },

plugins: [
{
name: "in-memory-cache",
setup: (build) => {
build.onResolve({ filter: /virtual:courses/ }, (args) => ({
path: args.path,
namespace,
}));
build.onResolve({ filter: /virtual:instructors/ }, (args) => ({
path: args.path,
namespace,
}));
build.onLoad({ filter: /virtual:courses/, namespace }, async () => ({
contents: `export const courses = ${JSON.stringify(
Object.fromEntries(
(await prisma.course.findMany()).map(normalizeCourse).map((x) => [x.id, x]),
),
)}`,
}));
build.onLoad({ filter: /virtual:instructors/, namespace }, async () => ({
contents: `export const instructors = ${JSON.stringify(
Object.fromEntries((await prisma.instructor.findMany()).map((x) => [x.ucinetid, x])),
)}`,
}));
},
},
],
};

/**
* Shared construct props.
*/
export const constructs: ApiConstructProps = {
functionProps: () => ({ runtime: Runtime.NODEJS_20_X }),
functionPlugin: ({ functionProps, handler }, scope) => {
const warmingTarget = new LambdaFunction(handler, {
event: RuleTargetInput.fromObject(warmingRequestBody),
Expand Down Expand Up @@ -263,7 +302,7 @@ export async function main(): Promise<App> {
'exports.h=async _=>({headers:{"Access-Control-Allow-Origin": "*","Access-Control-Allow-Headers": "Apollo-Require-Preflight,Content-Type","Access-Control-Allow-Methods": "GET,POST,OPTIONS"},statusCode:204})',
),
handler: "index.h",
runtime: Runtime.NODEJS_18_X,
runtime: Runtime.NODEJS_20_X,
architecture: Architecture.ARM_64,
}),
);
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Ambient declaration file for defining "virtual" modules/files.
* The file contents are generated dynamically during build time by esbuild.
* DO NOT add any imports/exports; that converts the file to a regular module
* and removes the global declarations.
*/

/**
* Virtual module for caching course information during build time.
*/
declare module "virtual:courses" {
declare const courses: Record<string, import("@peterportal-api/types").Course>;
}
/**
* Virtual module for caching instructor information during build time.
*/
declare module "virtual:instructors" {
declare const instructors: Record<string, import("@peterportal-api/types").Instructor>;
}
37 changes: 37 additions & 0 deletions apps/api/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { Course as PrismaCourse } from "@libs/db";
import { Course, CourseLevel, GECategory, PrerequisiteTree } from "@peterportal-api/types";

const days = ["Su", "M", "Tu", "W", "Th", "F", "Sa"];

const courseLevels: Record<string, CourseLevel> = {
LowerDiv: "Lower Division (1-99)",
UpperDiv: "Upper Division (100-199)",
Graduate: "Graduate/Professional Only (200+)",
};

const geMapping: Record<string, GECategory> = {
"GE-1A": "GE Ia: Lower Division Writing",
"GE-1B": "GE Ib: Upper Division Writing",
"GE-2": "GE II: Science and Technology",
"GE-3": "GE III: Social & Behavioral Sciences",
"GE-4": "GE IV: Arts and Humanities",
"GE-5A": "GE Va: Quantitative Literacy",
"GE-5B": "GE Vb: Formal Reasoning",
"GE-6": "GE VI: Language Other Than English",
"GE-7": "GE VII: Multicultural Studies",
"GE-8": "GE VIII: International/Global Issues",
};

/**
* Input to a transform function.
*/
Expand Down Expand Up @@ -35,3 +57,18 @@ export const flattenDayStringsAndSplit = (value: TransformInput): TransformOutpu
),
)
: undefined;

export function normalizeCourse(course: PrismaCourse): Course {
const courseLevel = courseLevels[course.courseLevel];
const geList = (course.geList as string[]).map((x) => geMapping[x]);
return {
...course,
courseLevel,
instructorHistory: course.instructorHistory as unknown as string[],
prerequisiteTree: course.prerequisiteTree as unknown as PrerequisiteTree,
prerequisiteList: course.prerequisiteList as unknown as string[],
prerequisiteFor: course.prerequisiteFor as unknown as string[],
geList,
terms: course.terms as unknown as string[],
};
}
4 changes: 2 additions & 2 deletions apps/api/src/routes/v1/graphql/schema/courses.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ type Course {
}

extend type Query {
"Get the course with the given ID."
course(courseId: String!): Course!
"Get the course with the given ID, or null if no such course exists."
course(courseId: String!): Course
"Get courses that match the given constraints."
courses(
"The department the courses are in."
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/routes/v1/graphql/schema/instructors.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ type Instructor {
}

extend type Query {
"Get the instructor with the corresponding UCInetID."
instructor(ucinetid: String!): Instructor!
"Get the instructor with the corresponding UCInetID, or null if no such instructor exists."
instructor(ucinetid: String!): Instructor
"Get instructors that match the given constraints."
instructors(
"A substring of the instructors' full names."
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/routes/v1/rest/courses/+endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { PrismaClient } from "@libs/db";
import { createHandler } from "@libs/lambda";
import { ZodError } from "zod";

import { constructPrismaQuery, normalizeCourse } from "./lib";
import { normalizeCourse } from "../../../../lib/utils";

import { constructPrismaQuery } from "./lib";
import { QuerySchema } from "./schema";

const prisma = new PrismaClient();
Expand Down
55 changes: 1 addition & 54 deletions apps/api/src/routes/v1/rest/courses/lib.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,7 @@
import { Course as PrismaCourse, CourseLevel as PrismaCourseLevel, Prisma } from "@libs/db";
import { Course, CourseLevel, GE, GECategory, PrerequisiteTree } from "@peterportal-api/types";
import { Prisma } from "@libs/db";

import { Query } from "./schema";

export function normalizeCourse(course: PrismaCourse): Course {
let courseLevel: CourseLevel;
switch (course.courseLevel as PrismaCourseLevel) {
case "LowerDiv":
courseLevel = "Lower Division (1-99)";
break;
case "UpperDiv":
courseLevel = "Upper Division (100-199)";
break;
case "Graduate":
courseLevel = "Graduate/Professional Only (200+)";
break;
}
const geList = (course.geList as Array<Omit<GE, "ANY">>).map((x): GECategory => {
switch (x) {
case "GE-1A":
return "GE Ia: Lower Division Writing";
case "GE-1B":
return "GE Ib: Upper Division Writing";
case "GE-2":
return "GE II: Science and Technology";
case "GE-3":
return "GE III: Social & Behavioral Sciences";
case "GE-4":
return "GE IV: Arts and Humanities";
case "GE-5A":
return "GE Va: Quantitative Literacy";
case "GE-5B":
return "GE Vb: Formal Reasoning";
case "GE-6":
return "GE VI: Language Other Than English";
case "GE-7":
return "GE VII: Multicultural Studies";
case "GE-8":
return "GE VIII: International/Global Issues";
// this branch should never happen
default:
throw new Error();
}
});
return {
...course,
courseLevel,
instructorHistory: course.instructorHistory as unknown as string[],
prerequisiteTree: course.prerequisiteTree as unknown as PrerequisiteTree,
prerequisiteList: course.prerequisiteList as unknown as string[],
prerequisiteFor: course.prerequisiteFor as unknown as string[],
geList,
terms: course.terms as unknown as string[],
};
}

/**
* Constructs a Prisma query for the given filter parameters.
* @param parsedQuery The query object parsed by Zod.
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/routes/v1/rest/courses/{id}/+config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiPropsOverride } from "@bronya.js/api-construct";

import { esbuildOptions, constructs } from "../../../../../../bronya.config";

export const overrides: ApiPropsOverride = {
esbuild: esbuildOptions,
constructs: {
functionPlugin: constructs.functionPlugin,
restApiProps: constructs.restApiProps,
},
};
45 changes: 13 additions & 32 deletions apps/api/src/routes/v1/rest/courses/{id}/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,20 @@
import { PrismaClient } from "@libs/db";
import { createHandler } from "@libs/lambda";

import { normalizeCourse } from "../lib";

const prisma = new PrismaClient();

async function onWarm() {
await prisma.$connect();
}
import { courses } from "virtual:courses";

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const requestId = context.awsRequestId;
const params = event.pathParameters;

if (params?.id == null) {
return res.createErrorResult(400, "Course number not provided", requestId);
}

if (params?.id === "all") {
const courses = await prisma.course.findMany();
return res.createOKResult(courses.map(normalizeCourse), headers, requestId);
}
const { id } = event.pathParameters ?? {};

try {
return res.createOKResult(
normalizeCourse(
await prisma.course.findFirstOrThrow({
where: { id: decodeURIComponent(params.id) },
}),
),
headers,
requestId,
);
} catch {
return res.createErrorResult(404, `Course ${params.id} not found`, requestId);
switch (id) {
case null:
case undefined:
return res.createErrorResult(400, "Course number not provided", requestId);
case "all":
return res.createOKResult(Object.values(courses), headers, requestId);
default:
return courses[decodeURIComponent(id)]
? res.createOKResult(courses[decodeURIComponent(id)], headers, requestId)
: res.createErrorResult(404, `Course ${id} not found`, requestId);
}
}, onWarm);
});
11 changes: 11 additions & 0 deletions apps/api/src/routes/v1/rest/instructors/{id}/+config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiPropsOverride } from "@bronya.js/api-construct";

import { esbuildOptions, constructs } from "../../../../../../bronya.config";

export const overrides: ApiPropsOverride = {
esbuild: esbuildOptions,
constructs: {
functionPlugin: constructs.functionPlugin,
restApiProps: constructs.restApiProps,
},
};
41 changes: 13 additions & 28 deletions apps/api/src/routes/v1/rest/instructors/{id}/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,20 @@
import { PrismaClient } from "@libs/db";
import { createHandler } from "@libs/lambda";

const prisma = new PrismaClient();

async function onWarm() {
await prisma.$connect();
}
import { instructors } from "virtual:instructors";

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const requestId = context.awsRequestId;
const params = event.pathParameters;

if (params?.id == null) {
return res.createErrorResult(400, "Instructor UCInetID not provided", requestId);
}

try {
if (params.id === "all") {
const instructors = await prisma.instructor.findMany();
return res.createOKResult(instructors, headers, requestId);
}
const { id } = event.pathParameters ?? {};

return res.createOKResult(
await prisma.instructor.findFirstOrThrow({
where: { ucinetid: decodeURIComponent(params.id) },
}),
headers,
requestId,
);
} catch {
return res.createErrorResult(404, `Instructor ${params.id} not found`, requestId);
switch (id) {
case null:
case undefined:
return res.createErrorResult(400, "Instructor UCInetID not provided", requestId);
case "all":
return res.createOKResult(Object.values(instructors), headers, requestId);
default:
return instructors[decodeURIComponent(id)]
? res.createOKResult(instructors[decodeURIComponent(id)], headers, requestId)
: res.createErrorResult(404, `Instructor ${id} not found`, requestId);
}
}, onWarm);
});
Loading

0 comments on commit ffe387c

Please sign in to comment.