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

perf: ⚡️ optimize courses/instructors by ID #117

Merged
merged 18 commits into from
Dec 21, 2023
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
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]),
),
)}`,
}));
ecxyzzy marked this conversation as resolved.
Show resolved Hide resolved
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