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

Commit

Permalink
feat: ✨ migrate graphql endpoint (#97)
Browse files Browse the repository at this point in the history
Co-authored-by: Aponia <[email protected]>
  • Loading branch information
ecxyzzy and ap0nia authored Sep 8, 2023
1 parent f9574ba commit 9b65441
Show file tree
Hide file tree
Showing 15 changed files with 1,240 additions and 32 deletions.
42 changes: 29 additions & 13 deletions apps/api/bronya.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ import { createApiCliPlugins } from "@bronya.js/api-construct/plugins/cli";
import { logger } from "@libs/lambda";

/**
* @see https://github.com/evanw/esbuild/issues/1921#issuecomment-1491470829
* @see https://github.com/evanw/esbuild/issues/1921#issuecomment-1623640043
*/
const js = `\
import * as path from 'path';
import { fileURLToPath } from 'url';
import { createRequire as topLevelCreateRequire } from 'module';
const require = topLevelCreateRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// language=JavaScript
const js = `
import topLevelModule from "node:module";
import topLevelUrl from "node:url";
import topLevelPath from "node:path";
const require = topLevelModule.createRequire(import.meta.url);
const __filename = topLevelUrl.fileURLToPath(import.meta.url);
const __dirname = topLevelPath.dirname(__filename);
`;

const projectRoot = process.cwd();
Expand Down Expand Up @@ -80,19 +82,33 @@ class MyStack extends Stack {
});
},
},
environment: {
DATABASE_URL: process.env["DATABASE_URL"] ?? "",
SHADOW_DATABASE_URL: process.env["SHADOW_DATABASE_URL"] ?? "",
},
environment: { DATABASE_URL: process.env["DATABASE_URL"] ?? "" },
esbuild: {
format: "esm",
platform: "node",
bundle: true,
minify: true,
banner: { js },
outExtension: { ".js": ".mjs" },
plugins: [
{
name: "copy",
name: "copy-graphql-schema",
setup(build) {
build.onStart(async () => {
if (!build.initialOptions.outdir?.endsWith("graphql")) return;

fs.mkdirSync(build.initialOptions.outdir, { recursive: true });

fs.cpSync(
path.resolve(projectRoot, "src/routes/v1/graphql/schema"),
path.join(build.initialOptions.outdir, "schema"),
{ recursive: true },
);
});
},
},
{
name: "copy-prisma",
setup(build) {
build.onStart(async () => {
const outDirectory = build.initialOptions.outdir ?? projectRoot;
Expand Down
9 changes: 7 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
"dev": "bunny dev-api"
},
"dependencies": {
"@apollo/server": "4.9.2",
"@aws-sdk/client-lambda": "3.398.0",
"@graphql-tools/load-files": "7.0.0",
"@graphql-tools/merge": "9.0.0",
"@graphql-tools/utils": "10.0.5",
"@libs/db": "workspace:^",
"@libs/lambda": "workspace:^",
"@libs/uc-irvine-api": "workspace:^",
Expand All @@ -31,11 +35,12 @@
"aws-cdk-lib": "2.93.0",
"cheerio": "1.0.0-rc.12",
"cross-fetch": "4.0.0",
"graphql": "16.8.0",
"zod": "3.22.2"
},
"devDependencies": {
"@bronya.js/api-construct": "0.10.8",
"@bronya.js/core": "0.10.8",
"@bronya.js/api-construct": "0.10.9",
"@bronya.js/core": "0.10.9",
"@types/aws-lambda": "8.10.119",
"aws-cdk": "2.93.0",
"dotenv": "16.3.1",
Expand Down
85 changes: 85 additions & 0 deletions apps/api/src/routes/v1/graphql/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { join } from "node:path";
import { parse } from "node:url";

import { ApolloServer, HeaderMap, HTTPGraphQLRequest } from "@apollo/server";
import {
ApolloServerPluginLandingPageLocalDefault,
ApolloServerPluginLandingPageProductionDefault,
} from "@apollo/server/plugin/landingPage/default";
import { loadFilesSync } from "@graphql-tools/load-files";
import { mergeTypeDefs } from "@graphql-tools/merge";
import { compress } from "@libs/lambda";
import type { APIGatewayProxyHandler, APIGatewayProxyResult } from "aws-lambda";

import { transformBody } from "./lib";
import { resolvers } from "./resolvers";

const responseHeaders: APIGatewayProxyResult["headers"] = {
"Access-Control-Allow-Headers": "Apollo-Require-Preflight, Content-Type",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
};

const graphqlServer = new ApolloServer({
introspection: true,
plugins: [
process.env.NODE_ENV === "development"
? ApolloServerPluginLandingPageLocalDefault()
: ApolloServerPluginLandingPageProductionDefault({ footer: false }),
],
resolvers,
typeDefs: mergeTypeDefs(loadFilesSync(join(__dirname, "schema/*.graphql"))),
});

export const ANY: APIGatewayProxyHandler = async (event) => {
const { body, headers: eventHeaders, httpMethod: method } = event;

try {
graphqlServer.assertStarted("");
} catch {
await graphqlServer.start();
}

let headers: HeaderMap;
const req: HTTPGraphQLRequest = {
body,
get headers() {
headers ??= Object.entries(eventHeaders).reduce(
(m, [k, v]) => m.set(k, Array.isArray(v) ? v.join(", ") : v ?? ""),
new HeaderMap(),
);
return headers;
},
method,
search: parse(event.path ?? "").search ?? "",
};
const res = await graphqlServer.executeHTTPGraphQLRequest({
httpGraphQLRequest: req,
context: async () => ({}),
});

const statusCode = res.status ?? 200;
res.headers.forEach((v, k) => (responseHeaders[k] = v));

try {
const { body, method } = compress(
await transformBody(res.body),
req.headers.get("accept-encoding"),
);
if (method) {
responseHeaders["content-encoding"] = method;
}
return {
body,
headers: responseHeaders,
isBase64Encoded: !!method,
statusCode,
};
} catch {
return {
body: "",
headers: responseHeaders,
statusCode: 500,
};
}
};
70 changes: 70 additions & 0 deletions apps/api/src/routes/v1/graphql/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { BaseContext, HTTPGraphQLResponse } from "@apollo/server";
import type { IFieldResolver } from "@graphql-tools/utils";
import { isErrorResponse } from "@peterportal-api/types";
import type { RawResponse } from "@peterportal-api/types";
import { GraphQLError } from "graphql/error";

function getBaseUrl() {
switch (process.env.NODE_ENV) {
case "production":
return `https://api-next.peterportal.org`;
case "staging":
return `https://${process.env.STAGE}.api-next.peterportal.org`;
default:
return `http://localhost:${process.env.API_PORT ?? 8080}`;
}
}

