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

feat: ✨ add DynamoDB-backed cache (WIP) #127

Closed
wants to merge 10 commits into from
43 changes: 40 additions & 3 deletions apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ 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";
import { RuleTargetInput, Rule, Schedule } from "aws-cdk-lib/aws-events";
import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import {
Effect,
ManagedPolicy,
PolicyDocument,
PolicyStatement,
Role,
ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import { Architecture, Code, Function as AwsLambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
import { ARecord, HostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
import { ApiGateway } from "aws-cdk-lib/aws-route53-targets";
import { App, Stack, Duration } from "aws-cdk-lib/core";
import { App, Duration, Stack } from "aws-cdk-lib/core";
import { config } from "dotenv";
import type { BuildOptions } from "esbuild";

Expand Down Expand Up @@ -141,7 +150,28 @@ export const esbuildOptions: BuildOptions = {
* Shared construct props.
*/
export const constructs: ApiConstructProps = {
functionProps: () => ({ runtime: Runtime.NODEJS_20_X }),
functionProps: (scope, id, route) => ({
runtime: Runtime.NODEJS_20_X,
role: new Role(scope, `${id}-${route.endpoint}-role`, {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
],
inlinePolicies: {
lambdaInvokePolicy: new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
resources: [
`arn:aws:dynamodb:${process.env["AWS_REGION"]}:${process.env["ACCOUNT_ID"]}:table/${id}-cache`,
],
actions: ["dynamodb:GetItem", "dynamodb:PutItem"],
}),
],
}),
},
}),
}),
functionPlugin: ({ functionProps, handler }, scope) => {
const warmingTarget = new LambdaFunction(handler, {
event: RuleTargetInput.fromObject(warmingRequestBody),
Expand Down Expand Up @@ -171,6 +201,7 @@ export const constructs: ApiConstructProps = {
*/
class ApiStack extends Stack {
public api: Api;
public cache: Table;

constructor(scope: App, id: string, stage: string) {
super(scope, id);
Expand Down Expand Up @@ -205,6 +236,12 @@ class ApiStack extends Stack {
},
esbuild: esbuildOptions,
});
this.cache = new Table(this, `${id}-cache`, {
tableName: `${id}-cache`,
partitionKey: { name: "cacheKey", type: AttributeType.STRING },
timeToLiveAttribute: "expireAt",
billingMode: BillingMode.PAY_PER_REQUEST,
});
}
}

Expand Down
5 changes: 3 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@graphql-tools/load-files": "7.0.0",
"@graphql-tools/merge": "9.0.1",
"@graphql-tools/utils": "10.0.13",
"@libs/cache-client": "workspace:^",
"@libs/db": "workspace:^",
"@libs/lambda": "workspace:^",
"@libs/uc-irvine-api": "workspace:^",
Expand All @@ -38,8 +39,8 @@
"zod": "3.22.4"
},
"devDependencies": {
"@bronya.js/api-construct": "0.11.3",
"@bronya.js/core": "0.11.3",
"@bronya.js/api-construct": "0.11.4",
"@bronya.js/core": "0.11.4",
"@types/aws-lambda": "8.10.132",
"aws-cdk": "2.124.0",
"dotenv": "16.4.1",
Expand Down
118 changes: 71 additions & 47 deletions apps/api/src/routes/v1/rest/grades/{id}/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APICacheClient } from "@libs/cache-client";
import { PrismaClient } from "@libs/db";
import { logger, createHandler } from "@libs/lambda";
import type {
Expand All @@ -20,35 +21,49 @@ import { QuerySchema } from "./schema";

const prisma = new PrismaClient();

const cacheClient = new APICacheClient({ route: "/v1/rest/grades" });

async function onWarm() {
await prisma.$connect();
}

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

try {
const parsedQuery = QuerySchema.parse(query);
switch (params?.id) {
case "raw":
case "aggregate":
{
const result = (
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow);
switch (params.id) {
case "raw":
return res.createOKResult<RawGrades>(result, headers, requestId);
case "aggregate":
return res.createOKResult(aggregateGrades(result), headers, requestId);
}
case "aggregate": {
const cacheResult = await cacheClient.get({ ...query, id: params.id });
if (cacheResult) return res.createOKResult(cacheResult, headers, requestId);
const result = (
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow);
switch (params.id) {
case "raw":
return res.createOKResult<RawGrades>(
await cacheClient.put({ ...query, id: params.id }, result),
headers,
requestId,
);
case "aggregate":
return res.createOKResult(
await cacheClient.put({ ...query, id: params.id }, aggregateGrades(result)),
headers,
requestId,
);
}
break;
}
// eslint-disable-next-line no-fallthrough
case "options": {
const cacheResult = await cacheClient.get({ ...query, id: params.id });
if (cacheResult) return res.createOKResult(cacheResult, headers, requestId);
const result = await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
select: {
Expand Down Expand Up @@ -79,50 +94,59 @@ export const GET = createHandler(async (event, context, res) => {
sectionCodes: Array.from(sectionCodes).sort(),
};
return res.createOKResult<GradesOptions>(
{
...ret,
instructors: parsedQuery.instructor
? [parsedQuery.instructor]
: (
await prisma.gradesInstructor.findMany({
where: {
year: { in: ret.years },
sectionCode: { in: ret.sectionCodes },
},
select: { name: true },
distinct: ["name"],
})
)
.map((x) => x.name)
.sort(),
},
await cacheClient.put(
{ ...query, id: params.id },
{
...ret,
instructors: parsedQuery.instructor
? [parsedQuery.instructor]
: (
await prisma.gradesInstructor.findMany({
where: {
year: { in: ret.years },
sectionCode: { in: ret.sectionCodes },
},
select: { name: true },
distinct: ["name"],
})
)
.map((x) => x.name)
.sort(),
},
),
headers,
requestId,
);
}
case "aggregateByCourse": {
return res.createOKResult<AggregateGradesByCourse>(
aggregateByCourse(
(
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow),
await cacheClient.put(
{ ...query, id: params.id },
aggregateByCourse(
(
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow),
),
),
headers,
requestId,
);
}
case "aggregateByOffering": {
return res.createOKResult<AggregateGradesByOffering>(
aggregateByOffering(
(
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow),
await cacheClient.put(
{ ...query, id: params.id },
aggregateByOffering(
(
await prisma.gradesSection.findMany({
where: constructPrismaQuery(parsedQuery),
include: { instructors: true },
})
).map(transformRow),
),
),
headers,
requestId,
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/routes/v1/rest/websoc/+config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { constructs, esbuildOptions } from "../../../../../bronya.config";
export const overrides: ApiPropsOverride = {
constructs: {
...constructs,
functionProps: (scope, id) => ({
...constructs.functionProps?.(scope, id),
functionProps: (scope, id, route) => ({
...constructs.functionProps?.(scope, id, route),
role: new Role(scope, `${id}-v1-rest-websoc-role`, {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
Expand All @@ -30,6 +30,13 @@ export const overrides: ApiPropsOverride = {
],
actions: ["lambda:InvokeFunction"],
}),
new PolicyStatement({
effect: Effect.ALLOW,
resources: [
`arn:aws:dynamodb:${process.env["AWS_REGION"]}:${process.env["ACCOUNT_ID"]}:table/${id}-cache`,
],
actions: ["dynamodb:GetItem", "dynamodb:PutItem"],
}),
],
}),
},
Expand Down
21 changes: 17 additions & 4 deletions apps/api/src/routes/v1/rest/websoc/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { APICacheClient } from "@libs/cache-client";
import { PrismaClient } from "@libs/db";
import { createHandler } from "@libs/lambda";
import type { WebsocAPIResponse } from "@libs/uc-irvine-api/websoc";
Expand All @@ -10,22 +11,26 @@ import { QuerySchema } from "./schema";

const prisma = new PrismaClient();

// let connected = false
const lambdaClient = await APILambdaClient.new();

const cacheClient = new APICacheClient({ route: "/v1/rest/websoc" });

async function onWarm() {
await prisma.$connect();
}

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const query = event.queryStringParameters;
const query = event.queryStringParameters ?? {};
const requestId = context.awsRequestId;

try {
const parsedQuery = QuerySchema.parse(query);

if (parsedQuery.cache) {
const cacheResult = await cacheClient.get(query);
if (cacheResult) return res.createOKResult(cacheResult, headers, requestId);

const websocSections = await prisma.websocSection.findMany({
where: constructPrismaQuery(parsedQuery),
select: { department: true, courseNumber: true, data: true },
Expand Down Expand Up @@ -82,7 +87,11 @@ export const GET = createHandler(async (event, context, res) => {

const combinedResponses = combineAndNormalizeResponses(...responses);

return res.createOKResult(sortResponse(combinedResponses), headers, requestId);
return res.createOKResult(
await cacheClient.put(query, sortResponse(combinedResponses)),
headers,
requestId,
);
}

const websocApiResponses = websocSections
Expand All @@ -91,7 +100,11 @@ export const GET = createHandler(async (event, context, res) => {

const combinedResponses = combineAndNormalizeResponses(...websocApiResponses);

return res.createOKResult(sortResponse(combinedResponses), headers, requestId);
return res.createOKResult(
await cacheClient.put(query, sortResponse(combinedResponses)),
headers,
requestId,
);
}

/**
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/routes/v1/rest/websoc/{id}/+config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { constructs, esbuildOptions } from "../../../../../../bronya.config";
export const overrides: ApiPropsOverride = {
constructs: {
...constructs,
functionProps: (scope, id) => ({
...constructs.functionProps?.(scope, id),
functionProps: (scope, id, route) => ({
...constructs.functionProps?.(scope, id, route),
role: new Role(scope, `${id}-v1-rest-websoc-id-role`, {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
Expand All @@ -30,6 +30,13 @@ export const overrides: ApiPropsOverride = {
],
actions: ["lambda:InvokeFunction"],
}),
new PolicyStatement({
effect: Effect.ALLOW,
resources: [
`arn:aws:dynamodb:${process.env["AWS_REGION"]}:${process.env["ACCOUNT_ID"]}:table/${id}-cache`,
],
actions: ["dynamodb:GetItem", "dynamodb:PutItem"],
}),
],
}),
},
Expand Down
13 changes: 13 additions & 0 deletions libs/cache-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@libs/cache-client",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@aws-sdk/client-dynamodb": "3.501.0",
"@aws-sdk/lib-dynamodb": "3.501.0"
}
}
Loading
Loading