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 16 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
13 changes: 13 additions & 0 deletions apps/api/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// These are the type declarations for the virtual modules
// used by the `/courses/{id}` and `/instructors/{id}` routes.
// The reason we have to use inline imports here is because with `import` statements, TypeScript
// treats this as a normal module rather than an ambient module, and so the module declarations
// don't actually work when consumed by other TypeScript source files.

declare module "virtual:courses" {
declare const courses: Record<string, import("@peterportal-api/types").Course>;
}

declare module "virtual:instructors" {
declare const instructors: Record<string, import("@peterportal-api/types").Instructor>;
}
ecxyzzy marked this conversation as resolved.
Show resolved Hide resolved
55 changes: 55 additions & 0 deletions apps/api/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Course as PrismaCourse, CourseLevel as PrismaCourseLevel } from "@libs/db";
import { Course, CourseLevel, GE, GECategory, PrerequisiteTree } from "@peterportal-api/types";

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

/**
Expand Down Expand Up @@ -35,3 +38,55 @@ export const flattenDayStringsAndSplit = (value: TransformInput): TransformOutpu
),
)
: undefined;

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();
}
ecxyzzy marked this conversation as resolved.
Show resolved Hide resolved
});
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,
},
};
Loading