From 4bbd53d79fb04ddbd66b1866dec6d02e9c5111f7 Mon Sep 17 00:00:00 2001 From: Jordan Date: Wed, 24 Apr 2024 10:13:35 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E2=9C=A8=20changed=20error=20respo?= =?UTF-8?q?nse=20to=20success=20flag=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/routes/v1/graphql/lib.ts | 3 +-- libs/lambda/src/response.ts | 8 ++++---- package.json | 1 - packages/types/types/response.ts | 6 +++++- packages/websoc-fuzzy-search/setup.ts | 5 ++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/api/src/routes/v1/graphql/lib.ts b/apps/api/src/routes/v1/graphql/lib.ts index a643400a..a73f6e2a 100644 --- a/apps/api/src/routes/v1/graphql/lib.ts +++ b/apps/api/src/routes/v1/graphql/lib.ts @@ -1,6 +1,5 @@ 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"; @@ -58,7 +57,7 @@ export const proxyRestApi = }; }); - if (isErrorResponse(data)) { + if (!data.success) { throw new GraphQLError(data.message, { extensions: { code: data.error.toUpperCase().replace(" ", "_"), diff --git a/libs/lambda/src/response.ts b/libs/lambda/src/response.ts index a4e90d62..3038f91d 100644 --- a/libs/lambda/src/response.ts +++ b/libs/lambda/src/response.ts @@ -47,7 +47,7 @@ export function createOKResult( ): APIGatewayProxyResult { const statusCode = 200; const timestamp = createTimestamp(); - const response: Response = { statusCode, timestamp, requestId, payload }; + const response: Response = { statusCode, timestamp, requestId, payload, success: true }; const headers = { ...responseHeaders }; try { @@ -89,12 +89,12 @@ export function createErrorResult( e instanceof Error ? `${e.name}: ${e.message}` : typeof e === "string" - ? e - : "An unknown error has occurred. Please try again."; + ? e + : "An unknown error has occurred. Please try again."; const error = httpErrorCodes[statusCode as keyof typeof httpErrorCodes]; - const body: ErrorResponse = { timestamp, requestId, statusCode, error, message }; + const body: ErrorResponse = { timestamp, requestId, statusCode, error, message, success: false }; logger.error(`${body.statusCode} ${body.error}: ${body.message}`); diff --git a/package.json b/package.json index 6223b36d..c50c74c3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ }, "packageManager": "pnpm@8.15.4", "engines": { - "node": "20", "pnpm": "8" } } diff --git a/packages/types/types/response.ts b/packages/types/types/response.ts index 21814103..7cbf0df5 100644 --- a/packages/types/types/response.ts +++ b/packages/types/types/response.ts @@ -25,6 +25,8 @@ export type Response = BaseResponse & { * The payload returned by the REST API. */ payload: T; + + success: true; }; /** @@ -39,6 +41,8 @@ export type ErrorResponse = BaseResponse & { * The detailed error message. */ message: string; + + success: false; }; /** @@ -54,4 +58,4 @@ export type RawResponse = Response | ErrorResponse; * ``ErrorResponse`` or a ``Response``. * @param r The object to test. */ -export const isErrorResponse = (r: RawResponse): r is ErrorResponse => "error" in r; +export const isErrorResponse = (r: RawResponse): r is ErrorResponse => r.success == false; diff --git a/packages/websoc-fuzzy-search/setup.ts b/packages/websoc-fuzzy-search/setup.ts index 358782dc..991204c7 100644 --- a/packages/websoc-fuzzy-search/setup.ts +++ b/packages/websoc-fuzzy-search/setup.ts @@ -2,7 +2,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { normalize } from "node:path"; import { gzipSync } from "node:zlib"; -import { isErrorResponse } from "@peterportal-api/types"; import type { Course, Instructor, RawResponse } from "@peterportal-api/types"; import fetch from "cross-fetch"; import pluralize from "pluralize"; @@ -219,7 +218,7 @@ async function main() { console.time("Data fetched in"); const coursesRes = await fetch("https://api-next.peterportal.org/v1/rest/courses/all"); const coursesJson: RawResponse = await coursesRes.json(); - if (isErrorResponse(coursesJson)) throw new Error("Could not fetch courses from API."); + if (!coursesJson.success) throw new Error("Could not fetch courses from API."); coursesJson.payload.forEach( ({ id, department, departmentName, courseNumber, geList, courseLevel, school, title }) => { d.courses[id] = { @@ -235,7 +234,7 @@ async function main() { ); const instructorsRes = await fetch("https://api-next.peterportal.org/v1/rest/instructors/all"); const instructorsJson: RawResponse = await instructorsRes.json(); - if (isErrorResponse(instructorsJson)) throw new Error("Could not fetch instructors from API."); + if (!instructorsJson.success) throw new Error("Could not fetch instructors from API."); instructorsJson.payload.forEach(({ ucinetid, shortenedName, name, schools, department }) => { d.instructors[ucinetid] = { ucinetid, From 74889e2a601605a64e1b155272c4a604199e165d Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Wed, 15 May 2024 08:29:25 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20filtering=20by=20?= =?UTF-8?q?restriction=20code=20to=20WebSoc=20endpoint=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kevin Wu Co-authored-by: Aponia --- .../src/routes/v1/graphql/schema/enum.graphql | 23 +++++++ .../routes/v1/graphql/schema/websoc.graphql | 1 + .../src/routes/v1/rest/websoc/+endpoint.ts | 34 ++++++++++- apps/api/src/routes/v1/rest/websoc/lib.ts | 11 ++++ apps/api/src/routes/v1/rest/websoc/schema.ts | 23 ++++++- libs/db/prisma/schema.prisma | 60 +++++++++++++------ services/websoc-scraper-v2/index.ts | 5 ++ 7 files changed, 135 insertions(+), 22 deletions(-) diff --git a/apps/api/src/routes/v1/graphql/schema/enum.graphql b/apps/api/src/routes/v1/graphql/schema/enum.graphql index 38820ffc..bf23e2d9 100644 --- a/apps/api/src/routes/v1/graphql/schema/enum.graphql +++ b/apps/api/src/routes/v1/graphql/schema/enum.graphql @@ -43,6 +43,29 @@ enum SectionType { Tap Tut } + +"The set of valid restriction codes." +enum RestrictionCode { + A + B + C + D + E + F + G + H + I + J + K + L + M + N + O + R + S + X +} + "The set of valid options for filtering full courses." enum FullCourses { ANY diff --git a/apps/api/src/routes/v1/graphql/schema/websoc.graphql b/apps/api/src/routes/v1/graphql/schema/websoc.graphql index bc211dad..ada14060 100644 --- a/apps/api/src/routes/v1/graphql/schema/websoc.graphql +++ b/apps/api/src/routes/v1/graphql/schema/websoc.graphql @@ -176,6 +176,7 @@ extend type Query { maxCapacity: String fullCourses: FullCourses cancelledCourses: CancelledCourses + excludeRestrictionCodes: [RestrictionCode!] ): WebsocAPIResponse! "Get data on all available departments." depts: [Department!]! diff --git a/apps/api/src/routes/v1/rest/websoc/+endpoint.ts b/apps/api/src/routes/v1/rest/websoc/+endpoint.ts index f49ce8a1..d7faba2c 100644 --- a/apps/api/src/routes/v1/rest/websoc/+endpoint.ts +++ b/apps/api/src/routes/v1/rest/websoc/+endpoint.ts @@ -11,7 +11,6 @@ import { QuerySchema } from "./schema"; const prisma = new PrismaClient(); -// let connected = false const lambdaClient = await APILambdaClient.new(); async function onWarm() { @@ -111,7 +110,13 @@ export const GET = createHandler(async (event, context, res) => { queries: normalizeQuery(parsedQuery), }); - return res.createOKResult(websocResults, headers, requestId); + if (!parsedQuery.excludeRestrictionCodes?.length) { + return res.createOKResult(websocResults, headers, requestId); + } + + const filteredWebsocResults = filterResults(websocResults, parsedQuery.excludeRestrictionCodes); + + return res.createOKResult(filteredWebsocResults, headers, requestId); } catch (error) { if (error instanceof ZodError) { const messages = error.issues.map((issue) => issue.message); @@ -120,3 +125,28 @@ export const GET = createHandler(async (event, context, res) => { return res.createErrorResult(400, error, requestId); } }, onWarm); + +function filterResults(results: WebsocAPIResponse, restrictionCodes: string[]): WebsocAPIResponse { + results.schools = results.schools + .map((school) => { + school.departments = school.departments + .map((department) => { + department.courses = department.courses + .map((course) => { + course.sections = course.sections.filter( + (section) => + !section.restrictions + .split(/ and | or /) + .some((code: string) => restrictionCodes.includes(code)), + ); + return course; + }) + .filter((course) => course.sections.length > 0); + return department; + }) + .filter((department) => department.courses.length > 0); + return school; + }) + .filter((school) => school.departments.length > 0); + return results; +} diff --git a/apps/api/src/routes/v1/rest/websoc/lib.ts b/apps/api/src/routes/v1/rest/websoc/lib.ts index b2ddf725..02202d1c 100644 --- a/apps/api/src/routes/v1/rest/websoc/lib.ts +++ b/apps/api/src/routes/v1/rest/websoc/lib.ts @@ -1,3 +1,4 @@ +import type { $Enums } from "@libs/db"; import type { Prisma } from "@libs/db"; import type { WebsocAPIOptions } from "@libs/uc-irvine-lib/websoc"; import { notNull } from "@libs/utils"; @@ -200,6 +201,16 @@ export function constructPrismaQuery(parsedQuery: Query): Prisma.WebsocSectionWh ); } + if (parsedQuery.excludeRestrictionCodes) { + AND.push({ + NOT: { + restrictionCodes: { + hasSome: parsedQuery.excludeRestrictionCodes as $Enums.RestrictionCode[], + }, + }, + }); + } + return { AND, diff --git a/apps/api/src/routes/v1/rest/websoc/schema.ts b/apps/api/src/routes/v1/rest/websoc/schema.ts index 10312c38..f7184b03 100644 --- a/apps/api/src/routes/v1/rest/websoc/schema.ts +++ b/apps/api/src/routes/v1/rest/websoc/schema.ts @@ -1,3 +1,4 @@ +import { $Enums } from "@libs/db"; import { anyArray, cancelledCoursesOptions, @@ -51,6 +52,12 @@ export const QuerySchema = z units: z.string().array().or(z.string()).optional().transform(flattenStringsAndSplit), startTime: z.optional(z.literal("").or(z.string().regex(/([1-9]|1[0-2]):[0-5][0-9][ap]m/))), endTime: z.optional(z.literal("").or(z.string().regex(/([1-9]|1[0-2]):[0-5][0-9][ap]m/))), + excludeRestrictionCodes: z + .string() + .array() + .or(z.string()) + .optional() + .transform(flattenStringsAndSplit), }) .refine((x) => x.cache || !x.cacheOnly, { message: "cacheOnly cannot be true if cache is false", @@ -67,7 +74,21 @@ export const QuerySchema = z ) .refine((x) => x.cacheOnly || x.building || !x.room, { message: 'If "building" is provided, "room" must also be provided', - }); + }) + .refine( + (x) => { + // If not excluding restriction codes, then no more validation is needed. + if (x.excludeRestrictionCodes == null) return true; + + // Ensure that all provided restriction codes are valid. + return x.excludeRestrictionCodes.every((code) => + Object.values($Enums.RestrictionCode).includes(code as $Enums.RestrictionCode), + ); + }, + { + message: `Restriction codes must be in [${Object.values($Enums.RestrictionCode).join(", ")}]`, + }, + ); /** * Type of the parsed query: useful for passing the query as input to other functions. diff --git a/libs/db/prisma/schema.prisma b/libs/db/prisma/schema.prisma index d648c063..8173a725 100644 --- a/libs/db/prisma/schema.prisma +++ b/libs/db/prisma/schema.prisma @@ -44,6 +44,27 @@ enum WebsocSectionType { Tut } +enum RestrictionCode { + A + B + C + D + E + F + G + H + I + J + K + L + M + N + O + R + S + X +} + // Models model CalendarTerm { @@ -224,25 +245,26 @@ model WebsocSectionMeeting { } model WebsocSection { - year String - quarter Quarter - sectionCode Int - timestamp DateTime - geCategories Json - department String - courseNumber String - courseNumeric Int - instructors WebsocSectionInstructor[] - courseTitle String - sectionType WebsocSectionType - units String - meetings WebsocSectionMeeting[] - maxCapacity Int - sectionFull Boolean - waitlistFull Boolean - overEnrolled Boolean - cancelled Boolean - data Json + year String + quarter Quarter + sectionCode Int + timestamp DateTime + geCategories Json + department String + courseNumber String + courseNumeric Int + instructors WebsocSectionInstructor[] + courseTitle String + sectionType WebsocSectionType + units String + meetings WebsocSectionMeeting[] + maxCapacity Int + sectionFull Boolean + waitlistFull Boolean + overEnrolled Boolean + cancelled Boolean + restrictionCodes RestrictionCode[] + data Json @@id([year, quarter, sectionCode, timestamp]) @@unique([year, quarter, sectionCode, timestamp], name: "idx") diff --git a/services/websoc-scraper-v2/index.ts b/services/websoc-scraper-v2/index.ts index 977b21e6..1709728a 100644 --- a/services/websoc-scraper-v2/index.ts +++ b/services/websoc-scraper-v2/index.ts @@ -1,3 +1,4 @@ +import type { $Enums } from "@libs/db"; import { PrismaClient } from "@libs/db"; import { getTermDateData } from "@libs/uc-irvine-lib/registrar"; import type { @@ -116,6 +117,7 @@ type ProcessedSection = { waitlistFull: boolean; overEnrolled: boolean; cancelled: boolean; + restrictionCodes: $Enums.RestrictionCode[]; data: object; }; }; @@ -391,6 +393,9 @@ async function scrape(name: string, term: Term) { parseInt(section.numCurrentlyEnrolled.totalEnrolled, 10) > parseInt(section.maxCapacity, 10), cancelled: section.sectionComment.includes("*** CANCELLED ***"), + restrictionCodes: section.restrictions + ? (section.restrictions.split(/ and | or /) as $Enums.RestrictionCode[]) + : [], data: isolateSection({ school, department, course, section }), }, };