export async function transformBody(body: HTTPGraphQLResponse["body"]): Promise<string> {
if (body.kind === "complete") {
return body.string;
}
let transformedBody = "";
for await (const chunk of body.asyncIterator) {
transformedBody += chunk;
}
return transformedBody;
}

export function geTransform(args: Record<string, string>) {
if (args.ge) return { ...args, ge: args.ge.replace("_", "-") };
delete args.ge;
return args;
}

export const proxyRestApi =
(
route: string,
proxyArgs?: {
argsTransform?: (args: Record<string, string>) => Record<string, string>;
pathArg?: string;
},
): IFieldResolver<never, BaseContext> =>
async (_source, args, _context, _info) => {
const { argsTransform = (args: Record<string, string>) => args, pathArg } = proxyArgs ?? {};
const urlSearchParams = new URLSearchParams(argsTransform(args));
const query = urlSearchParams.toString();

const data: RawResponse<unknown> = await fetch(
pathArg
? `${getBaseUrl()}${route}/${args[pathArg]}`
: `${getBaseUrl()}${route}${query ? "?" + query : ""}`,
)
.then((res) => res.json())
.catch((err) => {
return {
error: `INTERNAL_SERVER_ERROR: ${err.message}`,
message: err.message,
};
});

if (isErrorResponse(data)) {
throw new GraphQLError(data.message, {
extensions: {
code: data.error.toUpperCase().replace(" ", "_"),
},
});
}

return data.payload;
};
23 changes: 23 additions & 0 deletions apps/api/src/routes/v1/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ApolloServerOptions, BaseContext } from "@apollo/server";

import { geTransform, proxyRestApi } from "./lib";

export const resolvers: ApolloServerOptions<BaseContext>["resolvers"] = {
Query: {
calendar: proxyRestApi("/v1/rest/calendar"),
course: proxyRestApi("/v1/rest/courses", { pathArg: "courseId" }),
courses: proxyRestApi("/v1/rest/courses", { argsTransform: geTransform }),
allCourses: proxyRestApi("/v1/rest/courses/all"),
rawGrades: proxyRestApi("/v1/rest/grades/raw"),
aggregateGrades: proxyRestApi("/v1/rest/grades/aggregate"),
gradesOptions: proxyRestApi("/v1/rest/grades/options"),
instructor: proxyRestApi("/v1/rest/instructors", { pathArg: "courseId" }),
instructors: proxyRestApi("/v1/rest/instructors"),
allInstructors: proxyRestApi("/v1/rest/instructors/all"),
larc: proxyRestApi("/v1/rest/larc"),
websoc: proxyRestApi("/v1/rest/websoc", { argsTransform: geTransform }),
depts: proxyRestApi("/v1/rest/websoc/depts"),
terms: proxyRestApi("/v1/rest/websoc/terms"),
week: proxyRestApi("/v1/rest/week"),
},
};
8 changes: 8 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/base.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"A JavaScript Date object returned as a string."
scalar Date
"A JavaScript object."
scalar JSON

type Query {
_empty: String
}
16 changes: 16 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/calendar.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"An object that includes important dates for a specified quarter."
type QuarterDates {
"When instruction begins for the given quarter."
instructionStart: Date!
"When instruction ends for the given quarter."
instructionEnd: Date!
"When finals begin for the given quarter."
finalsStart: Date!
"When finals end for the given quarter."
finalsEnd: Date!
}

extend type Query {
"Get important dates for a quarter."
calendar(year: String!, quarter: Quarter!): QuarterDates!
}
87 changes: 87 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/courses.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"An object that represents a course."
type Course {
"The course ID."
id: String!
"The department code that the course belongs to."
department: String!
"The course number of the course."
courseNumber: String!
"The numeric part of the course number."
courseNumeric: Int!
"The school that the course belongs to."
school: String!
"The title of the course."
title: String!
"The level of the course."
courseLevel: String!
"The minimum number of units that can be earned by taking the course."
minUnits: Float!
"The maximum number of units that can be earned by taking the course."
maxUnits: Float!
"The course description."
description: String!
"The name of the department that the course belongs to."
departmentName: String!
"The UCINetIDs of all instructors who have taught this course in the past."
instructorHistory: [String!]!
"The prerequisite tree object for the course."
prerequisiteTree: JSON!
"The list of prerequisites for the course."
prerequisiteList: [String!]!
"The catalogue's prerequisite text for the course."
prerequisiteText: String!
"The courses for which this course is a prerequisite."
prerequisiteFor: [String!]!
"The repeat policy for this course."
repeatability: String!
"The grading option(s) available for this course."
gradingOption: String!
"The course(s) with which this course is concurrent."
concurrent: String!
"The course(s) that are the same as this course."
sameAs: String!
"The enrollment restriction(s) placed on this course."
restriction: String!
"The course(s) with which this course overlaps."
overlap: String!
"The corequisites for this course."
corequisites: String!
"The list of GE categories that this course fulfills."
geList: [String!]!
"The catalogue's GE text for this course."
geText: String!
"The list of terms in which this course was offered."
terms: [String!]!
}

extend type Query {
"Get the course with the given ID."
course(courseId: String!): Course!
"Get courses that match the given constraints."
courses(
"The department the courses are in."
department: String
"The course number of the courses."
courseNumber: String
"The numeric part of the course number."
courseNumeric: Int
"A substring of the courses' titles."
titleContains: String
"The level of the courses."
courseLevel: Division
"The minimum units of the courses."
minUnits: Float
"The maximum units of the courses."
maxUnits: Float
"A substring of the courses' descriptions."
descriptionContains: String
"A comma-separated list of all instructors that may have taught these courses."
taughtByInstructors: String
"The GE category that the courses fulfill."
geCategory: GE
"A comma-separated list of all terms that the courses may have been taught in."
taughtInTerms: String
): [Course!]!
"Get all courses."
allCourses: [Course!]!
}
Loading

0 comments on commit 9b65441

Please sign in to comment.