From 554b8584e3472a571caf15ea80be0233db63b290 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 11:39:49 -0400 Subject: [PATCH 01/24] add koa-zod-router library --- package-lock.json | 165 ++++++++++++------ package.json | 5 +- .../us/minnesota/northfield}/menu.test.ts | 4 +- .../us/minnesota/northfield/menu.ts | 127 ++++++++++++++ 4 files changed, 245 insertions(+), 56 deletions(-) rename source/{ccci-carleton-college/v1 => ccci-shared/us/minnesota/northfield}/menu.test.ts (95%) create mode 100644 source/ccci-shared/us/minnesota/northfield/menu.ts diff --git a/package-lock.json b/package-lock.json index faf9bd87..a73f8655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,16 +17,14 @@ "is-absolute-url": "4.0.1", "jsdom": "24.0.0", "koa": "2.15.3", - "koa-bodyparser": "4.4.1", "koa-cash": "^4.1.1", "koa-compress": "5.1.1", "koa-conditional-get": "3.0.0", - "koa-ctx-cache-control": "1.0.1", "koa-etag": "4.0.0", "koa-json-error": "3.1.2", "koa-logger": "3.2.1", "koa-response-time": "2.1.0", - "koa-router": "12.0.1", + "koa-zod-router": "^2.2.0", "ky": "^1.2.4", "lodash-es": "^4.17.21", "memoize": "10.0.0", @@ -35,6 +33,7 @@ "normalize-url": "8.0.1", "p-map": "7.0.2", "turndown": "7.1.3", + "type-fest": "^4.18.2", "zod": "^3.23.8" }, "devDependencies": { @@ -205,6 +204,44 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "node_modules/@koa/router": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.1.tgz", + "integrity": "sha512-ribfPYfHb+Uw3b27Eiw6NPqjhIhTpVFzEWLwyc/1Xp+DCdwRRyIlAUODX+9bPARF6aQtUu1+/PHzdNvRzcs/+Q==", + "dependencies": { + "debug": "^4.3.4", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.2.1" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@koa/router/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@koa/router/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -1507,6 +1544,14 @@ "@types/send": "*" } }, + "node_modules/@types/formidable": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz", + "integrity": "sha512-L4HcrA05IgQyNYJj6kItuIkXrInJvsXTPC5B1i64FggWKKqSL+4hgt7asiSNva75AoLQjq29oPxFfU4GAQ6Z2w==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-assert": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.5.tgz", @@ -1566,7 +1611,6 @@ "version": "4.3.12", "resolved": "https://registry.npmjs.org/@types/koa-bodyparser/-/koa-bodyparser-4.3.12.tgz", "integrity": "sha512-hKMmRMVP889gPIdLZmmtou/BijaU1tHPyMNmcK7FAHAdATnRcGQQy78EqTTxLH1D4FTsrxIzklAQCso9oGoebQ==", - "dev": true, "dependencies": { "@types/koa": "*" } @@ -2183,6 +2227,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", @@ -2934,6 +2983,15 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3517,6 +3575,20 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz", + "integrity": "sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -3852,6 +3924,14 @@ "node": ">= 0.4" } }, + "node_modules/hexoid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", + "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -4416,14 +4496,6 @@ "node": ">= 10" } }, - "node_modules/koa-ctx-cache-control": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/koa-ctx-cache-control/-/koa-ctx-cache-control-1.0.1.tgz", - "integrity": "sha512-r0OniNTTLs0PvqjOKgydVFqb8l8wuSDDTVwwVZyyXfpHOpe+21Lk/6njXm6LDVxGZY2/QCTInN21fSg7fmEMUA==", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/koa-etag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/koa-etag/-/koa-etag-4.0.0.tgz", @@ -4532,42 +4604,22 @@ "resolved": "https://registry.npmjs.org/koa-response-time/-/koa-response-time-2.1.0.tgz", "integrity": "sha512-W2YvnmqmmdEwjJh2rvUPqDD+yjnKvIQry6JhV9dHEwGpfcRzjmQBRVNg62Czbvut/Dpf+l7b9uFauet157UeUQ==" }, - "node_modules/koa-router": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/koa-router/-/koa-router-12.0.1.tgz", - "integrity": "sha512-gaDdj3GtzoLoeosacd50kBBTnnh3B9AYxDThQUo4sfUyXdOhY6ku1qyZKW88tQCRgc3Sw6ChXYXWZwwgjOxE0w==", - "dependencies": { - "debug": "^4.3.4", - "http-errors": "^2.0.0", - "koa-compose": "^4.1.0", - "methods": "^1.1.2", - "path-to-regexp": "^6.2.1" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/koa-router/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/koa-zod-router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/koa-zod-router/-/koa-zod-router-2.2.0.tgz", + "integrity": "sha512-Sx6e1mPipEJiyDnNnebOCoU+7a1dghaqAbyv2Bt6SpUOIJV9JpO1T8EXPCV9CQK8fuC6t3spruuXcaXMPzRB7A==", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "@koa/router": "^12.0.1", + "@types/formidable": "^2.0.6", + "@types/koa__router": "^12.0.2", + "@types/koa-bodyparser": "^4.3.11", + "formidable": "^2.1.2", + "koa-bodyparser": "^4.4.1", + "zod": "^3.22.4" }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/koa-router/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" + "peerDependencies": { + "koa": ">=2.14.1 <3.x", + "zod": ">=3.22.4 <4.x" } }, "node_modules/ky": { @@ -5765,6 +5817,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -6259,12 +6323,11 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", + "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 30e5bb9a..5c94101b 100644 --- a/package.json +++ b/package.json @@ -47,16 +47,14 @@ "is-absolute-url": "4.0.1", "jsdom": "24.0.0", "koa": "2.15.3", - "koa-bodyparser": "4.4.1", "koa-cash": "^4.1.1", "koa-compress": "5.1.1", "koa-conditional-get": "3.0.0", - "koa-ctx-cache-control": "1.0.1", "koa-etag": "4.0.0", "koa-json-error": "3.1.2", "koa-logger": "3.2.1", "koa-response-time": "2.1.0", - "koa-router": "12.0.1", + "koa-zod-router": "^2.2.0", "ky": "^1.2.4", "lodash-es": "^4.17.21", "memoize": "10.0.0", @@ -65,6 +63,7 @@ "normalize-url": "8.0.1", "p-map": "7.0.2", "turndown": "7.1.3", + "type-fest": "^4.18.2", "zod": "^3.23.8" }, "devDependencies": { diff --git a/source/ccci-carleton-college/v1/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts similarity index 95% rename from source/ccci-carleton-college/v1/menu.test.ts rename to source/ccci-shared/us/minnesota/northfield/menu.test.ts index 1eb7c8c4..30e961f6 100644 --- a/source/ccci-carleton-college/v1/menu.test.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -9,7 +9,7 @@ import {keysOf} from '../../ccc-lib/keysOf.js' const cafeInfoFunctions: Record Promise> = { stav: menu.stavCafe, cage: menu.cageCafe, - kingsRoom: menu.kingsRoomCafe, + kings: menu.kingsRoomCafe, cave: menu.caveCafe, burton: menu.burtonCafe, ldc: menu.ldcCafe, @@ -21,7 +21,7 @@ const cafeInfoFunctions: Record Pro const cafeMenuFunctions: Record Promise> = { stav: menu.stavMenu, cage: menu.cageMenu, - kingsRoom: menu.kingsRoomMenu, + kings: menu.kingsRoomMenu, cave: menu.caveMenu, burton: menu.burtonMenu, ldc: menu.ldcMenu, diff --git a/source/ccci-shared/us/minnesota/northfield/menu.ts b/source/ccci-shared/us/minnesota/northfield/menu.ts new file mode 100644 index 00000000..49fa8618 --- /dev/null +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -0,0 +1,127 @@ +import mem from 'memoize' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' +import {get} from '../../../../ccc-lib/http.js' +import {ONE_DAY, ONE_HOUR} from '../../../../ccc-lib/constants.js' +import * as bonapp from '../../../../menus-bonapp/index.js' +import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../../../menus-bonapp/types.js' +import {GH_PAGES} from '../../../../ccci-stolaf-college/v1/gh-pages.js' + +const pauseMenuUrl = GH_PAGES('pause-menu.json') +const GET_DAY = mem(get, {maxAge: ONE_DAY}) +export const getPauseMenu = async () => + CafeMenuResponseSchema.parse(await GET_DAY(pauseMenuUrl).json()) + +const getMenu = mem(bonapp.menu, {maxAge: ONE_HOUR}) +const getInfo = mem(bonapp.cafe, {maxAge: ONE_HOUR}) +const getNutrition = mem(bonapp.nutrition, {maxAge: ONE_HOUR}) + +type BONAPP_CAFE_NAMES_TYPE = z.infer +export const BONAPP_CAFE_NAMES = z.union([ + z.literal('stav-hall'), + z.literal('the-cage'), + z.literal('kings-room'), + z.literal('the-cave'), + z.literal('c-store'), + z.literal('burton'), + z.literal('ldc'), + z.literal('sayles'), + z.literal('weitz'), + z.literal('schulze'), +]) + +export const CAFE_NAMES = BONAPP_CAFE_NAMES.or(z.literal('the-pause')) + +export const CAFE_URLS: Record = { + 'stav-hall': 'https://stolaf.cafebonappetit.com/cafe/stav-hall/', + 'the-cage': 'https://stolaf.cafebonappetit.com/cafe/the-cage/', + 'kings-room': 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', + 'the-cave': 'https://stolaf.cafebonappetit.com/cafe/the-cave/', + 'c-store': 'https://stolaf.cafebonappetit.com/cafe/the-cave/', // alias for "cave" + burton: 'https://carleton.cafebonappetit.com/cafe/burton/', + ldc: 'https://carleton.cafebonappetit.com/cafe/east-hall/', + sayles: 'https://carleton.cafebonappetit.com/cafe/sayles-cafe/', + weitz: 'https://carleton.cafebonappetit.com/cafe/weitz-cafe/', + schulze: 'https://carleton.cafebonappetit.com/cafe/schulze-cafe/', +} as const + +export const CAFE_ID_TO_URL: Record = { + 261: 'stav-hall', + 262: 'the-cage', + 263: 'kings-room', + 35: 'burton', + 36: 'ldc', + 34: 'sayles', + 458: 'weitz', +} as const + +export const getNamedMenuRoute = createRouteSpec({ + method: 'get', + path: '/food/named/menu/:cafeName', + validate: { + params: z.object({cafeName: CAFE_NAMES}), + response: CafeMenuResponseSchema, + }, + handler: async (ctx) => { + if (ctx.request.params.cafeName === 'the-pause') { + ctx.body = await getPauseMenu() + } else { + ctx.body = await getMenu(CAFE_URLS[ctx.request.params.cafeName]) + } + }, +}) + +export const getNamedCafeRoute = createRouteSpec({ + method: 'get', + path: '/food/named/cafe/:cafeName', + validate: { + params: z.object({cafeName: BONAPP_CAFE_NAMES}), + response: CafeInfoResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getInfo(CAFE_URLS[ctx.request.params.cafeName]) + }, +}) + +export const getBonAppMenuRoute = createRouteSpec({ + method: 'get', + path: '/food/menu/:cafeId', + validate: { + params: z.object({cafeId: z.coerce.number()}), + }, + handler: async (ctx) => { + let cafeName = CAFE_ID_TO_URL[ctx.request.params.cafeId] + if (!cafeName) { + ctx.throw() + return + } + ctx.body = await getMenu(CAFE_URLS[cafeName]) + }, +}) + +export const getBonAppCafeRoute = createRouteSpec({ + method: 'get', + path: '/food/cafe/:cafeId', + validate: { + params: z.object({cafeId: z.coerce.number()}), + }, + handler: async (ctx) => { + let cafeName = CAFE_ID_TO_URL[ctx.request.params.cafeId] + if (!cafeName) { + ctx.throw() + return + } + ctx.body = await getInfo(CAFE_URLS[cafeName]) + }, +}) + +export const getBonAppItemNutritionRoute = createRouteSpec({ + method: 'get', + path: '/food/item/:itemId', + validate: { + params: z.object({itemId: z.string()}), + }, + handler: async (ctx) => { + ctx.body = await getNutrition(ctx.request.params.itemId) + }, +}) From 71a1e64bd9c6992d0e03bf1c642e976ebc3c78d0 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 11:40:05 -0400 Subject: [PATCH 02/24] migrate core server to koa-zod-router --- source/ccc-server/server.ts | 42 +++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index b8858680..79bbf31d 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -3,9 +3,8 @@ import etag from 'koa-etag' import compress from 'koa-compress' import logger from 'koa-logger' import responseTime from 'koa-response-time' -import bodyParser from 'koa-bodyparser' import cacheControl from 'koa-ctx-cache-control' -import Router from 'koa-router' +import zodRouter from 'koa-zod-router' import Koa from 'koa' import * as Sentry from '@sentry/node' import {z} from 'zod' @@ -25,7 +24,7 @@ async function main() { } const institution = institutionResult.data - let v1: Router + let v1: typeof zodRouter switch (institution) { case 'carleton-college': v1 = (await import('../ccci-carleton-college/index.js')).v1 @@ -40,15 +39,36 @@ async function main() { // // set up the routes // - const router = new Router() + const router = zodRouter({ + zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, + }) + router.use(v1.routes()) - router.get('/', (ctx) => { - ctx.body = 'Hello world!' + router.get({ + name: 'hello-world', + path: '/', + validate: { + query: z + .object({ + greeting: z.string().default('Hello').describe('foo'), + subject: z.string().default('world').describe('bar'), + }) + .default({}), + response: z.string(), + }, + handler: (ctx) => { + ctx.body = `${ctx.request.query.greeting} ${ctx.request.query.subject}` + }, }) - router.get('/ping', (ctx) => { - ctx.body = 'pong' + router.get({ + name: 'ping', + path: '/ping', + validate: {response: z.string()}, + handler: (ctx) => { + ctx.body = 'pong' + }, }) // @@ -62,14 +82,10 @@ async function main() { app.use(etag()) // support adding cache-control headers cacheControl(app) - // parse request bodies - app.use(bodyParser()) // hook in the router app.use(router.routes()) app.use(router.allowedMethods()) - - // I'm not sure why typescript-eslint was complaining about this... - // eslint-disable-next-line @typescript-eslint/no-unsafe-call + // activate Sentry Sentry.setupKoaErrorHandler(app) // From e5cc78698e83a2ad308dfa9e4fad439ffc7d86f0 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 12:21:24 -0400 Subject: [PATCH 03/24] wip menu migration to koa-zod-router --- package-lock.json | 119 +++++++++++ package.json | 3 + source/ccc-server/server.ts | 25 +-- source/ccci-carleton-college/v1/index.ts | 61 ++---- source/ccci-carleton-college/v1/menu.ts | 189 +----------------- .../us/minnesota/northfield/menu.test.ts | 86 ++++---- .../us/minnesota/northfield/menu.ts | 1 + source/ccci-stolaf-college/v1/menu.test.ts | 4 +- source/ccci-stolaf-college/v1/menu.ts | 6 +- 9 files changed, 207 insertions(+), 287 deletions(-) diff --git a/package-lock.json b/package-lock.json index a73f8655..09e534ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,12 +52,15 @@ "@types/koa-response-time": "^2.1.5", "@types/koa-router": "^7.4.8", "@types/lodash-es": "^4.17.12", + "@types/supertest": "^6.0.2", "@types/turndown": "^5.0.4", + "async-listen": "^3.0.1", "ava": "^6.1.3", "eslint": "^8.0.0", "eslint-config-prettier": "^9.1.0", "globals": "^15.2.0", "prettier": "^3.2.5", + "supertest": "^7.0.0", "typed-query-selector": "^2.11.2", "typescript": "^5.4.5", "typescript-eslint": "^7.0.0" @@ -1471,6 +1474,12 @@ "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==" }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true + }, "node_modules/@types/cookies": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", @@ -1703,6 +1712,12 @@ "@types/lodash": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1782,6 +1797,27 @@ "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.5.tgz", "integrity": "sha512-9Hp0ObzwwO57DpLFF0InUjUm/II8GmKAvzbefxQTihCb7KI6yc9yzf0nLc4mVdby5N4DRCgQM2wCup9KTieeww==" }, + "node_modules/@types/superagent": { + "version": "8.1.7", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.7.tgz", + "integrity": "sha512-NmIsd0Yj4DDhftfWvvAku482PZum4DBW7U51OvS8gvOkDDY0WT1jsVyDV3hK+vplrsYw8oDwi9QxOM7U68iwww==", + "dev": true, + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", + "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "dev": true, + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -2232,6 +2268,15 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/async-listen": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.0.1.tgz", + "integrity": "sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", @@ -2738,6 +2783,15 @@ "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -2819,6 +2873,12 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -4831,6 +4891,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -6082,6 +6154,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/formidable": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.1.tgz", + "integrity": "sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==", + "dev": true, + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/supertap": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", @@ -6146,6 +6252,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supertest": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", + "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", + "dev": true, + "dependencies": { + "methods": "^1.1.2", + "superagent": "^9.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 5c94101b..3b8a8029 100644 --- a/package.json +++ b/package.json @@ -82,12 +82,15 @@ "@types/koa-response-time": "^2.1.5", "@types/koa-router": "^7.4.8", "@types/lodash-es": "^4.17.12", + "@types/supertest": "^6.0.2", "@types/turndown": "^5.0.4", + "async-listen": "^3.0.1", "ava": "^6.1.3", "eslint": "^8.0.0", "eslint-config-prettier": "^9.1.0", "globals": "^15.2.0", "prettier": "^3.2.5", + "supertest": "^7.0.0", "typed-query-selector": "^2.11.2", "typescript": "^5.4.5", "typescript-eslint": "^7.0.0" diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 79bbf31d..11179ed9 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -3,12 +3,10 @@ import etag from 'koa-etag' import compress from 'koa-compress' import logger from 'koa-logger' import responseTime from 'koa-response-time' -import cacheControl from 'koa-ctx-cache-control' import zodRouter from 'koa-zod-router' import Koa from 'koa' import * as Sentry from '@sentry/node' import {z} from 'zod' -import type {ContextState, RouterState} from './context.js' const InstitutionSchema = z.enum(['stolaf-college', 'carleton-college']) @@ -24,16 +22,6 @@ async function main() { } const institution = institutionResult.data - let v1: typeof zodRouter - switch (institution) { - case 'carleton-college': - v1 = (await import('../ccci-carleton-college/index.js')).v1 - break - case 'stolaf-college': - v1 = (await import('../ccci-stolaf-college/index.js')).v1 - break - } - const app = new Koa() // @@ -43,7 +31,16 @@ async function main() { zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, }) - router.use(v1.routes()) + switch (institution) { + case 'carleton-college': { + let v1 = (await import('../ccci-carleton-college/index.js')).v1 + router.use(v1.routes()) + break + } + // case 'stolaf-college': + // v1 = (await import('../ccci-stolaf-college/index.js')).v1 + // break + } router.get({ name: 'hello-world', @@ -81,7 +78,7 @@ async function main() { app.use(conditional()) app.use(etag()) // support adding cache-control headers - cacheControl(app) + // cacheControl(app) // hook in the router app.use(router.routes()) app.use(router.allowedMethods()) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 3f871f11..705793d3 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -1,57 +1,31 @@ -import Router from 'koa-router' -import * as calendar from './calendar.js' +import zodRouter from 'koa-zod-router' + +/*import * as calendar from './calendar.js' import * as contacts from './contacts.js' -import * as convos from './convos.js' +import * as convocations from './convos.js' import * as dictionary from './dictionary.js' import * as faqs from './faqs.js' import * as help from './help.js' import * as hours from './hours.js' import * as jobs from './jobs.js' -import * as map from './map.js' +import * as map from './map.js'*/ import * as menus from './menu.js' -import * as news from './news.js' +/*import * as news from './news.js' import * as orgs from './orgs.js' import * as transit from './transit.js' import * as util from './util.js' -import * as webcams from './webcams.js' -import type {ContextState, RouterState} from '../../ccc-server/context.js' +import * as webcams from './webcams.js'*/ -const api = new Router({prefix: '/v1'}) +export const api = zodRouter({zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}}) // food -api.get('/food/item/:itemId', menus.bonAppNutrition) -api.get('/food/menu/:cafeId', menus.bonAppMenu) -api.get('/food/cafe/:cafeId', menus.bonAppCafe) - -api.get('/food/named/menu/the-pause', menus.pauseMenu) - -api.get('/food/named/cafe/stav-hall', menus.stavCafe) -api.get('/food/named/menu/stav-hall', menus.stavMenu) - -api.get('/food/named/cafe/the-cage', menus.cageCafe) -api.get('/food/named/menu/the-cage', menus.cageMenu) - -api.get('/food/named/cafe/kings-room', menus.kingsRoomCafe) -api.get('/food/named/menu/kings-room', menus.kingsRoomMenu) - -api.get('/food/named/cafe/the-cave', menus.caveCafe) -api.get('/food/named/menu/the-cave', menus.caveMenu) - -api.get('/food/named/cafe/burton', menus.burtonCafe) -api.get('/food/named/menu/burton', menus.burtonMenu) - -api.get('/food/named/cafe/ldc', menus.ldcCafe) -api.get('/food/named/menu/ldc', menus.ldcMenu) - -api.get('/food/named/cafe/sayles', menus.saylesCafe) -api.get('/food/named/menu/sayles', menus.saylesMenu) - -api.get('/food/named/cafe/weitz', menus.weitzCafe) -api.get('/food/named/menu/weitz', menus.weitzMenu) - -api.get('/food/named/cafe/schulze', menus.schulzeCafe) -api.get('/food/named/menu/schulze', menus.schulzeMenu) +api.register(menus.getBonAppItemNutritionRoute) +api.register(menus.getBonAppMenuRoute) +api.register(menus.getBonAppCafeRoute) +api.register(menus.getNamedMenuRoute) +api.register(menus.getNamedCafeRoute) +/* // calendar api.get('/calendar/google', calendar.google) api.get('/calendar/ics', calendar.ics) @@ -69,8 +43,8 @@ api.get('/dictionary', dictionary.dictionary) // convos api.get('/convos/upcoming', calendar.convos) -api.get('/convos/upcoming/:id', convos.upcomingDetail) -api.get('/convos/archived', convos.archived) +api.get('/convos/upcoming/:id', convocations.upcomingDetail) +api.get('/convos/archived', convocations.archived) // important contacts api.get('/contacts', contacts.contacts) @@ -124,5 +98,4 @@ api.get('/routes', (ctx) => { })) .sort((a, b) => a.path.localeCompare(b.path)) }) - -export {api} +*/ diff --git a/source/ccci-carleton-college/v1/menu.ts b/source/ccci-carleton-college/v1/menu.ts index db817f28..8c6c9de4 100644 --- a/source/ccci-carleton-college/v1/menu.ts +++ b/source/ccci-carleton-college/v1/menu.ts @@ -1,188 +1 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY, ONE_HOUR} from '../../ccc-lib/constants.js' -import * as bonapp from '../../menus-bonapp/index.js' -import mem from 'memoize' -import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' - -const pauseMenuUrl = GH_PAGES('pause-menu.json') -const GET_DAY = mem(get, {maxAge: ONE_DAY}) -export const getPauseMenu = () => GET_DAY(pauseMenuUrl).json() - -const getMenu = mem(bonapp.menu, {maxAge: ONE_HOUR}) -const getInfo = mem(bonapp.cafe, {maxAge: ONE_HOUR}) -const getNutrition = mem(bonapp.nutrition, {maxAge: ONE_HOUR}) - -export const CAFE_URLS = { - stav: 'https://stolaf.cafebonappetit.com/cafe/stav-hall/', - cage: 'https://stolaf.cafebonappetit.com/cafe/the-cage/', - kingsRoom: 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', - cave: 'https://stolaf.cafebonappetit.com/cafe/the-cave/', - burton: 'https://carleton.cafebonappetit.com/cafe/burton/', - ldc: 'https://carleton.cafebonappetit.com/cafe/east-hall/', - sayles: 'https://carleton.cafebonappetit.com/cafe/sayles-cafe/', - weitz: 'https://carleton.cafebonappetit.com/cafe/weitz-cafe/', - schulze: 'https://carleton.cafebonappetit.com/cafe/schulze-cafe/', -} as const - -export const CAFE_ID_TO_URL = { - 261: 'stav', - 262: 'cage', - 263: 'kingsRoom', - 35: 'burton', - 36: 'ldc', - 34: 'sayles', - 458: 'weitz', -} as const - -function isKeyofCafeIdToUrl(s: string | number): s is keyof typeof CAFE_ID_TO_URL { - return s in CAFE_ID_TO_URL -} - -export async function pauseMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getPauseMenu() -} - -export async function bonAppMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let cafeId = ctx.URL.searchParams.get('cafeId') - ctx.assert(cafeId, 400, '?cafeId is required') - ctx.assert( - isKeyofCafeIdToUrl(cafeId), - 400, - `?cafeId must be one of ${Object.values(CAFE_ID_TO_URL).join(', ')}`, - ) - ctx.body = await getMenu(CAFE_URLS[CAFE_ID_TO_URL[cafeId]]) -} - -export async function bonAppCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let cafeId = ctx.URL.searchParams.get('cafeId') - ctx.assert(cafeId, 400, '?cafeId is required') - ctx.assert( - isKeyofCafeIdToUrl(cafeId), - 400, - `?cafeId must be one of ${Object.values(CAFE_ID_TO_URL).join(', ')}`, - ) - ctx.body = await getInfo(CAFE_URLS[CAFE_ID_TO_URL[cafeId]]) -} - -export async function bonAppNutrition(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let itemId = ctx.URL.searchParams.get('itemId') - ctx.assert(itemId, 400, '?itemId is required') - ctx.body = await getNutrition(itemId) -} - -export async function stavCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.stav) -} - -export async function stavMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.stav) -} - -export async function cageCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.cage) -} - -export async function cageMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.cage) -} - -export async function kingsRoomCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.kingsRoom) -} - -export async function kingsRoomMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.kingsRoom) -} - -export async function caveCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.cave) -} - -export async function caveMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.cave) -} - -export async function burtonCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.burton) -} - -export async function burtonMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.burton) -} - -export async function ldcCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.ldc) -} - -export async function ldcMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.ldc) -} - -export async function saylesCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.sayles) -} - -export async function saylesMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.sayles) -} - -export async function weitzCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.weitz) -} - -export async function weitzMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.weitz) -} - -export async function schulzeCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.schulze) -} - -export async function schulzeMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.schulze) -} +export * from '../../ccci-shared/us/minnesota/northfield/menu.js' diff --git a/source/ccci-shared/us/minnesota/northfield/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts index 30e961f6..28328e6d 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.test.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -1,45 +1,59 @@ -import test from 'ava' -import {noop} from 'lodash-es' +import baseTest, {type TestFn} from 'ava' +import request from 'supertest' +import Koa from 'koa' +import {listen} from 'async-listen' + +import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../../../menus-bonapp/types.js' +import {keysOf} from '../../../../ccc-lib/keysOf.js' import * as menu from './menu.js' -import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../menus-bonapp/types.js' -import type {Context} from '../../ccc-server/context.js' -import {keysOf} from '../../ccc-lib/keysOf.js' - -const cafeInfoFunctions: Record Promise> = { - stav: menu.stavCafe, - cage: menu.cageCafe, - kings: menu.kingsRoomCafe, - cave: menu.caveCafe, - burton: menu.burtonCafe, - ldc: menu.ldcCafe, - sayles: menu.saylesCafe, - weitz: menu.weitzCafe, - schulze: menu.schulzeCafe, -} as const - -const cafeMenuFunctions: Record Promise> = { - stav: menu.stavMenu, - cage: menu.cageMenu, - kings: menu.kingsRoomMenu, - cave: menu.caveMenu, - burton: menu.burtonMenu, - ldc: menu.ldcMenu, - sayles: menu.saylesMenu, - weitz: menu.weitzMenu, - schulze: menu.schulzeMenu, -} as const +import zodRouter from 'koa-zod-router' +import logger from 'koa-logger' +import * as http from 'node:http' + +const test = baseTest as TestFn<{server: Koa}> + +test.before((t) => { + let server = new Koa() + let router = zodRouter({zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}}) + + router.register(menu.getBonAppItemNutritionRoute) + router.register(menu.getBonAppMenuRoute) + router.register(menu.getBonAppCafeRoute) + router.register(menu.getNamedMenuRoute) + router.register(menu.getNamedCafeRoute) + + server.use(router.routes()) + + t.context.server = server +}) + +test('example', async (t) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + let baseUrl = await listen(http.createServer(t.context.server.callback())) + await fetch(`${baseUrl.href}/food/named/menu/the-pause`) +}) +/* for (const cafe of keysOf(menu.CAFE_URLS)) { test(`${cafe} cafe endpoint should return a BamcoCafeInfo struct`, async (t) => { - let ctx = {cacheControl: noop, body: null} as Context - await t.notThrowsAsync(() => cafeInfoFunctions[cafe](ctx)) - t.notThrows(() => CafeInfoResponseSchema.parse(ctx.body)) + await t.notThrowsAsync( + request(t.context.server.listen()) + .get(`/food/cafe/${cafe}`) + .expect('Content-Type', /^application\/json\b/) + .expect(200) + .expect((response) => CafeInfoResponseSchema.parse(response.body)), + ) }) - test(`${cafe} menu endpoint should return a CafeMenu struct`, async (t) => { - let ctx = {cacheControl: noop, body: null} as Context - await t.notThrowsAsync(() => cafeMenuFunctions[cafe](ctx)) - t.notThrows(() => CafeMenuResponseSchema.parse(ctx.body)) + test(`${cafe} menu endpoint should return a BamcoCafeInfo struct`, async (t) => { + await t.notThrowsAsync( + request(t.context.server.listen()) + .get(`/food/menu/${cafe}`) + .expect('Content-Type', /^application\/json\b/) + .expect(200) + .expect((response) => CafeMenuResponseSchema.parse(response.body)), + ) }) } +*/ diff --git a/source/ccci-shared/us/minnesota/northfield/menu.ts b/source/ccci-shared/us/minnesota/northfield/menu.ts index 49fa8618..df89a24b 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -63,6 +63,7 @@ export const getNamedMenuRoute = createRouteSpec({ response: CafeMenuResponseSchema, }, handler: async (ctx) => { + console.log('foo') if (ctx.request.params.cafeName === 'the-pause') { ctx.body = await getPauseMenu() } else { diff --git a/source/ccci-stolaf-college/v1/menu.test.ts b/source/ccci-stolaf-college/v1/menu.test.ts index 1eb7c8c4..30e961f6 100644 --- a/source/ccci-stolaf-college/v1/menu.test.ts +++ b/source/ccci-stolaf-college/v1/menu.test.ts @@ -9,7 +9,7 @@ import {keysOf} from '../../ccc-lib/keysOf.js' const cafeInfoFunctions: Record Promise> = { stav: menu.stavCafe, cage: menu.cageCafe, - kingsRoom: menu.kingsRoomCafe, + kings: menu.kingsRoomCafe, cave: menu.caveCafe, burton: menu.burtonCafe, ldc: menu.ldcCafe, @@ -21,7 +21,7 @@ const cafeInfoFunctions: Record Pro const cafeMenuFunctions: Record Promise> = { stav: menu.stavMenu, cage: menu.cageMenu, - kingsRoom: menu.kingsRoomMenu, + kings: menu.kingsRoomMenu, cave: menu.caveMenu, burton: menu.burtonMenu, ldc: menu.ldcMenu, diff --git a/source/ccci-stolaf-college/v1/menu.ts b/source/ccci-stolaf-college/v1/menu.ts index db817f28..a5eb62d6 100644 --- a/source/ccci-stolaf-college/v1/menu.ts +++ b/source/ccci-stolaf-college/v1/menu.ts @@ -16,7 +16,7 @@ const getNutrition = mem(bonapp.nutrition, {maxAge: ONE_HOUR}) export const CAFE_URLS = { stav: 'https://stolaf.cafebonappetit.com/cafe/stav-hall/', cage: 'https://stolaf.cafebonappetit.com/cafe/the-cage/', - kingsRoom: 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', + kings: 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', cave: 'https://stolaf.cafebonappetit.com/cafe/the-cave/', burton: 'https://carleton.cafebonappetit.com/cafe/burton/', ldc: 'https://carleton.cafebonappetit.com/cafe/east-hall/', @@ -106,13 +106,13 @@ export async function cageMenu(ctx: Context) { export async function kingsRoomCafe(ctx: Context) { ctx.cacheControl(ONE_HOUR) - ctx.body = await getInfo(CAFE_URLS.kingsRoom) + ctx.body = await getInfo(CAFE_URLS.kings) } export async function kingsRoomMenu(ctx: Context) { ctx.cacheControl(ONE_HOUR) - ctx.body = await getMenu(CAFE_URLS.kingsRoom) + ctx.body = await getMenu(CAFE_URLS.kings) } export async function caveCafe(ctx: Context) { From ceb1dbd3424a989e409cdafe0734b9edfe00a824 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 14:50:46 -0400 Subject: [PATCH 04/24] continue work on menus --- package-lock.json | 31 +++++ package.json | 1 + source/ccc-server/server.ts | 3 + source/ccci-carleton-college/index.ts | 4 + source/ccci-carleton-college/v1/index.ts | 5 +- .../us/minnesota/northfield/menu.test.ts | 118 +++++++++++++----- .../us/minnesota/northfield/menu.ts | 83 ++++++------ source/ccci-stolaf-college/index.ts | 4 + source/menus-bonapp/types.ts | 15 +++ 9 files changed, 195 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09e534ff..4762389b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "@frogpond/ccc-server", "license": "AGPL-3.0-only", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.0.0", "@sentry/node": "^8.0.0-rc.1", "@sentry/profiling-node": "^8.0.0-rc.1", "@sentry/utils": "^8.0.0-rc.1", @@ -66,6 +67,17 @@ "typescript-eslint": "^7.0.0" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.0.0.tgz", + "integrity": "sha512-rJRKHD2m6nUb/9ZheeN8nqOURX24WTzY8Sex1ZKT0Kpx+xfpRcD0fTD6vEeXNHGaDGxzu65Jj/jb2x6nLTjcMw==", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@ava/typescript": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@ava/typescript/-/typescript-5.0.0.tgz", @@ -5245,6 +5257,14 @@ "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" }, + "node_modules/openapi3-ts": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.3.1.tgz", + "integrity": "sha512-ha/kTOLhMQL7MvS9Abu/cpCXx5qwHQ++88YkUzn1CGfmM8JvCOG/4ZE6tRsexgXRFaoJrcwLyf81H2Y/CXALtA==", + "dependencies": { + "yaml": "^2.4.1" + } + }, "node_modules/opentelemetry-instrumentation-fetch-node": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/opentelemetry-instrumentation-fetch-node/-/opentelemetry-instrumentation-fetch-node-1.2.0.tgz", @@ -6857,6 +6877,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yaml": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", + "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 3b8a8029..1f7b4759 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "watch": "node ./scripts/watch.js" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.0.0", "@sentry/node": "^8.0.0-rc.1", "@sentry/profiling-node": "^8.0.0-rc.1", "@sentry/utils": "^8.0.0-rc.1", diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 11179ed9..38a1a7c3 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -6,8 +6,11 @@ import responseTime from 'koa-response-time' import zodRouter from 'koa-zod-router' import Koa from 'koa' import * as Sentry from '@sentry/node' +import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' import {z} from 'zod' +extendZodWithOpenApi(z) + const InstitutionSchema = z.enum(['stolaf-college', 'carleton-college']) async function main() { diff --git a/source/ccci-carleton-college/index.ts b/source/ccci-carleton-college/index.ts index 2356bcc9..f27c17ed 100644 --- a/source/ccci-carleton-college/index.ts +++ b/source/ccci-carleton-college/index.ts @@ -1 +1,5 @@ +import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' +import {z} from 'zod' +extendZodWithOpenApi(z) + export {api as v1} from './v1/index.js' diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 705793d3..5c292f82 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -16,7 +16,10 @@ import * as transit from './transit.js' import * as util from './util.js' import * as webcams from './webcams.js'*/ -export const api = zodRouter({zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}}) +export const api = zodRouter({ + zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, + koaRouter: {prefix: '/v1'}, +}) // food api.register(menus.getBonAppItemNutritionRoute) diff --git a/source/ccci-shared/us/minnesota/northfield/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts index 28328e6d..c53548c5 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.test.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -3,18 +3,23 @@ import request from 'supertest' import Koa from 'koa' import {listen} from 'async-listen' -import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../../../menus-bonapp/types.js' +import { + CafeInfoResponseSchema, + CafeMenuResponseSchema, + PauseMenuSchema, +} from '../../../../menus-bonapp/types.js' import {keysOf} from '../../../../ccc-lib/keysOf.js' import * as menu from './menu.js' -import zodRouter from 'koa-zod-router' -import logger from 'koa-logger' +import zodRouter, {createRouteSpec} from 'koa-zod-router' import * as http from 'node:http' +import ky from 'ky' +import {BamcoCafeSlugs} from './menu.js' -const test = baseTest as TestFn<{server: Koa}> +const test = baseTest as TestFn<{server: http.Server; prefixUrl: URL}> -test.before((t) => { - let server = new Koa() +test.before(async (t) => { + let app = new Koa() let router = zodRouter({zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}}) router.register(menu.getBonAppItemNutritionRoute) @@ -23,37 +28,88 @@ test.before((t) => { router.register(menu.getNamedMenuRoute) router.register(menu.getNamedCafeRoute) - server.use(router.routes()) + router.register( + createRouteSpec({ + method: 'get', + path: '/', + handler: (context) => (context.body = 'hello, world!'), + }), + ) - t.context.server = server -}) + app.use(router.routes()) -test('example', async (t) => { // eslint-disable-next-line @typescript-eslint/no-misused-promises - let baseUrl = await listen(http.createServer(t.context.server.callback())) - await fetch(`${baseUrl.href}/food/named/menu/the-pause`) + t.context.server = http.createServer(app.callback()) + t.context.prefixUrl = await listen(t.context.server) +}) + +test.after.always((t) => { + t.context.server.close() +}) + +test('the pause menu endpoint should return a PauseMenuSchema struct', async (t) => { + let {prefixUrl} = t.context + let response = await ky('food/named/menu/the-pause', {prefixUrl}) + t.is(response.status, 200) + await t.notThrowsAsync(async () => PauseMenuSchema.parse(await response.json())) }) -/* -for (const cafe of keysOf(menu.CAFE_URLS)) { - test(`${cafe} cafe endpoint should return a BamcoCafeInfo struct`, async (t) => { - await t.notThrowsAsync( - request(t.context.server.listen()) - .get(`/food/cafe/${cafe}`) - .expect('Content-Type', /^application\/json\b/) - .expect(200) - .expect((response) => CafeInfoResponseSchema.parse(response.body)), - ) +for (const cafeSlug of keysOf(menu.BamcoSlugToUrl)) { + test(`/cafeSlug endpoint for ${JSON.stringify(cafeSlug)} should return a CafeInfoResponseSchema struct`, async (t) => { + let {prefixUrl} = t.context + let response = await ky(`food/named/cafe/${cafeSlug}`, {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 200) + await t.notThrowsAsync(async () => CafeInfoResponseSchema.parse(await response.json())) + }) + + test(`/menu endpoint for ${JSON.stringify(cafeSlug)} should return a CafeMenuResponseSchema struct`, async (t) => { + let {prefixUrl} = t.context + let response = await ky(`food/named/menu/${cafeSlug}`, {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 200) + await t.notThrowsAsync(async () => CafeMenuResponseSchema.parse(await response.json())) + }) +} + +for (const cafeId of keysOf(menu.BamcoIdToSlug)) { + test(`/cafe endpoint for id=${cafeId} should return a CafeInfoResponseSchema struct`, async (t) => { + let {prefixUrl} = t.context + let response = await ky(`food/cafe/${cafeId}`, {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 200) + await t.notThrowsAsync(async () => CafeInfoResponseSchema.parse(await response.json())) }) - test(`${cafe} menu endpoint should return a BamcoCafeInfo struct`, async (t) => { - await t.notThrowsAsync( - request(t.context.server.listen()) - .get(`/food/menu/${cafe}`) - .expect('Content-Type', /^application\/json\b/) - .expect(200) - .expect((response) => CafeMenuResponseSchema.parse(response.body)), - ) + test(`/cafe endpoint for id=${cafeId} should return a CafeMenuResponseSchema struct`, async (t) => { + let {prefixUrl} = t.context + let response = await ky(`food/menu/${cafeId}`, {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 200) + await t.notThrowsAsync(async () => CafeMenuResponseSchema.parse(await response.json())) }) } -*/ + +test('/cafe endpoint for an unknown cafe ID should return a 400', async (t) => { + let {prefixUrl} = t.context + let response = await ky('food/cafe/0', {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 400) + t.like(await response.json(), { + error: { + params: { + name: 'ZodError', + issues: [{code: 'invalid_enum_value', path: ['cafeId']}], + }, + }, + }) +}) + +test('/cafe endpoint for an unknown cafe name should return a 400', async (t) => { + let {prefixUrl} = t.context + let response = await ky('food/named/cafe/googoool', {prefixUrl, throwHttpErrors: false}) + t.is(response.status, 400) + t.like(await response.json(), { + error: { + params: { + name: 'ZodError', + issues: [{code: 'invalid_union', message: 'Invalid input', path: ['cafeName']}], + }, + }, + }) +}) diff --git a/source/ccci-shared/us/minnesota/northfield/menu.ts b/source/ccci-shared/us/minnesota/northfield/menu.ts index df89a24b..d29d20c8 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -4,20 +4,23 @@ import {z} from 'zod' import {get} from '../../../../ccc-lib/http.js' import {ONE_DAY, ONE_HOUR} from '../../../../ccc-lib/constants.js' import * as bonapp from '../../../../menus-bonapp/index.js' -import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../../../menus-bonapp/types.js' +import { + CafeInfoResponseSchema, + CafeMenuResponseSchema, + PauseMenuSchema, +} from '../../../../menus-bonapp/types.js' import {GH_PAGES} from '../../../../ccci-stolaf-college/v1/gh-pages.js' const pauseMenuUrl = GH_PAGES('pause-menu.json') const GET_DAY = mem(get, {maxAge: ONE_DAY}) -export const getPauseMenu = async () => - CafeMenuResponseSchema.parse(await GET_DAY(pauseMenuUrl).json()) +export const getPauseMenu = async () => PauseMenuSchema.parse(await GET_DAY(pauseMenuUrl).json()) const getMenu = mem(bonapp.menu, {maxAge: ONE_HOUR}) const getInfo = mem(bonapp.cafe, {maxAge: ONE_HOUR}) const getNutrition = mem(bonapp.nutrition, {maxAge: ONE_HOUR}) -type BONAPP_CAFE_NAMES_TYPE = z.infer -export const BONAPP_CAFE_NAMES = z.union([ +type BamcoCafeSlugs = z.infer +export const BamcoCafeSlugs = z.union([ z.literal('stav-hall'), z.literal('the-cage'), z.literal('kings-room'), @@ -30,44 +33,58 @@ export const BONAPP_CAFE_NAMES = z.union([ z.literal('schulze'), ]) -export const CAFE_NAMES = BONAPP_CAFE_NAMES.or(z.literal('the-pause')) +export const AllKnownCafeSlugs = BamcoCafeSlugs.or(z.literal('the-pause')) -export const CAFE_URLS: Record = { +export const BamcoSlugToUrl = { 'stav-hall': 'https://stolaf.cafebonappetit.com/cafe/stav-hall/', 'the-cage': 'https://stolaf.cafebonappetit.com/cafe/the-cage/', 'kings-room': 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', 'the-cave': 'https://stolaf.cafebonappetit.com/cafe/the-cave/', - 'c-store': 'https://stolaf.cafebonappetit.com/cafe/the-cave/', // alias for "cave" + 'c-store': 'https://stolaf.cafebonappetit.com/cafe/the-cave/', // alias for "the-cave" burton: 'https://carleton.cafebonappetit.com/cafe/burton/', ldc: 'https://carleton.cafebonappetit.com/cafe/east-hall/', sayles: 'https://carleton.cafebonappetit.com/cafe/sayles-cafe/', weitz: 'https://carleton.cafebonappetit.com/cafe/weitz-cafe/', schulze: 'https://carleton.cafebonappetit.com/cafe/schulze-cafe/', -} as const +} as const satisfies Record + +// I don't like having to maintain this lookup table twice, but this way TS guarantees +// that both copies of the table have the same entries. +const BamcoSlugToId = { + 'stav-hall': '261', + 'the-cage': '262', + 'kings-room': '263', + sayles: '34', + burton: '35', + ldc: '36', + weitz: '458', +} as const satisfies Partial> + +const KnownCafeIdEnum = z.nativeEnum(BamcoSlugToId) +type KnownCafeIdEnum = z.infer // "apple" | "banana" | 3 -export const CAFE_ID_TO_URL: Record = { - 261: 'stav-hall', - 262: 'the-cage', - 263: 'kings-room', - 35: 'burton', - 36: 'ldc', - 34: 'sayles', - 458: 'weitz', +export const BamcoIdToSlug: Record = { + '261': 'stav-hall', + '262': 'the-cage', + '263': 'kings-room', + '35': 'burton', + '36': 'ldc', + '34': 'sayles', + '458': 'weitz', } as const export const getNamedMenuRoute = createRouteSpec({ method: 'get', path: '/food/named/menu/:cafeName', validate: { - params: z.object({cafeName: CAFE_NAMES}), - response: CafeMenuResponseSchema, + params: z.object({cafeName: AllKnownCafeSlugs}), + response: CafeMenuResponseSchema.or(PauseMenuSchema), }, handler: async (ctx) => { - console.log('foo') if (ctx.request.params.cafeName === 'the-pause') { ctx.body = await getPauseMenu() } else { - ctx.body = await getMenu(CAFE_URLS[ctx.request.params.cafeName]) + ctx.body = await getMenu(BamcoSlugToUrl[ctx.request.params.cafeName]) } }, }) @@ -76,11 +93,11 @@ export const getNamedCafeRoute = createRouteSpec({ method: 'get', path: '/food/named/cafe/:cafeName', validate: { - params: z.object({cafeName: BONAPP_CAFE_NAMES}), + params: z.object({cafeName: BamcoCafeSlugs}), response: CafeInfoResponseSchema, }, handler: async (ctx) => { - ctx.body = await getInfo(CAFE_URLS[ctx.request.params.cafeName]) + ctx.body = await getInfo(BamcoSlugToUrl[ctx.request.params.cafeName]) }, }) @@ -88,15 +105,11 @@ export const getBonAppMenuRoute = createRouteSpec({ method: 'get', path: '/food/menu/:cafeId', validate: { - params: z.object({cafeId: z.coerce.number()}), + params: z.object({cafeId: KnownCafeIdEnum}), }, handler: async (ctx) => { - let cafeName = CAFE_ID_TO_URL[ctx.request.params.cafeId] - if (!cafeName) { - ctx.throw() - return - } - ctx.body = await getMenu(CAFE_URLS[cafeName]) + let cafeName = BamcoIdToSlug[ctx.request.params.cafeId] + ctx.body = await getMenu(BamcoSlugToUrl[cafeName]) }, }) @@ -104,15 +117,11 @@ export const getBonAppCafeRoute = createRouteSpec({ method: 'get', path: '/food/cafe/:cafeId', validate: { - params: z.object({cafeId: z.coerce.number()}), + params: z.object({cafeId: KnownCafeIdEnum}), }, handler: async (ctx) => { - let cafeName = CAFE_ID_TO_URL[ctx.request.params.cafeId] - if (!cafeName) { - ctx.throw() - return - } - ctx.body = await getInfo(CAFE_URLS[cafeName]) + let cafeName = BamcoIdToSlug[ctx.request.params.cafeId] + ctx.body = await getInfo(BamcoSlugToUrl[cafeName]) }, }) diff --git a/source/ccci-stolaf-college/index.ts b/source/ccci-stolaf-college/index.ts index 2356bcc9..f27c17ed 100644 --- a/source/ccci-stolaf-college/index.ts +++ b/source/ccci-stolaf-college/index.ts @@ -1 +1,5 @@ +import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' +import {z} from 'zod' +extendZodWithOpenApi(z) + export {api as v1} from './v1/index.js' diff --git a/source/menus-bonapp/types.ts b/source/menus-bonapp/types.ts index 1fbdfd6d..ff5a79f9 100644 --- a/source/menus-bonapp/types.ts +++ b/source/menus-bonapp/types.ts @@ -159,3 +159,18 @@ export const CafeMenuResponseSchema = z.object({ days: z.array(z.object({date: z.string().date(), cafe: CafeMenuSchema})), items: z.record(CafeMenuItemSchema), }) + +export const PauseMenuSchema = z.object({ + data: z.object({ + stationMenus: z.object({label: z.string(), note: z.string().optional()}).array(), + foodItems: z + .object({ + label: z.string(), + station: z.string(), + description: z.string().optional(), + special: z.boolean().optional(), + }) + .array(), + corIcons: z.record(CorIconSchema), + }), +}) From 9ec9d30879559c241577606a318a75f5685a3074 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 16:29:03 -0400 Subject: [PATCH 05/24] cleanups to menu file --- .../ccci-shared/us/minnesota/northfield/menu.test.ts | 12 +----------- source/ccci-shared/us/minnesota/northfield/menu.ts | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/source/ccci-shared/us/minnesota/northfield/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts index c53548c5..e16b4e58 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.test.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -1,5 +1,4 @@ import baseTest, {type TestFn} from 'ava' -import request from 'supertest' import Koa from 'koa' import {listen} from 'async-listen' @@ -11,10 +10,9 @@ import { import {keysOf} from '../../../../ccc-lib/keysOf.js' import * as menu from './menu.js' -import zodRouter, {createRouteSpec} from 'koa-zod-router' +import zodRouter from 'koa-zod-router' import * as http from 'node:http' import ky from 'ky' -import {BamcoCafeSlugs} from './menu.js' const test = baseTest as TestFn<{server: http.Server; prefixUrl: URL}> @@ -28,14 +26,6 @@ test.before(async (t) => { router.register(menu.getNamedMenuRoute) router.register(menu.getNamedCafeRoute) - router.register( - createRouteSpec({ - method: 'get', - path: '/', - handler: (context) => (context.body = 'hello, world!'), - }), - ) - app.use(router.routes()) // eslint-disable-next-line @typescript-eslint/no-misused-promises diff --git a/source/ccci-shared/us/minnesota/northfield/menu.ts b/source/ccci-shared/us/minnesota/northfield/menu.ts index d29d20c8..d4d21b8e 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -58,7 +58,7 @@ const BamcoSlugToId = { burton: '35', ldc: '36', weitz: '458', -} as const satisfies Partial> +} as const satisfies Partial> const KnownCafeIdEnum = z.nativeEnum(BamcoSlugToId) type KnownCafeIdEnum = z.infer // "apple" | "banana" | 3 From 20f6beb5f2da8170de0c13661a44223f8a8d86c2 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sun, 12 May 2024 19:59:57 -0400 Subject: [PATCH 06/24] begin work on calendars --- package-lock.json | 14 +- package.json | 3 +- source/ccc-server/server.ts | 2 + source/ccci-carleton-college/index.ts | 3 + source/ccci-carleton-college/v1/calendar.ts | 155 +++++++++++--------- source/ccci-carleton-college/v1/contacts.ts | 37 +++-- source/ccci-carleton-college/v1/index.ts | 27 ++-- source/ccci-stolaf-college/index.ts | 3 + 8 files changed, 140 insertions(+), 104 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4762389b..6e752949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "p-map": "7.0.2", "turndown": "7.1.3", "type-fest": "^4.18.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.3.0" }, "devDependencies": { "@ava/typescript": "^5.0.0", @@ -6971,6 +6972,17 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.3.0.tgz", + "integrity": "sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } } } diff --git a/package.json b/package.json index 1f7b4759..e77767e1 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "p-map": "7.0.2", "turndown": "7.1.3", "type-fest": "^4.18.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.3.0" }, "devDependencies": { "@ava/typescript": "^5.0.0", diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 38a1a7c3..57752f26 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -8,8 +8,10 @@ import Koa from 'koa' import * as Sentry from '@sentry/node' import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' import {z} from 'zod' +import {errorMap} from 'zod-validation-error' extendZodWithOpenApi(z) +z.setErrorMap(errorMap) const InstitutionSchema = z.enum(['stolaf-college', 'carleton-college']) diff --git a/source/ccci-carleton-college/index.ts b/source/ccci-carleton-college/index.ts index f27c17ed..5096f77e 100644 --- a/source/ccci-carleton-college/index.ts +++ b/source/ccci-carleton-college/index.ts @@ -1,5 +1,8 @@ import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' import {z} from 'zod' +import {errorMap} from 'zod-validation-error' + extendZodWithOpenApi(z) +z.setErrorMap(errorMap) export {api as v1} from './v1/index.js' diff --git a/source/ccci-carleton-college/v1/calendar.ts b/source/ccci-carleton-college/v1/calendar.ts index d3560117..c636eac7 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -2,80 +2,89 @@ import {googleCalendar} from '../../calendar/google.js' import {ical} from '../../calendar/ical.js' import {ONE_MINUTE} from '../../ccc-lib/constants.js' import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' +import {EventSchema} from '../../calendar/types.js' export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) export const getInternetCalendar = mem(ical, {maxAge: ONE_MINUTE}) -export async function google(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let calendarId = ctx.URL.searchParams.get('id') - ctx.assert(calendarId, 400, '?id is required') - ctx.body = await getGoogleCalendar(calendarId) -} - -export async function ics(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let calendarUrl = ctx.URL.searchParams.get('url') - ctx.assert(calendarUrl, 400, '?id is required') - ctx.body = await getInternetCalendar(new URL(calendarUrl)) -} - -export async function carleton(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let url = 'https://www.carleton.edu/calendar/?loadFeed=calendar&stamp=1714843628' - ctx.body = await getInternetCalendar(url) -} - -export async function cave(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let url = 'https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar' - ctx.body = await getInternetCalendar(url) -} - -export async function stolaf(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = '5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function northfield(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'thisisnorthfield@gmail.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function krlx(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'krlxradio88.1@gmail.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function ksto(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'kstonarwhal@gmail.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function convos(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let url = 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar&stamp=1714843936' - ctx.body = await getInternetCalendar(url) -} - -export async function sumo(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let url = - 'https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar&stamp=1714840383' - ctx.body = await getInternetCalendar(url) -} +export const getGoogleCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/google', + validate: { + query: z.object({id: z.string()}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getGoogleCalendar(ctx.request.query.id) + }, +}) + +export const getInternetCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/google', + validate: { + query: z.object({url: z.string().url()}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getInternetCalendar(ctx.request.query.url) + }, +}) + +const KnownCalendars = z.enum([ + 'carleton', + 'the-cave', + 'stolaf', + 'northfield', + 'krlx-schedule', + 'ksto-schedule', + 'upcoming-convos', + 'sumo-schedule', +]) + +export const getKnownCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/named/:calendar', + validate: { + params: z.object({calendar: KnownCalendars}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + switch (ctx.request.params.calendar) { + case 'carleton': + ctx.body = await getInternetCalendar('https://www.carleton.edu/calendar/?loadFeed=calendar') + break + case 'the-cave': + ctx.body = await getInternetCalendar( + 'https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar', + ) + break + case 'stolaf': + ctx.body = await getGoogleCalendar( + '5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com', + ) + break + case 'northfield': + ctx.body = await getGoogleCalendar('thisisnorthfield@gmail.com') + break + case 'ksto-schedule': + ctx.body = await getGoogleCalendar('kstonarwhal@gmail.com') + break + case 'krlx-schedule': + ctx.body = await getGoogleCalendar('krlxradio88.1@gmail.com') + break + case 'upcoming-convos': + ctx.body = await getInternetCalendar( + 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar', + ) + break + case 'sumo-schedule': + ctx.body = await getInternetCalendar( + 'https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar', + ) + break + } + }, +}) diff --git a/source/ccci-carleton-college/v1/contacts.ts b/source/ccci-carleton-college/v1/contacts.ts index d5e99399..58fc68c7 100644 --- a/source/ccci-carleton-college/v1/contacts.ts +++ b/source/ccci-carleton-college/v1/contacts.ts @@ -1,19 +1,30 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' -const GET = mem(get, {maxAge: ONE_HOUR}) +type ContactType = z.infer +const ContactSchema = z.array( + z.object({ + title: z.string(), + phoneNumber: z.string(), + buttonText: z.string(), + category: z.string(), + image: z.string().optional(), + synopsis: z.string(), + text: z.string(), + }), +) -let url = GH_PAGES('contact-info.json') - -export function getContacts() { - return GET(url).json() +export async function getContacts(): Promise { + return ContactSchema.parse(await get(GH_PAGES('contact-info.json')).json()) } -export async function contacts(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getContacts() -} +export const getContactsRoute = createRouteSpec({ + method: 'get', + path: '/contacts', + validate: {response: ContactSchema}, + handler: async (ctx) => { + ctx.body = await getContacts() + }, +}) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 5c292f82..076ac6c8 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -1,7 +1,8 @@ import zodRouter from 'koa-zod-router' -/*import * as calendar from './calendar.js' +import * as calendar from './calendar.js' import * as contacts from './contacts.js' +/* import * as convocations from './convos.js' import * as dictionary from './dictionary.js' import * as faqs from './faqs.js' @@ -14,7 +15,8 @@ import * as menus from './menu.js' import * as orgs from './orgs.js' import * as transit from './transit.js' import * as util from './util.js' -import * as webcams from './webcams.js'*/ +import * as webcams from './webcams.js' +*/ export const api = zodRouter({ zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, @@ -28,19 +30,15 @@ api.register(menus.getBonAppCafeRoute) api.register(menus.getNamedMenuRoute) api.register(menus.getNamedCafeRoute) -/* // calendar -api.get('/calendar/google', calendar.google) -api.get('/calendar/ics', calendar.ics) -api.get('/calendar/named/carleton', calendar.carleton) -api.get('/calendar/named/the-cave', calendar.cave) -api.get('/calendar/named/stolaf', calendar.stolaf) -api.get('/calendar/named/northfield', calendar.northfield) -api.get('/calendar/named/krlx-schedule', calendar.krlx) -api.get('/calendar/named/ksto-schedule', calendar.ksto) -api.get('/calendar/named/upcoming-convos', calendar.convos) -api.get('/calendar/named/sumo-schedule', calendar.sumo) +api.register(calendar.getGoogleCalendarRoute) +api.register(calendar.getInternetCalendarRoute) +api.register(calendar.getKnownCalendarRoute) + +// important contacts +api.register(contacts.getContactsRoute) +/* // dictionary api.get('/dictionary', dictionary.dictionary) @@ -49,9 +47,6 @@ api.get('/convos/upcoming', calendar.convos) api.get('/convos/upcoming/:id', convocations.upcomingDetail) api.get('/convos/archived', convocations.archived) -// important contacts -api.get('/contacts', contacts.contacts) - // help tools api.get('/tools/help', help.help) diff --git a/source/ccci-stolaf-college/index.ts b/source/ccci-stolaf-college/index.ts index f27c17ed..5096f77e 100644 --- a/source/ccci-stolaf-college/index.ts +++ b/source/ccci-stolaf-college/index.ts @@ -1,5 +1,8 @@ import {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' import {z} from 'zod' +import {errorMap} from 'zod-validation-error' + extendZodWithOpenApi(z) +z.setErrorMap(errorMap) export {api as v1} from './v1/index.js' From 98cd3fb57e34a6e8200da06535376bb93f30d571 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Fri, 24 May 2024 10:01:10 -0400 Subject: [PATCH 07/24] expand printWidth --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e77767e1..01869e33 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "license": "AGPL-3.0-only", "type": "module", "prettier": { - "printWidth": 100, + "printWidth": 120, "useTabs": true, "singleQuote": true, "trailingComma": "all", From b5f87fecad348c48b3cff1f89c1e39271bf3813d Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Fri, 24 May 2024 10:01:15 -0400 Subject: [PATCH 08/24] reformat calendar --- source/ccci-carleton-college/v1/calendar.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/source/ccci-carleton-college/v1/calendar.ts b/source/ccci-carleton-college/v1/calendar.ts index c636eac7..90cd5183 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -57,14 +57,10 @@ export const getKnownCalendarRoute = createRouteSpec({ ctx.body = await getInternetCalendar('https://www.carleton.edu/calendar/?loadFeed=calendar') break case 'the-cave': - ctx.body = await getInternetCalendar( - 'https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar', - ) + ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar') break case 'stolaf': - ctx.body = await getGoogleCalendar( - '5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com', - ) + ctx.body = await getGoogleCalendar('5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com') break case 'northfield': ctx.body = await getGoogleCalendar('thisisnorthfield@gmail.com') @@ -76,14 +72,10 @@ export const getKnownCalendarRoute = createRouteSpec({ ctx.body = await getGoogleCalendar('krlxradio88.1@gmail.com') break case 'upcoming-convos': - ctx.body = await getInternetCalendar( - 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar', - ) + ctx.body = await getInternetCalendar('https://www.carleton.edu/convocations/calendar/?loadFeed=calendar') break case 'sumo-schedule': - ctx.body = await getInternetCalendar( - 'https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar', - ) + ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar') break } }, From e3de0eb09afe6417d22070b109086ecef30ea0d3 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Fri, 24 May 2024 10:06:23 -0400 Subject: [PATCH 09/24] migrate dictionary --- source/ccci-carleton-college/v1/contacts.ts | 24 +++++++------ source/ccci-carleton-college/v1/dictionary.ts | 34 ++++++++++++------- source/ccci-carleton-college/v1/index.ts | 6 ++-- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/source/ccci-carleton-college/v1/contacts.ts b/source/ccci-carleton-college/v1/contacts.ts index 58fc68c7..290ee88b 100644 --- a/source/ccci-carleton-college/v1/contacts.ts +++ b/source/ccci-carleton-college/v1/contacts.ts @@ -4,17 +4,19 @@ import {createRouteSpec} from 'koa-zod-router' import {z} from 'zod' type ContactType = z.infer -const ContactSchema = z.array( - z.object({ - title: z.string(), - phoneNumber: z.string(), - buttonText: z.string(), - category: z.string(), - image: z.string().optional(), - synopsis: z.string(), - text: z.string(), - }), -) +const ContactSchema = z.object({ + data: z.array( + z.object({ + title: z.string(), + phoneNumber: z.string(), + buttonText: z.string(), + category: z.string(), + image: z.string().optional(), + synopsis: z.string(), + text: z.string(), + }), + ), +}) export async function getContacts(): Promise { return ContactSchema.parse(await get(GH_PAGES('contact-info.json')).json()) diff --git a/source/ccci-carleton-college/v1/dictionary.ts b/source/ccci-carleton-college/v1/dictionary.ts index ffef3bad..da45bafc 100644 --- a/source/ccci-carleton-college/v1/dictionary.ts +++ b/source/ccci-carleton-college/v1/dictionary.ts @@ -1,19 +1,27 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +type DictionaryType = z.infer +const DictionarySchema = z.object({ + data: z.array( + z.object({ + word: z.string(), + definition: z.string(), + }), + ), +}) -let url = GH_PAGES('dictionary-carls.json') - -export function getDictionary() { - return GET(url).json() +export async function getDictionary(): Promise { + return DictionarySchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) } -export async function dictionary(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getDictionary() -} +export const getDictionaryRoute = createRouteSpec({ + method: 'get', + path: '/dictionary', + validate: {response: DictionarySchema}, + handler: async (ctx) => { + ctx.body = await getDictionary() + }, +}) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 076ac6c8..4829b570 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -2,9 +2,9 @@ import zodRouter from 'koa-zod-router' import * as calendar from './calendar.js' import * as contacts from './contacts.js' +import * as dictionary from './dictionary.js' /* import * as convocations from './convos.js' -import * as dictionary from './dictionary.js' import * as faqs from './faqs.js' import * as help from './help.js' import * as hours from './hours.js' @@ -38,10 +38,10 @@ api.register(calendar.getKnownCalendarRoute) // important contacts api.register(contacts.getContactsRoute) -/* // dictionary -api.get('/dictionary', dictionary.dictionary) +api.register(dictionary.getDictionaryRoute) +/* // convos api.get('/convos/upcoming', calendar.convos) api.get('/convos/upcoming/:id', convocations.upcomingDetail) From d556054f65680b752614da7f1c6d658dd9a3fe9d Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Fri, 24 May 2024 10:20:56 -0400 Subject: [PATCH 10/24] migrate convos --- source/ccci-carleton-college/v1/calendar.ts | 4 +- source/ccci-carleton-college/v1/convos.ts | 99 ++++++++++++++------- source/ccci-carleton-college/v1/index.ts | 10 +-- 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/source/ccci-carleton-college/v1/calendar.ts b/source/ccci-carleton-college/v1/calendar.ts index 90cd5183..8bcbce26 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -6,6 +6,8 @@ import {createRouteSpec} from 'koa-zod-router' import {z} from 'zod' import {EventSchema} from '../../calendar/types.js' +export const CARLETON_UPCOMING_CONVOCATIONS_URL = 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar' + export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) export const getInternetCalendar = mem(ical, {maxAge: ONE_MINUTE}) @@ -72,7 +74,7 @@ export const getKnownCalendarRoute = createRouteSpec({ ctx.body = await getGoogleCalendar('krlxradio88.1@gmail.com') break case 'upcoming-convos': - ctx.body = await getInternetCalendar('https://www.carleton.edu/convocations/calendar/?loadFeed=calendar') + ctx.body = await getInternetCalendar(CARLETON_UPCOMING_CONVOCATIONS_URL) break case 'sumo-schedule': ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar') diff --git a/source/ccci-carleton-college/v1/convos.ts b/source/ccci-carleton-college/v1/convos.ts index 2db5d961..3978532c 100644 --- a/source/ccci-carleton-college/v1/convos.ts +++ b/source/ccci-carleton-college/v1/convos.ts @@ -1,22 +1,43 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' import {makeAbsoluteUrl} from '../../ccc-lib/url.js' import {htmlToMarkdown} from '../../ccc-lib/html-to-markdown.js' -import mem from 'memoize' import {JSDOM} from 'jsdom' import moment from 'moment' -import type {Context} from '../../ccc-server/context.js' import assert from 'node:assert/strict' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' +import {EventSchema} from '../../calendar/types.js' +import {CARLETON_UPCOMING_CONVOCATIONS_URL, getInternetCalendar} from './calendar.js' const archiveBase = 'https://feed.podbean.com/carletonconvos/feed.xml' -function processConvo(event: Element) { - let title = JSDOM.fragment(event.querySelector('title')?.textContent ?? '').textContent?.trim() - - let description = - JSDOM.fragment(event.querySelector('description')?.textContent ?? '').textContent?.trim() ?? '' - - let pubDate = moment(event.querySelector('pubDate')?.textContent) +type ConvocationEpisodeType = z.infer +const ConvocationEpisodeSchema = z.object({ + title: z.string(), + description: z.string(), + pubDate: z.string().datetime(), + enclosure: z.nullable( + z.object({ + url: z.string().url(), + length: z.string(), + type: z.string(), + }), + ), +}) + +type UpcomingConvocationEventType = z.infer +const UpcomingConvocationEventSchema = z.object({ + sponsor: z.string(), + content: z.string(), + images: z.string().url().array(), +}) + +function processConvocation(event: Element): ConvocationEpisodeType { + let title = JSDOM.fragment(event.querySelector('title')?.textContent ?? '').textContent?.trim() ?? '' + + let description = JSDOM.fragment(event.querySelector('description')?.textContent ?? '').textContent?.trim() ?? '' + + let pubDate = moment(event.querySelector('pubDate')?.textContent).toISOString() let enclosureEl = event.querySelector('enclosure') let enclosure = enclosureEl @@ -30,7 +51,7 @@ function processConvo(event: Element) { return {title, description, pubDate, enclosure} } -async function fetchUpcoming(eventId: string) { +async function fetchUpcomingDetail(eventId: string): Promise { let baseUrl = 'https://www.carleton.edu/convocations/calendar/' let url = 'https://www.carleton.edu/convocations/calendar/' let body = await get(url, {searchParams: {eId: eventId}}).text() @@ -55,36 +76,46 @@ async function fetchUpcoming(eventId: string) { baseUrl, }) - return { + return UpcomingConvocationEventSchema.parse({ images, content: descText, sponsor: sponsorText, - } -} - -export const getUpcoming = mem(fetchUpcoming, {maxAge: ONE_HOUR * 6}) - -export async function upcomingDetail(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - - let detailId = ctx.URL.searchParams.get('id') - ctx.assert(detailId, 400, '?id is required') - ctx.body = await getUpcoming(detailId) + }) } async function fetchArchived() { let body = await get(archiveBase).text() let dom = new JSDOM(body, {contentType: 'text/xml'}) - let convos = Array.from(dom.window.document.querySelectorAll('rss channel item')).map( - processConvo, - ) - convos = convos.slice(0, 100) - return Promise.all(convos) + let convos = Array.from(dom.window.document.querySelectorAll('rss channel item')).map(processConvocation) + return Promise.all(convos.slice(0, 100)) } -export const getArchived = mem(fetchArchived, {maxAge: ONE_HOUR * 6}) - -export async function archived(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - ctx.body = await getArchived() -} +export const getConvocationDetail = createRouteSpec({ + method: 'get', + path: '/convos/upcoming/:id', + validate: { + params: z.object({id: z.string()}), + response: UpcomingConvocationEventSchema, + }, + handler: async (ctx) => { + ctx.body = await fetchUpcomingDetail(ctx.request.params.id) + }, +}) + +export const getArchivedConvocations = createRouteSpec({ + method: 'get', + path: '/convos/archived', + validate: {response: z.array(ConvocationEpisodeSchema)}, + handler: async (ctx) => { + ctx.body = await fetchArchived() + }, +}) + +export const getUpcomingConvocations = createRouteSpec({ + method: 'get', + path: '/convos/upcoming', + validate: {response: EventSchema.array()}, + handler: async (ctx) => { + ctx.body = await getInternetCalendar(CARLETON_UPCOMING_CONVOCATIONS_URL) + }, +}) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 4829b570..fdb11bbf 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -3,8 +3,8 @@ import zodRouter from 'koa-zod-router' import * as calendar from './calendar.js' import * as contacts from './contacts.js' import * as dictionary from './dictionary.js' -/* import * as convocations from './convos.js' +/* import * as faqs from './faqs.js' import * as help from './help.js' import * as hours from './hours.js' @@ -41,12 +41,12 @@ api.register(contacts.getContactsRoute) // dictionary api.register(dictionary.getDictionaryRoute) -/* // convos -api.get('/convos/upcoming', calendar.convos) -api.get('/convos/upcoming/:id', convocations.upcomingDetail) -api.get('/convos/archived', convocations.archived) +api.register(convocations.getUpcomingConvocations) +api.register(convocations.getConvocationDetail) +api.register(convocations.getArchivedConvocations) +/* // help tools api.get('/tools/help', help.help) From 46ac27f49795977d5261ee3f2b6b250480b36353 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Fri, 24 May 2024 11:32:30 -0400 Subject: [PATCH 11/24] migrate help --- source/ccci-carleton-college/v1/help.ts | 83 +++++++++++++++++++++--- source/ccci-carleton-college/v1/index.ts | 6 +- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/source/ccci-carleton-college/v1/help.ts b/source/ccci-carleton-college/v1/help.ts index c0386d6a..a5baba91 100644 --- a/source/ccci-carleton-college/v1/help.ts +++ b/source/ccci-carleton-college/v1/help.ts @@ -3,17 +3,84 @@ import {ONE_HOUR} from '../../ccc-lib/constants.js' import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +export type SendEmailButtonType = z.infer +export const SendEmailButtonSchema = z.object({ + action: z.literal('send-email'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({ + to: z.union([z.string(), z.array(z.string())]), + cc: z.union([z.string(), z.array(z.string())]).optional(), + bcc: z.union([z.string(), z.array(z.string())]).optional(), + subject: z.string(), + body: z.string(), + }), +}) -let url = GH_PAGES('help.json') +export type OpenUrlButtonType = z.infer +export const OpenUrlButtonSchema = z.object({ + action: z.literal('open-url'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({url: z.string().url()}), +}) -export function getHelp() { - return GET(url).json() -} +export type CallPhoneButtonType = z.infer +export const CallPhoneButtonSchema = z.object({ + action: z.literal('call-phone'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({number: z.string().min(1)}), +}) + +export type CustomButtonType = z.infer +export const CustomButtonSchema = z.object({ + action: z.literal('custom'), + title: z.string(), + enabled: z.boolean().optional(), + params: z.record(z.string(), z.unknown()), +}) + +export type ToolButtonType = z.infer +export const ToolButtonSchema = z.union([ + SendEmailButtonSchema, + OpenUrlButtonSchema, + CallPhoneButtonSchema, + CustomButtonSchema, +]) -export async function help(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +export type ToolType = z.infer +export const ToolSchema = z.object({ + key: z.string(), + title: z.string(), + body: z.string(), + buttons: ToolButtonSchema.array(), + enabled: z.boolean().optional(), + hidden: z.boolean().optional(), + message: z.string().optional(), + versionRange: z.string().optional(), +}) - ctx.body = await getHelp() +type ResponseType = z.infer +const ResponseSchema = z.object({ + data: ToolSchema.array(), +}) + +export async function getHelp(): Promise { + return ResponseSchema.parse(await get(GH_PAGES('help.json')).json()) } + +export const getHelpRoute = createRouteSpec({ + method: 'get', + path: '/tools/help', + validate: {response: ResponseSchema}, + handler: async (ctx) => { + ctx.body = await getHelp() + }, +}) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index fdb11bbf..a22ec54d 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -4,9 +4,9 @@ import * as calendar from './calendar.js' import * as contacts from './contacts.js' import * as dictionary from './dictionary.js' import * as convocations from './convos.js' -/* import * as faqs from './faqs.js' import * as help from './help.js' +/* import * as hours from './hours.js' import * as jobs from './jobs.js' import * as map from './map.js'*/ @@ -46,13 +46,13 @@ api.register(convocations.getUpcomingConvocations) api.register(convocations.getConvocationDetail) api.register(convocations.getArchivedConvocations) -/* // help tools -api.get('/tools/help', help.help) +api.register(help.getHelpRoute) // faqs api.get('/faqs', faqs.faqs) +/* // webcams api.get('/webcams', webcams.webcams) From a42f4b80ba307303236a775cf46212afb660e449 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 12:03:23 -0400 Subject: [PATCH 12/24] continue porting carleton endpoints to koa-zod-router --- package-lock.json | 6 + package.json | 1 + source/ccci-carleton-college/v1/contacts.ts | 29 ++--- source/ccci-carleton-college/v1/convos.ts | 2 +- source/ccci-carleton-college/v1/dictionary.ts | 19 ++- source/ccci-carleton-college/v1/faqs.ts | 28 ++-- source/ccci-carleton-college/v1/gh-pages.ts | 5 +- source/ccci-carleton-college/v1/help.ts | 9 -- source/ccci-carleton-college/v1/hours.ts | 63 +++++++-- source/ccci-carleton-college/v1/index.ts | 29 ++--- source/ccci-carleton-college/v1/map.ts | 47 ++++--- source/ccci-carleton-college/v1/news.ts | 120 ++++++++++-------- source/ccci-carleton-college/v1/news/nnb.ts | 20 ++- source/ccci-carleton-college/v1/webcams.ts | 41 ++++-- 14 files changed, 248 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e752949..b31b3dae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "turndown": "7.1.3", "type-fest": "^4.18.2", "zod": "^3.23.8", + "zod-geojson": "^0.0.1", "zod-validation-error": "^3.3.0" }, "devDependencies": { @@ -6973,6 +6974,11 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-geojson": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/zod-geojson/-/zod-geojson-0.0.1.tgz", + "integrity": "sha512-GhIKOAcR7gUtB5GLBbSiRIe0kgL3fqOzgbBmYNjsamNWSjTzzh2rU32iiP4Z17ddoJ3oK/yh6yEUtu/zEIG/oQ==" + }, "node_modules/zod-validation-error": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.3.0.tgz", diff --git a/package.json b/package.json index 01869e33..84e5186e 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "turndown": "7.1.3", "type-fest": "^4.18.2", "zod": "^3.23.8", + "zod-geojson": "^0.0.1", "zod-validation-error": "^3.3.0" }, "devDependencies": { diff --git a/source/ccci-carleton-college/v1/contacts.ts b/source/ccci-carleton-college/v1/contacts.ts index 290ee88b..aa46590c 100644 --- a/source/ccci-carleton-college/v1/contacts.ts +++ b/source/ccci-carleton-college/v1/contacts.ts @@ -3,29 +3,28 @@ import {GH_PAGES} from './gh-pages.js' import {createRouteSpec} from 'koa-zod-router' import {z} from 'zod' -type ContactType = z.infer const ContactSchema = z.object({ - data: z.array( - z.object({ - title: z.string(), - phoneNumber: z.string(), - buttonText: z.string(), - category: z.string(), - image: z.string().optional(), - synopsis: z.string(), - text: z.string(), - }), - ), + title: z.string(), + phoneNumber: z.string(), + buttonText: z.string(), + category: z.string(), + image: z.string().optional(), + synopsis: z.string(), + text: z.string(), }) -export async function getContacts(): Promise { - return ContactSchema.parse(await get(GH_PAGES('contact-info.json')).json()) +const ResponseSchema = z.object({ + data: ContactSchema.array(), +}) + +export async function getContacts() { + return ResponseSchema.parse(await get(GH_PAGES('contact-info.json')).json()) } export const getContactsRoute = createRouteSpec({ method: 'get', path: '/contacts', - validate: {response: ContactSchema}, + validate: {response: ResponseSchema}, handler: async (ctx) => { ctx.body = await getContacts() }, diff --git a/source/ccci-carleton-college/v1/convos.ts b/source/ccci-carleton-college/v1/convos.ts index 3978532c..319340db 100644 --- a/source/ccci-carleton-college/v1/convos.ts +++ b/source/ccci-carleton-college/v1/convos.ts @@ -105,7 +105,7 @@ export const getConvocationDetail = createRouteSpec({ export const getArchivedConvocations = createRouteSpec({ method: 'get', path: '/convos/archived', - validate: {response: z.array(ConvocationEpisodeSchema)}, + validate: {response: ConvocationEpisodeSchema.array()}, handler: async (ctx) => { ctx.body = await fetchArchived() }, diff --git a/source/ccci-carleton-college/v1/dictionary.ts b/source/ccci-carleton-college/v1/dictionary.ts index da45bafc..33e921aa 100644 --- a/source/ccci-carleton-college/v1/dictionary.ts +++ b/source/ccci-carleton-college/v1/dictionary.ts @@ -3,24 +3,23 @@ import {GH_PAGES} from './gh-pages.js' import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' -type DictionaryType = z.infer const DictionarySchema = z.object({ - data: z.array( - z.object({ - word: z.string(), - definition: z.string(), - }), - ), + word: z.string(), + definition: z.string(), }) -export async function getDictionary(): Promise { - return DictionarySchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) +const ResponseSchema = z.object({ + data: DictionarySchema.array(), +}) + +export async function getDictionary() { + return ResponseSchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) } export const getDictionaryRoute = createRouteSpec({ method: 'get', path: '/dictionary', - validate: {response: DictionarySchema}, + validate: {response: ResponseSchema}, handler: async (ctx) => { ctx.body = await getDictionary() }, diff --git a/source/ccci-carleton-college/v1/faqs.ts b/source/ccci-carleton-college/v1/faqs.ts index 4214c06d..5f5e35db 100644 --- a/source/ccci-carleton-college/v1/faqs.ts +++ b/source/ccci-carleton-college/v1/faqs.ts @@ -1,19 +1,21 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +const FaqsSchema = z.object({ + text: z.string(), +}) -let url = GH_PAGES('faqs.json') - -export function getFaqs() { - return GET(url).json() +export async function getFaqs() { + return FaqsSchema.parse(await get(GH_PAGES('faqs.json')).json()) } -export async function faqs(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getFaqs() -} +export const getFaqsRoute = createRouteSpec({ + method: 'get', + path: '/faqs', + validate: {response: FaqsSchema}, + handler: async (ctx) => { + ctx.body = await getFaqs() + }, +}) diff --git a/source/ccci-carleton-college/v1/gh-pages.ts b/source/ccci-carleton-college/v1/gh-pages.ts index 6d25d6fb..e2a170f9 100644 --- a/source/ccci-carleton-college/v1/gh-pages.ts +++ b/source/ccci-carleton-college/v1/gh-pages.ts @@ -1,2 +1,3 @@ -export const GH_PAGES = (filename: string) => - new URL(`https://carls-app.github.io/carls/${filename}`) +export const GH_PAGES = (filename: string) => new URL(`https://carls-app.github.io/carls/${filename}`) + +export const MAP_DATA = (filename: string) => new URL(`https://carls-app.github.io/map-data/${filename}`) diff --git a/source/ccci-carleton-college/v1/help.ts b/source/ccci-carleton-college/v1/help.ts index a5baba91..a4c44bb7 100644 --- a/source/ccci-carleton-college/v1/help.ts +++ b/source/ccci-carleton-college/v1/help.ts @@ -1,12 +1,8 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' -export type SendEmailButtonType = z.infer export const SendEmailButtonSchema = z.object({ action: z.literal('send-email'), title: z.string(), @@ -21,7 +17,6 @@ export const SendEmailButtonSchema = z.object({ }), }) -export type OpenUrlButtonType = z.infer export const OpenUrlButtonSchema = z.object({ action: z.literal('open-url'), title: z.string(), @@ -30,7 +25,6 @@ export const OpenUrlButtonSchema = z.object({ params: z.object({url: z.string().url()}), }) -export type CallPhoneButtonType = z.infer export const CallPhoneButtonSchema = z.object({ action: z.literal('call-phone'), title: z.string(), @@ -39,7 +33,6 @@ export const CallPhoneButtonSchema = z.object({ params: z.object({number: z.string().min(1)}), }) -export type CustomButtonType = z.infer export const CustomButtonSchema = z.object({ action: z.literal('custom'), title: z.string(), @@ -47,7 +40,6 @@ export const CustomButtonSchema = z.object({ params: z.record(z.string(), z.unknown()), }) -export type ToolButtonType = z.infer export const ToolButtonSchema = z.union([ SendEmailButtonSchema, OpenUrlButtonSchema, @@ -55,7 +47,6 @@ export const ToolButtonSchema = z.union([ CustomButtonSchema, ]) -export type ToolType = z.infer export const ToolSchema = z.object({ key: z.string(), title: z.string(), diff --git a/source/ccci-carleton-college/v1/hours.ts b/source/ccci-carleton-college/v1/hours.ts index 9f723d23..aa3a6766 100644 --- a/source/ccci-carleton-college/v1/hours.ts +++ b/source/ccci-carleton-college/v1/hours.ts @@ -1,19 +1,60 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +const LinkSchema = z.object({ + title: z.string(), + url: z.string().url(), +}) -let url = GH_PAGES('building-hours.json') +const ScheduleBlockSchema = z.object({ + days: z.union([ + z.literal('Mo'), + z.literal('Tu'), + z.literal('We'), + z.literal('Th'), + z.literal('Fr'), + z.literal('Sa'), + z.literal('Su'), + ]), + from: z.string().regex(/^1?\d:[0-5]?\d[ap]m$/), + to: z.string().regex(/^1?\d:[0-5]?\d[ap]m$/), +}) -export function getBuildingHours() { - return GET(url).json() -} +const ScheduleSchema = z.object({ + title: z.string(), + notes: z.string().optional(), + closedForChapelTime: z.boolean().optional(), + isPhysicallyOpen: z.boolean().optional(), + hours: ScheduleBlockSchema.array(), +}) + +const BuildingHoursSchema = z.object({ + name: z.string(), + subtitle: z.string().optional(), + abbreviation: z.string().optional(), + category: z.string(), + image: z.string().optional(), + isNotice: z.boolean().optional(), + noticeMessage: z.string().optional(), + schedule: ScheduleSchema.array(), + links: LinkSchema.array(), +}) -export async function buildingHours(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +const ResponseSchema = z.object({ + data: BuildingHoursSchema.array(), +}) - ctx.body = await getBuildingHours() +export async function getBuildingHours() { + return ResponseSchema.parse(await get(GH_PAGES('building-hours.json')).json()) } + +export const getBuildingHoursRoute = createRouteSpec({ + method: 'get', + path: '/spaces/hours', + validate: {response: ResponseSchema}, + handler: async (ctx) => { + ctx.body = await getBuildingHours() + }, +}) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index a22ec54d..6f2d55c0 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -6,17 +6,15 @@ import * as dictionary from './dictionary.js' import * as convocations from './convos.js' import * as faqs from './faqs.js' import * as help from './help.js' -/* import * as hours from './hours.js' import * as jobs from './jobs.js' -import * as map from './map.js'*/ +import * as map from './map.js' import * as menus from './menu.js' -/*import * as news from './news.js' +import * as news from './news.js' import * as orgs from './orgs.js' import * as transit from './transit.js' import * as util from './util.js' import * as webcams from './webcams.js' -*/ export const api = zodRouter({ zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, @@ -50,33 +48,29 @@ api.register(convocations.getArchivedConvocations) api.register(help.getHelpRoute) // faqs -api.get('/faqs', faqs.faqs) +api.register(faqs.getFaqsRoute) -/* // webcams -api.get('/webcams', webcams.webcams) +api.register(webcams.getWebcamsRoute) // jobs api.get('/jobs', jobs.jobs) // map -api.get('/map', map.map) -api.get('/map/geojson', map.geojson) +api.register(map.getMapRoute) +api.register(map.getMapGeoJsonRoute) // orgs api.get('/orgs', orgs.orgs) // news -api.get('/news/rss', news.rss) -api.get('/news/wpjson', news.wpJson) -api.get('/news/named/nnb', news.nnb) -api.get('/news/named/carleton-now', news.carletonNow) -api.get('/news/named/carletonian', news.carletonian) -api.get('/news/named/krlx', news.krlxNews) -api.get('/news/named/covid', news.covidNews) +api.register(news.getRssFeedRoute) +api.register(news.getWpJsonFeedRoute) +api.register(news.getNoonNewsBulletinRoute) +api.register(news.getKnownFeedRoute) // hours -api.get('/spaces/hours', hours.buildingHours) +api.register(hours.getBuildingHoursRoute) // transit api.get('/transit/bus', transit.bus) @@ -96,4 +90,3 @@ api.get('/routes', (ctx) => { })) .sort((a, b) => a.path.localeCompare(b.path)) }) -*/ diff --git a/source/ccci-carleton-college/v1/map.ts b/source/ccci-carleton-college/v1/map.ts index 3402dc62..819b52ab 100644 --- a/source/ccci-carleton-college/v1/map.ts +++ b/source/ccci-carleton-college/v1/map.ts @@ -1,28 +1,33 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' +import {MAP_DATA} from './gh-pages.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' +import {GeoJSONSchema} from 'zod-geojson' -const GET = mem(get, {maxAge: ONE_HOUR}) +const MapSchema = z.record(z.string(), z.unknown()) -let url = 'https://carls-app.github.io/map-data/' - -export function getMap() { - return GET(url + 'map.json').json() -} - -export async function map(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMap() +export async function getMap() { + return MapSchema.parse(await get(MAP_DATA('map.json')).json()) } -export function getGeojsonMap() { - return GET(url + 'map.geojson').json() +export const getMapRoute = createRouteSpec({ + method: 'get', + path: '/map', + validate: {response: MapSchema}, + handler: async (ctx) => { + ctx.body = await getMap() + }, +}) + +export async function getGeoJson() { + return GeoJSONSchema.parse(await get(MAP_DATA('map.geojson')).json()) } -export async function geojson(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getGeojsonMap() -} +export const getMapGeoJsonRoute = createRouteSpec({ + method: 'get', + path: '/map/geojson', + validate: {response: GeoJSONSchema}, + handler: async (ctx) => { + ctx.body = await getGeoJson() + }, +}) diff --git a/source/ccci-carleton-college/v1/news.ts b/source/ccci-carleton-college/v1/news.ts index f156f51f..57b09110 100644 --- a/source/ccci-carleton-college/v1/news.ts +++ b/source/ccci-carleton-college/v1/news.ts @@ -1,63 +1,71 @@ -import {ONE_HOUR} from '../../ccc-lib/constants.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' +import {FeedItemSchema} from '../../feeds/types.js' import {fetchRssFeed} from '../../feeds/rss.js' -import {fetchWpJson, deprecatedWpJson} from '../../feeds/wp-json.js' -import {noonNewsBulletin} from './news/nnb.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' +import {deprecatedWpJson, fetchWpJson} from '../../feeds/wp-json.js' +import {NnbResponseSchema, noonNewsBulletin} from './news/nnb.js' -const cachedRssFeed = mem(fetchRssFeed, {maxAge: ONE_HOUR}) -const cachedWpJsonFeed = mem(fetchWpJson, {maxAge: ONE_HOUR}) -const cachedNoonNewsBulletin = mem(noonNewsBulletin, { - maxAge: ONE_HOUR * 6, +export const getRssFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/rss', + validate: { + query: z.object({url: z.string().url()}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await fetchRssFeed(ctx.request.query.url) + }, }) -export async function rss(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let urlToFetch = ctx.URL.searchParams.get('url') - ctx.assert(urlToFetch, 400, '?url is required') - ctx.body = await cachedRssFeed(urlToFetch) -} - -export async function wpJson(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let urlToFetch = ctx.URL.searchParams.get('url') - ctx.assert(urlToFetch, 400, '?url is required') - ctx.body = await cachedWpJsonFeed(urlToFetch) -} - -export async function nnb(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - - ctx.body = await cachedNoonNewsBulletin() -} - -export async function carletonNow(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedWpJsonFeed(new URL('https://www.carleton.edu/news/wp-json/wp/v2/posts'), { - per_page: 10, - _embed: true, - }) -} - -export async function carletonian(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedRssFeed( - new URL('https://apps.carleton.edu/carletonian/feeds/blogs/tonian'), - ) -} - -export async function krlxNews(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +export const getWpJsonFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/wpjson', + validate: { + query: z.object({url: z.string().url()}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await fetchWpJson(ctx.request.query.url) + }, +}) - ctx.body = await cachedRssFeed(new URL('https://content.krlx.org/feed/')) -} +export const getNoonNewsBulletinRoute = createRouteSpec({ + method: 'get', + path: '/news/named/nnb', + validate: { + response: NnbResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await noonNewsBulletin() + }, +}) -export function covidNews(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +const KnownFeeds = z.enum(['carleton-now', 'carletonian', 'krlx', 'covid']) - ctx.body = deprecatedWpJson() -} +export const getKnownFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/named/:feed', + validate: { + params: z.object({feed: KnownFeeds}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + switch (ctx.request.params.feed) { + case 'carletonian': + ctx.body = await fetchRssFeed(new URL('https://apps.carleton.edu/carletonian/feeds/blogs/tonian')) + break + case 'krlx': + ctx.body = await fetchRssFeed(new URL('https://content.krlx.org/feed/')) + break + case 'carleton-now': + ctx.body = await fetchWpJson(new URL('https://www.carleton.edu/news/wp-json/wp/v2/posts'), { + per_page: 10, + _embed: true, + }) + break + case 'covid': + ctx.body = deprecatedWpJson() + break + } + }, +}) diff --git a/source/ccci-carleton-college/v1/news/nnb.ts b/source/ccci-carleton-college/v1/news/nnb.ts index 4b85720c..f3b713f4 100644 --- a/source/ccci-carleton-college/v1/news/nnb.ts +++ b/source/ccci-carleton-college/v1/news/nnb.ts @@ -1,8 +1,22 @@ import {get} from '../../../ccc-lib/http.js' import {JSDOM} from 'jsdom' -import {groupBy, toPairs} from 'lodash-es' +import {groupBy} from 'lodash-es' +import {z} from 'zod' -export async function noonNewsBulletin() { +export type NnbResponseType = z.infer +export const NnbResponseSchema = z.array( + z.object({ + title: z.string(), + data: z.array( + z.object({ + description: z.string(), + category: z.string(), + }), + ), + }), +) + +export async function noonNewsBulletin(): Promise { let body = await get('https://apps.carleton.edu/campact/nnb/show.php3', { searchParams: {style: 'rss'}, }).text() @@ -18,5 +32,5 @@ export async function noonNewsBulletin() { }) const grouped = groupBy(bulletins, (m) => m.category) - return toPairs(grouped).map(([key, value]) => ({title: key, data: value})) + return NnbResponseSchema.parse(Object.entries(grouped).map(([key, value]) => ({title: key, data: value}))) } diff --git a/source/ccci-carleton-college/v1/webcams.ts b/source/ccci-carleton-college/v1/webcams.ts index 0215505e..22dd0f32 100644 --- a/source/ccci-carleton-college/v1/webcams.ts +++ b/source/ccci-carleton-college/v1/webcams.ts @@ -1,19 +1,36 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +const ColorSchema = z.union([ + z.string().regex(/^#[a-f0-9]{3,6}/i), + z.tuple([z.number().int(), z.number().int(), z.number().int()]), +]) -let url = GH_PAGES('webcams.json') +const WebcamSchema = z.object({ + name: z.string(), + pageUrl: z.string().url(), + streamUrl: z.string().url(), + thumbnail: z.string(), + tagline: z.string(), + accentColor: ColorSchema, + textColor: ColorSchema, +}) -export function getWebcams() { - return GET(url).json() -} - -export async function webcams(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +const ResponseSchema = z.object({ + data: WebcamSchema.array(), +}) - ctx.body = await getWebcams() +export async function getWebcams() { + return ResponseSchema.parse(await get(GH_PAGES('webcams.json')).json()) } + +export const getWebcamsRoute = createRouteSpec({ + method: 'get', + path: '/webcams', + validate: {response: ResponseSchema}, + handler: async (ctx) => { + ctx.body = await getWebcams() + }, +}) From fff7963f878f1b48f065ea88ea5a4d2fb31bd275 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 12:30:19 -0400 Subject: [PATCH 13/24] migrate jobs and orgs to koa-zod-router --- source/ccci-carleton-college/v1/index.ts | 10 +-- source/ccci-carleton-college/v1/jobs.ts | 80 +++++++++++++++--------- source/ccci-carleton-college/v1/orgs.ts | 22 ++++--- 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 6f2d55c0..6a7148f9 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -15,6 +15,8 @@ import * as orgs from './orgs.js' import * as transit from './transit.js' import * as util from './util.js' import * as webcams from './webcams.js' +import {getJobsRoute} from './jobs.js' +import {getStudentOrgsRoute} from './orgs.js' export const api = zodRouter({ zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, @@ -54,14 +56,14 @@ api.register(faqs.getFaqsRoute) api.register(webcams.getWebcamsRoute) // jobs -api.get('/jobs', jobs.jobs) +api.register(jobs.getJobsRoute) // map api.register(map.getMapRoute) api.register(map.getMapGeoJsonRoute) // orgs -api.get('/orgs', orgs.orgs) +api.register(orgs.getStudentOrgsRoute) // news api.register(news.getRssFeedRoute) @@ -84,8 +86,8 @@ api.get('/routes', (ctx) => { const leadingVersionRegex = /\/v[0-9]\// ctx.body = api.stack .map((layer) => ({ - path: layer.path, - displayName: layer.path.split(leadingVersionRegex).slice(1).join(), + path: layer.path.toString(), + displayName: layer.path.toString().split(leadingVersionRegex).slice(1).join(), params: layer.paramNames.map((param) => param.name), })) .sort((a, b) => a.path.localeCompare(b.path)) diff --git a/source/ccci-carleton-college/v1/jobs.ts b/source/ccci-carleton-college/v1/jobs.ts index c0dda468..a991bd63 100644 --- a/source/ccci-carleton-college/v1/jobs.ts +++ b/source/ccci-carleton-college/v1/jobs.ts @@ -1,23 +1,36 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY, ONE_HOUR} from '../../ccc-lib/constants.js' +import {ONE_DAY} from '../../ccc-lib/constants.js' import mem from 'memoize' import {JSDOM} from 'jsdom' import getUrls from 'get-urls' import pMap from 'p-map' -import type {Context} from '../../ccc-server/context.js' import assert from 'node:assert/strict' import {buildDetailMap} from '../../ccc-lib/html.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' const GET_ONE_DAY = mem(get, {maxAge: ONE_DAY}) -const GET_TWO_DAYS = mem(get, {maxAge: ONE_DAY * 2}) const jobsUrl = 'https://apps.carleton.edu/campus/sfs/employment/feeds/jobs' const BOOLEAN_KEYS = ['Position available during term', 'Position available during break'] -const PARAGRAPHICAL_KEYS = ['Description'] - -export async function fetchJob(link: URL) { +const PARAGRAPH_KEYS = ['Description'] + +type JobType = z.infer +const JobSchema = z.object({ + dateOpen: z.string(), + department: z.string().nullable(), + description: z.string(), + duringBreak: z.boolean(), + duringTerm: z.boolean(), + id: z.string(), + links: z.string().url().array(), + offCampus: z.boolean(), + title: z.string(), +}) + +export async function fetchJob(link: URL): Promise { let id = link.searchParams.get('job_id') assert(id) @@ -25,7 +38,7 @@ export async function fetchJob(link: URL) { link.protocol = 'https:' } - const body = await GET_TWO_DAYS(link).text() + const body = await GET_ONE_DAY(link).text() const dom = new JSDOM(body) const jobs = dom.window.document.querySelector('#jobs') @@ -40,40 +53,51 @@ export async function fetchJob(link: URL) { } let details = jobs.querySelectorAll('ul:first-of-type > li') - let detailMap = buildDetailMap(details, {paragraphs: PARAGRAPHICAL_KEYS, boolean: BOOLEAN_KEYS}) + let detailMap = buildDetailMap(details, {paragraphs: PARAGRAPH_KEYS, boolean: BOOLEAN_KEYS}) const description = detailMap.get('Description') ?? '' - const links = Array.from(getUrls(description === true ? '' : description)) + assert(typeof description === 'string') + const links = Array.from(getUrls(description)) + + const department = detailMap.get('Department or Office') + assert(typeof department === 'string') - return { + const dateOpen = detailMap.get('Date Open') ?? 'Unknown' + assert(typeof dateOpen === 'string') + + return JobSchema.parse({ id: id, title: titleText, offCampus: offCampus, - department: detailMap.get('Department or Office'), - dateOpen: detailMap.get('Date Open') ?? 'Unknown', + department, + dateOpen, duringTerm: Boolean(detailMap.get('Position available during term')), duringBreak: Boolean(detailMap.get('Position available during break')), - description: detailMap.get('Description') ?? '', + description, links: links, - } + }) } -async function _getAllJobs() { +type ResponseType = z.infer +const ResponseSchema = z.array(JobSchema) + +async function getAllJobs(): Promise { let body = await GET_ONE_DAY(jobsUrl).text() let dom = new JSDOM(body, {contentType: 'text/xml'}) - let jobLinks = Array.from(dom.window.document.querySelectorAll('rss channel item link')).flatMap( - (link) => { - let href = link.textContent?.trim() ?? '' - return URL.canParse(href) ? [new URL(href)] : [] - }, - ) + let jobLinks = Array.from(dom.window.document.querySelectorAll('rss channel item link')).flatMap((link) => { + let href = link.textContent?.trim() ?? '' + return URL.canParse(href) ? [new URL(href)] : [] + }) return pMap(jobLinks, fetchJob, {concurrency: 4}) } -export const getJobs = mem(_getAllJobs, {maxAge: ONE_HOUR}) - -export async function jobs(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getJobs() -} +export const getJobsRoute = createRouteSpec({ + method: 'get', + path: '/jobs', + validate: { + response: ResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getAllJobs() + }, +}) diff --git a/source/ccci-carleton-college/v1/orgs.ts b/source/ccci-carleton-college/v1/orgs.ts index 38fbb2b5..011c5aaf 100644 --- a/source/ccci-carleton-college/v1/orgs.ts +++ b/source/ccci-carleton-college/v1/orgs.ts @@ -5,6 +5,9 @@ import {JSDOM} from 'jsdom' import {sortBy} from 'lodash-es' import {z} from 'zod' import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {EventSchema} from '../../calendar/types.js' +import {getGoogleCalendar} from './calendar.js' export type CarletonStudentOrgType = z.infer export const CarletonStudentOrgSchema = z.object({ @@ -82,7 +85,7 @@ function domToOrg(orgNode: Element, sortableRegex: RegExp): SortableCarletonStud return SortableCarletonStudentOrgSchema.parse(orgObj) } -async function _getOrgs(): Promise { +async function getAllOrgs(): Promise { let orgsUrl = 'https://apps.carleton.edu/student/orgs/' let body = await get(orgsUrl).text() let dom = new JSDOM(body) @@ -115,10 +118,13 @@ async function _getOrgs(): Promise { return sortBy(Array.from(allOrgs.values()), '$sortableName') } -export const getOrgs = mem(_getOrgs, {maxAge: ONE_HOUR * 6}) - -export async function orgs(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - - ctx.body = await getOrgs() -} +export const getStudentOrgsRoute = createRouteSpec({ + method: 'get', + path: '/orgs', + validate: { + response: SortableCarletonStudentOrgSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getAllOrgs() + }, +}) From 5645509b951bd372cd2ef2cc0201c672d190263a Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 12:32:13 -0400 Subject: [PATCH 14/24] migrate html-to-markdown route to koa-zod-router --- source/ccci-carleton-college/v1/index.ts | 2 +- source/ccci-carleton-college/v1/util.ts | 27 ++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 6a7148f9..f2d1c531 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -79,7 +79,7 @@ api.get('/transit/bus', transit.bus) api.get('/transit/modes', transit.modes) // utilities -api.get('/util/html-to-md', util.htmlToMarkdown) +api.register(util.htmlToMarkdownRoute) // sitemap api.get('/routes', (ctx) => { diff --git a/source/ccci-carleton-college/v1/util.ts b/source/ccci-carleton-college/v1/util.ts index 0eb4286b..e6a8c5e9 100644 --- a/source/ccci-carleton-college/v1/util.ts +++ b/source/ccci-carleton-college/v1/util.ts @@ -1,14 +1,15 @@ -import {htmlToMarkdown as toMarkdown} from '../../ccc-lib/html-to-markdown.js' -import type {Context} from '../../ccc-server/context.js' +import {htmlToMarkdown} from '../../ccc-lib/html-to-markdown.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' -export function htmlToMarkdown(ctx: Context) { - ctx.assert( - typeof ctx.request.body === 'object' && - ctx.request.body && - 'text' in ctx.request.body && - typeof ctx.request.body.text === 'string', - 400, - 'request body .text property is required', - ) - ctx.response.body = toMarkdown(ctx.request.body.text) -} +export const htmlToMarkdownRoute = createRouteSpec({ + method: 'post', + path: '/util/html-to-md', + validate: { + body: z.object({text: z.string()}), + response: z.string(), + }, + handler: (ctx) => { + ctx.body = htmlToMarkdown(ctx.request.body.text) + }, +}) From a7baa1f27f7d1ae153b88795768c7bd632e61fad Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 12:49:18 -0400 Subject: [PATCH 15/24] continue work on porting to koa-zod-router --- source/ccc-frog-pond/building-hours.ts | 38 +++++++++++++++++ source/ccc-frog-pond/color.ts | 7 ++++ source/ccc-frog-pond/day-of-week.ts | 11 +++++ source/ccc-frog-pond/time.ts | 3 ++ source/ccc-frog-pond/transit.ts | 35 ++++++++++++++++ source/ccc-frog-pond/webcams.ts | 16 +++++++ source/ccci-carleton-college/v1/hours.ts | 49 ++-------------------- source/ccci-carleton-college/v1/index.ts | 6 +-- source/ccci-carleton-college/v1/transit.ts | 45 +++++++++++--------- source/ccci-carleton-college/v1/webcams.ts | 25 ++--------- 10 files changed, 144 insertions(+), 91 deletions(-) create mode 100644 source/ccc-frog-pond/building-hours.ts create mode 100644 source/ccc-frog-pond/color.ts create mode 100644 source/ccc-frog-pond/day-of-week.ts create mode 100644 source/ccc-frog-pond/time.ts create mode 100644 source/ccc-frog-pond/transit.ts create mode 100644 source/ccc-frog-pond/webcams.ts diff --git a/source/ccc-frog-pond/building-hours.ts b/source/ccc-frog-pond/building-hours.ts new file mode 100644 index 00000000..8f2f3fae --- /dev/null +++ b/source/ccc-frog-pond/building-hours.ts @@ -0,0 +1,38 @@ +import {z} from 'zod' +import {DayOfWeekSchema} from './day-of-week.js' +import {AmPmTimeSchema} from './time.js' + +const LinkSchema = z.object({ + title: z.string(), + url: z.string().url(), +}) + +const ScheduleBlockSchema = z.object({ + days: DayOfWeekSchema, + from: AmPmTimeSchema, + to: AmPmTimeSchema, +}) + +const ScheduleSchema = z.object({ + title: z.string(), + notes: z.string().optional(), + closedForChapelTime: z.boolean().optional(), + isPhysicallyOpen: z.boolean().optional(), + hours: ScheduleBlockSchema.array(), +}) + +export const BuildingHoursSchema = z.object({ + name: z.string(), + subtitle: z.string().optional(), + abbreviation: z.string().optional(), + category: z.string(), + image: z.string().optional(), + isNotice: z.boolean().optional(), + noticeMessage: z.string().optional(), + schedule: ScheduleSchema.array(), + links: LinkSchema.array(), +}) + +export const BuildingHoursResponseSchema = z.object({ + data: BuildingHoursSchema.array(), +}) diff --git a/source/ccc-frog-pond/color.ts b/source/ccc-frog-pond/color.ts new file mode 100644 index 00000000..7a5d4fee --- /dev/null +++ b/source/ccc-frog-pond/color.ts @@ -0,0 +1,7 @@ +import {z} from 'zod' + +export const ColorSchema = z.union([ + z.string().regex(/^#[a-f0-9]{3,6}/i), + z.string().regex(/^rgb\(\d+, \d+, \d+\)$/i), + z.tuple([z.number().int(), z.number().int(), z.number().int()]), +]) diff --git a/source/ccc-frog-pond/day-of-week.ts b/source/ccc-frog-pond/day-of-week.ts new file mode 100644 index 00000000..3975d1bd --- /dev/null +++ b/source/ccc-frog-pond/day-of-week.ts @@ -0,0 +1,11 @@ +import {z} from 'zod' + +export const DayOfWeekSchema = z.union([ + z.literal('Mo'), + z.literal('Tu'), + z.literal('We'), + z.literal('Th'), + z.literal('Fr'), + z.literal('Sa'), + z.literal('Su'), +]) diff --git a/source/ccc-frog-pond/time.ts b/source/ccc-frog-pond/time.ts new file mode 100644 index 00000000..8ff28c9c --- /dev/null +++ b/source/ccc-frog-pond/time.ts @@ -0,0 +1,3 @@ +import {z} from 'zod' + +export const AmPmTimeSchema = z.string().regex(/^1?\d:[0-5]?\d[ap]m$/) diff --git a/source/ccc-frog-pond/transit.ts b/source/ccc-frog-pond/transit.ts new file mode 100644 index 00000000..504f4553 --- /dev/null +++ b/source/ccc-frog-pond/transit.ts @@ -0,0 +1,35 @@ +import {z} from 'zod' +import {ColorSchema} from './color.js' +import {DayOfWeekSchema} from './day-of-week.js' + +const CoordinateSchema = z.tuple([z.number(), z.number()]) + +const BusScheduleSchema = z.object({ + days: DayOfWeekSchema, + coordinates: z.record(z.string(), CoordinateSchema), + stops: z.array(z.string()), + times: z.array(z.array(z.string())), +}) + +const BusTimesSchema = z.object({ + line: z.string(), + colors: z.object({bar: ColorSchema, dot: ColorSchema}), + notice: z.string().optional(), + schedules: BusScheduleSchema.array(), +}) + +export const BusTimesResponseSchema = z.object({ + data: BusTimesSchema.array(), +}) + +export const TransitModeSchema = z.object({ + name: z.string(), + category: z.string(), + url: z.string().url(), + synopsis: z.string(), + description: z.string(), +}) + +export const TransitModesResponseSchema = z.object({ + data: TransitModeSchema.array(), +}) diff --git a/source/ccc-frog-pond/webcams.ts b/source/ccc-frog-pond/webcams.ts new file mode 100644 index 00000000..af9b4625 --- /dev/null +++ b/source/ccc-frog-pond/webcams.ts @@ -0,0 +1,16 @@ +import {z} from 'zod' +import {ColorSchema} from './color.js' + +export const WebcamSchema = z.object({ + name: z.string(), + pageUrl: z.string().url(), + streamUrl: z.string().url(), + thumbnail: z.string(), + tagline: z.string(), + accentColor: ColorSchema, + textColor: ColorSchema, +}) + +export const WebcamResponseSchema = z.object({ + data: WebcamSchema.array(), +}) diff --git a/source/ccci-carleton-college/v1/hours.ts b/source/ccci-carleton-college/v1/hours.ts index aa3a6766..2976de9b 100644 --- a/source/ccci-carleton-college/v1/hours.ts +++ b/source/ccci-carleton-college/v1/hours.ts @@ -1,59 +1,16 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -const LinkSchema = z.object({ - title: z.string(), - url: z.string().url(), -}) - -const ScheduleBlockSchema = z.object({ - days: z.union([ - z.literal('Mo'), - z.literal('Tu'), - z.literal('We'), - z.literal('Th'), - z.literal('Fr'), - z.literal('Sa'), - z.literal('Su'), - ]), - from: z.string().regex(/^1?\d:[0-5]?\d[ap]m$/), - to: z.string().regex(/^1?\d:[0-5]?\d[ap]m$/), -}) - -const ScheduleSchema = z.object({ - title: z.string(), - notes: z.string().optional(), - closedForChapelTime: z.boolean().optional(), - isPhysicallyOpen: z.boolean().optional(), - hours: ScheduleBlockSchema.array(), -}) - -const BuildingHoursSchema = z.object({ - name: z.string(), - subtitle: z.string().optional(), - abbreviation: z.string().optional(), - category: z.string(), - image: z.string().optional(), - isNotice: z.boolean().optional(), - noticeMessage: z.string().optional(), - schedule: ScheduleSchema.array(), - links: LinkSchema.array(), -}) - -const ResponseSchema = z.object({ - data: BuildingHoursSchema.array(), -}) +import {BuildingHoursResponseSchema} from '../../ccc-frog-pond/building-hours.js' export async function getBuildingHours() { - return ResponseSchema.parse(await get(GH_PAGES('building-hours.json')).json()) + return BuildingHoursResponseSchema.parse(await get(GH_PAGES('building-hours.json')).json()) } export const getBuildingHoursRoute = createRouteSpec({ method: 'get', path: '/spaces/hours', - validate: {response: ResponseSchema}, + validate: {response: BuildingHoursResponseSchema}, handler: async (ctx) => { ctx.body = await getBuildingHours() }, diff --git a/source/ccci-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index f2d1c531..de17c411 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -15,8 +15,6 @@ import * as orgs from './orgs.js' import * as transit from './transit.js' import * as util from './util.js' import * as webcams from './webcams.js' -import {getJobsRoute} from './jobs.js' -import {getStudentOrgsRoute} from './orgs.js' export const api = zodRouter({ zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, @@ -75,8 +73,8 @@ api.register(news.getKnownFeedRoute) api.register(hours.getBuildingHoursRoute) // transit -api.get('/transit/bus', transit.bus) -api.get('/transit/modes', transit.modes) +api.register(transit.getBusTimesRoute) +api.register(transit.getTransitModesRoute) // utilities api.register(util.htmlToMarkdownRoute) diff --git a/source/ccci-carleton-college/v1/transit.ts b/source/ccci-carleton-college/v1/transit.ts index 290a17db..3396dc8d 100644 --- a/source/ccci-carleton-college/v1/transit.ts +++ b/source/ccci-carleton-college/v1/transit.ts @@ -1,27 +1,34 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {BusTimesResponseSchema, TransitModesResponseSchema} from '../../ccc-frog-pond/transit.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -export function getBus() { - return GET(GH_PAGES('bus-times.json')).json() +export async function getBus() { + return BusTimesResponseSchema.parse(await get(GH_PAGES('bus-times.json')).json()) } -export async function bus(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getBus() -} +export const getBusTimesRoute = createRouteSpec({ + method: 'get', + path: '/transit/bus', + validate: { + response: BusTimesResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getBus() + }, +}) -export function getModes() { - return GET(GH_PAGES('transportation.json')).json() +export async function getTransitModes() { + return TransitModesResponseSchema.parse(await get(GH_PAGES('transportation.json')).json()) } -export async function modes(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getModes() -} +export const getTransitModesRoute = createRouteSpec({ + method: 'get', + path: '/transit/modes', + validate: { + response: TransitModesResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getTransitModes() + }, +}) diff --git a/source/ccci-carleton-college/v1/webcams.ts b/source/ccci-carleton-college/v1/webcams.ts index 22dd0f32..536b7b71 100644 --- a/source/ccci-carleton-college/v1/webcams.ts +++ b/source/ccci-carleton-college/v1/webcams.ts @@ -1,35 +1,16 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -const ColorSchema = z.union([ - z.string().regex(/^#[a-f0-9]{3,6}/i), - z.tuple([z.number().int(), z.number().int(), z.number().int()]), -]) - -const WebcamSchema = z.object({ - name: z.string(), - pageUrl: z.string().url(), - streamUrl: z.string().url(), - thumbnail: z.string(), - tagline: z.string(), - accentColor: ColorSchema, - textColor: ColorSchema, -}) - -const ResponseSchema = z.object({ - data: WebcamSchema.array(), -}) +import {WebcamResponseSchema} from '../../ccc-frog-pond/webcams.js' export async function getWebcams() { - return ResponseSchema.parse(await get(GH_PAGES('webcams.json')).json()) + return WebcamResponseSchema.parse(await get(GH_PAGES('webcams.json')).json()) } export const getWebcamsRoute = createRouteSpec({ method: 'get', path: '/webcams', - validate: {response: ResponseSchema}, + validate: {response: WebcamResponseSchema}, handler: async (ctx) => { ctx.body = await getWebcams() }, From 4875485cf3fc94f2921454a5dc13d2cc002be322 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 12:53:50 -0400 Subject: [PATCH 16/24] continue pulling out common stuff to ccc-frog-pond --- source/ccc-frog-pond/contact.ts | 15 ++++ source/ccc-frog-pond/dictionary.ts | 10 +++ source/ccc-frog-pond/faqs.ts | 5 ++ source/ccc-frog-pond/help.ts | 61 +++++++++++++++++ source/ccci-carleton-college/v1/contacts.ts | 20 +----- source/ccci-carleton-college/v1/dictionary.ts | 15 +--- source/ccci-carleton-college/v1/faqs.ts | 6 +- source/ccci-carleton-college/v1/help.ts | 68 ++----------------- source/ccci-carleton-college/v1/orgs.ts | 5 -- 9 files changed, 102 insertions(+), 103 deletions(-) create mode 100644 source/ccc-frog-pond/contact.ts create mode 100644 source/ccc-frog-pond/dictionary.ts create mode 100644 source/ccc-frog-pond/faqs.ts create mode 100644 source/ccc-frog-pond/help.ts diff --git a/source/ccc-frog-pond/contact.ts b/source/ccc-frog-pond/contact.ts new file mode 100644 index 00000000..b1a13dfe --- /dev/null +++ b/source/ccc-frog-pond/contact.ts @@ -0,0 +1,15 @@ +import {z} from 'zod' + +export const ContactSchema = z.object({ + title: z.string(), + phoneNumber: z.string(), + buttonText: z.string(), + category: z.string(), + image: z.string().optional(), + synopsis: z.string(), + text: z.string(), +}) + +export const ContactResponseSchema = z.object({ + data: ContactSchema.array(), +}) diff --git a/source/ccc-frog-pond/dictionary.ts b/source/ccc-frog-pond/dictionary.ts new file mode 100644 index 00000000..99c86a10 --- /dev/null +++ b/source/ccc-frog-pond/dictionary.ts @@ -0,0 +1,10 @@ +import {z} from 'zod' + +export const DictionarySchema = z.object({ + word: z.string(), + definition: z.string(), +}) + +export const DictionaryResponseSchema = z.object({ + data: DictionarySchema.array(), +}) diff --git a/source/ccc-frog-pond/faqs.ts b/source/ccc-frog-pond/faqs.ts new file mode 100644 index 00000000..84544a4e --- /dev/null +++ b/source/ccc-frog-pond/faqs.ts @@ -0,0 +1,5 @@ +import {z} from 'zod' + +export const FaqsSchema = z.object({ + text: z.string(), +}) diff --git a/source/ccc-frog-pond/help.ts b/source/ccc-frog-pond/help.ts new file mode 100644 index 00000000..1eef5136 --- /dev/null +++ b/source/ccc-frog-pond/help.ts @@ -0,0 +1,61 @@ +import {z} from 'zod' + +export const SendEmailButtonSchema = z.object({ + action: z.literal('send-email'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({ + to: z.union([z.string(), z.array(z.string())]), + cc: z.union([z.string(), z.array(z.string())]).optional(), + bcc: z.union([z.string(), z.array(z.string())]).optional(), + subject: z.string(), + body: z.string(), + }), +}) + +export const OpenUrlButtonSchema = z.object({ + action: z.literal('open-url'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({url: z.string().url()}), +}) + +export const CallPhoneButtonSchema = z.object({ + action: z.literal('call-phone'), + title: z.string(), + icon: z.string(), + enabled: z.boolean().optional(), + params: z.object({number: z.string().min(1)}), +}) + +export const CustomButtonSchema = z.object({ + action: z.literal('custom'), + title: z.string(), + enabled: z.boolean().optional(), + params: z.record(z.string(), z.unknown()), +}) + +export const ToolButtonSchema = z.union([ + SendEmailButtonSchema, + OpenUrlButtonSchema, + CallPhoneButtonSchema, + CustomButtonSchema, +]) + +export const ToolSchema = z.object({ + key: z.string(), + title: z.string(), + body: z.string(), + buttons: ToolButtonSchema.array(), + enabled: z.boolean().optional(), + hidden: z.boolean().optional(), + message: z.string().optional(), + versionRange: z.string().optional(), +}) + +export type HelpResponseType = z.infer +export const HelpResponseSchema = z.object({ + data: ToolSchema.array(), +}) diff --git a/source/ccci-carleton-college/v1/contacts.ts b/source/ccci-carleton-college/v1/contacts.ts index aa46590c..471c34e5 100644 --- a/source/ccci-carleton-college/v1/contacts.ts +++ b/source/ccci-carleton-college/v1/contacts.ts @@ -1,30 +1,16 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' import {createRouteSpec} from 'koa-zod-router' -import {z} from 'zod' - -const ContactSchema = z.object({ - title: z.string(), - phoneNumber: z.string(), - buttonText: z.string(), - category: z.string(), - image: z.string().optional(), - synopsis: z.string(), - text: z.string(), -}) - -const ResponseSchema = z.object({ - data: ContactSchema.array(), -}) +import {ContactResponseSchema} from '../../ccc-frog-pond/contact.js' export async function getContacts() { - return ResponseSchema.parse(await get(GH_PAGES('contact-info.json')).json()) + return ContactResponseSchema.parse(await get(GH_PAGES('contact-info.json')).json()) } export const getContactsRoute = createRouteSpec({ method: 'get', path: '/contacts', - validate: {response: ResponseSchema}, + validate: {response: ContactResponseSchema}, handler: async (ctx) => { ctx.body = await getContacts() }, diff --git a/source/ccci-carleton-college/v1/dictionary.ts b/source/ccci-carleton-college/v1/dictionary.ts index 33e921aa..5973570e 100644 --- a/source/ccci-carleton-college/v1/dictionary.ts +++ b/source/ccci-carleton-college/v1/dictionary.ts @@ -1,25 +1,16 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -const DictionarySchema = z.object({ - word: z.string(), - definition: z.string(), -}) - -const ResponseSchema = z.object({ - data: DictionarySchema.array(), -}) +import {DictionaryResponseSchema} from '../../ccc-frog-pond/dictionary.js' export async function getDictionary() { - return ResponseSchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) + return DictionaryResponseSchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) } export const getDictionaryRoute = createRouteSpec({ method: 'get', path: '/dictionary', - validate: {response: ResponseSchema}, + validate: {response: DictionaryResponseSchema}, handler: async (ctx) => { ctx.body = await getDictionary() }, diff --git a/source/ccci-carleton-college/v1/faqs.ts b/source/ccci-carleton-college/v1/faqs.ts index 5f5e35db..377d0329 100644 --- a/source/ccci-carleton-college/v1/faqs.ts +++ b/source/ccci-carleton-college/v1/faqs.ts @@ -1,11 +1,7 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -const FaqsSchema = z.object({ - text: z.string(), -}) +import {FaqsSchema} from '../../ccc-frog-pond/faqs.js' export async function getFaqs() { return FaqsSchema.parse(await get(GH_PAGES('faqs.json')).json()) diff --git a/source/ccci-carleton-college/v1/help.ts b/source/ccci-carleton-college/v1/help.ts index a4c44bb7..9c0f91c2 100644 --- a/source/ccci-carleton-college/v1/help.ts +++ b/source/ccci-carleton-college/v1/help.ts @@ -1,76 +1,16 @@ import {get} from '../../ccc-lib/http.js' import {GH_PAGES} from './gh-pages.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' +import {HelpResponseSchema} from '../../ccc-frog-pond/help.js' -export const SendEmailButtonSchema = z.object({ - action: z.literal('send-email'), - title: z.string(), - icon: z.string(), - enabled: z.boolean().optional(), - params: z.object({ - to: z.union([z.string(), z.array(z.string())]), - cc: z.union([z.string(), z.array(z.string())]).optional(), - bcc: z.union([z.string(), z.array(z.string())]).optional(), - subject: z.string(), - body: z.string(), - }), -}) - -export const OpenUrlButtonSchema = z.object({ - action: z.literal('open-url'), - title: z.string(), - icon: z.string(), - enabled: z.boolean().optional(), - params: z.object({url: z.string().url()}), -}) - -export const CallPhoneButtonSchema = z.object({ - action: z.literal('call-phone'), - title: z.string(), - icon: z.string(), - enabled: z.boolean().optional(), - params: z.object({number: z.string().min(1)}), -}) - -export const CustomButtonSchema = z.object({ - action: z.literal('custom'), - title: z.string(), - enabled: z.boolean().optional(), - params: z.record(z.string(), z.unknown()), -}) - -export const ToolButtonSchema = z.union([ - SendEmailButtonSchema, - OpenUrlButtonSchema, - CallPhoneButtonSchema, - CustomButtonSchema, -]) - -export const ToolSchema = z.object({ - key: z.string(), - title: z.string(), - body: z.string(), - buttons: ToolButtonSchema.array(), - enabled: z.boolean().optional(), - hidden: z.boolean().optional(), - message: z.string().optional(), - versionRange: z.string().optional(), -}) - -type ResponseType = z.infer -const ResponseSchema = z.object({ - data: ToolSchema.array(), -}) - -export async function getHelp(): Promise { - return ResponseSchema.parse(await get(GH_PAGES('help.json')).json()) +export async function getHelp() { + return HelpResponseSchema.parse(await get(GH_PAGES('help.json')).json()) } export const getHelpRoute = createRouteSpec({ method: 'get', path: '/tools/help', - validate: {response: ResponseSchema}, + validate: {response: HelpResponseSchema}, handler: async (ctx) => { ctx.body = await getHelp() }, diff --git a/source/ccci-carleton-college/v1/orgs.ts b/source/ccci-carleton-college/v1/orgs.ts index 011c5aaf..9dacba26 100644 --- a/source/ccci-carleton-college/v1/orgs.ts +++ b/source/ccci-carleton-college/v1/orgs.ts @@ -1,13 +1,8 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {JSDOM} from 'jsdom' import {sortBy} from 'lodash-es' import {z} from 'zod' -import type {Context} from '../../ccc-server/context.js' import {createRouteSpec} from 'koa-zod-router' -import {EventSchema} from '../../calendar/types.js' -import {getGoogleCalendar} from './calendar.js' export type CarletonStudentOrgType = z.infer export const CarletonStudentOrgSchema = z.object({ From c37930120e2746d1e8ef0f1e0f775633e25e2914 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 13:09:22 -0400 Subject: [PATCH 17/24] port the shared stolaf-carleton parts to koa-zod-router --- eslint.config.js | 1 + source/ccci-carleton-college/v1/calendar.ts | 85 +------- .../us/minnesota/northfield/calendar.ts | 88 ++++++++ source/ccci-stolaf-college/v1/calendar.ts | 60 +----- source/ccci-stolaf-college/v1/contacts.ts | 26 ++- source/ccci-stolaf-college/v1/dictionary.ts | 26 ++- source/ccci-stolaf-college/v1/faqs.ts | 26 ++- source/ccci-stolaf-college/v1/help.ts | 26 ++- source/ccci-stolaf-college/v1/hours.ts | 26 ++- source/ccci-stolaf-college/v1/index.ts | 93 +++------ source/ccci-stolaf-college/v1/menu.test.ts | 45 ----- source/ccci-stolaf-college/v1/menu.ts | 189 +----------------- source/ccci-stolaf-college/v1/news.ts | 120 +++++------ source/ccci-stolaf-college/v1/transit.ts | 45 +++-- source/ccci-stolaf-college/v1/util.ts | 27 +-- source/ccci-stolaf-college/v1/webcams.ts | 26 ++- 16 files changed, 296 insertions(+), 613 deletions(-) create mode 100644 source/ccci-shared/us/minnesota/northfield/calendar.ts delete mode 100644 source/ccci-stolaf-college/v1/menu.test.ts diff --git a/eslint.config.js b/eslint.config.js index e19ae69f..7ffa6749 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,6 +58,7 @@ export default [ // conflicts with the noPropertyAccessFromIndexSignature tsconfig rule '@typescript-eslint/dot-notation': ['error', {allowIndexSignaturePropertyAccess: true}], + '@typescript-eslint/switch-exhaustiveness-check': 'error', }, }, { diff --git a/source/ccci-carleton-college/v1/calendar.ts b/source/ccci-carleton-college/v1/calendar.ts index 8bcbce26..d26a6e66 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -1,84 +1 @@ -import {googleCalendar} from '../../calendar/google.js' -import {ical} from '../../calendar/ical.js' -import {ONE_MINUTE} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import {createRouteSpec} from 'koa-zod-router' -import {z} from 'zod' -import {EventSchema} from '../../calendar/types.js' - -export const CARLETON_UPCOMING_CONVOCATIONS_URL = 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar' - -export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) -export const getInternetCalendar = mem(ical, {maxAge: ONE_MINUTE}) - -export const getGoogleCalendarRoute = createRouteSpec({ - method: 'get', - path: '/calendar/google', - validate: { - query: z.object({id: z.string()}), - response: EventSchema.array(), - }, - handler: async (ctx) => { - ctx.body = await getGoogleCalendar(ctx.request.query.id) - }, -}) - -export const getInternetCalendarRoute = createRouteSpec({ - method: 'get', - path: '/calendar/google', - validate: { - query: z.object({url: z.string().url()}), - response: EventSchema.array(), - }, - handler: async (ctx) => { - ctx.body = await getInternetCalendar(ctx.request.query.url) - }, -}) - -const KnownCalendars = z.enum([ - 'carleton', - 'the-cave', - 'stolaf', - 'northfield', - 'krlx-schedule', - 'ksto-schedule', - 'upcoming-convos', - 'sumo-schedule', -]) - -export const getKnownCalendarRoute = createRouteSpec({ - method: 'get', - path: '/calendar/named/:calendar', - validate: { - params: z.object({calendar: KnownCalendars}), - response: EventSchema.array(), - }, - handler: async (ctx) => { - switch (ctx.request.params.calendar) { - case 'carleton': - ctx.body = await getInternetCalendar('https://www.carleton.edu/calendar/?loadFeed=calendar') - break - case 'the-cave': - ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar') - break - case 'stolaf': - ctx.body = await getGoogleCalendar('5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com') - break - case 'northfield': - ctx.body = await getGoogleCalendar('thisisnorthfield@gmail.com') - break - case 'ksto-schedule': - ctx.body = await getGoogleCalendar('kstonarwhal@gmail.com') - break - case 'krlx-schedule': - ctx.body = await getGoogleCalendar('krlxradio88.1@gmail.com') - break - case 'upcoming-convos': - ctx.body = await getInternetCalendar(CARLETON_UPCOMING_CONVOCATIONS_URL) - break - case 'sumo-schedule': - ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar') - break - } - }, -}) +export * from '../../ccci-shared/us/minnesota/northfield/calendar.js' diff --git a/source/ccci-shared/us/minnesota/northfield/calendar.ts b/source/ccci-shared/us/minnesota/northfield/calendar.ts new file mode 100644 index 00000000..37266e09 --- /dev/null +++ b/source/ccci-shared/us/minnesota/northfield/calendar.ts @@ -0,0 +1,88 @@ +import mem from 'memoize' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' +import {EventSchema} from '../../../../calendar/types.js' +import {googleCalendar} from '../../../../calendar/google.js' +import {ical} from '../../../../calendar/ical.js' +import {ONE_MINUTE} from '../../../../ccc-lib/constants.js' + +export const CARLETON_UPCOMING_CONVOCATIONS_URL = 'https://www.carleton.edu/convocations/calendar/?loadFeed=calendar' + +export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) +export const getInternetCalendar = mem(ical, {maxAge: ONE_MINUTE}) + +export const getGoogleCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/google', + validate: { + query: z.object({id: z.string()}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getGoogleCalendar(ctx.request.query.id) + }, +}) + +export const getInternetCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/google', + validate: { + query: z.object({url: z.string().url()}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getInternetCalendar(ctx.request.query.url) + }, +}) + +const KnownCalendars = z.enum([ + 'carleton', + 'the-cave', + 'stolaf', + 'oleville', + 'northfield', + 'krlx-schedule', + 'ksto-schedule', + 'upcoming-convos', + 'sumo-schedule', +]) + +export const getKnownCalendarRoute = createRouteSpec({ + method: 'get', + path: '/calendar/named/:calendar', + validate: { + params: z.object({calendar: KnownCalendars}), + response: EventSchema.array(), + }, + handler: async (ctx) => { + switch (ctx.request.params.calendar) { + case 'carleton': + ctx.body = await getInternetCalendar('https://www.carleton.edu/calendar/?loadFeed=calendar') + break + case 'the-cave': + ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/cave/calendar/?loadFeed=calendar') + break + case 'stolaf': + ctx.body = await getGoogleCalendar('5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com') + break + case 'northfield': + ctx.body = await getGoogleCalendar('thisisnorthfield@gmail.com') + break + case 'ksto-schedule': + ctx.body = await getGoogleCalendar('stolaf.edu_7u3lgo4rr3o9dchr50q982ribk@group.calendar.google.com') + break + case 'krlx-schedule': + ctx.body = await getGoogleCalendar('krlxradio88.1@gmail.com') + break + case 'upcoming-convos': + ctx.body = await getInternetCalendar(CARLETON_UPCOMING_CONVOCATIONS_URL) + break + case 'sumo-schedule': + ctx.body = await getInternetCalendar('https://www.carleton.edu/student/orgs/sumo/schedule/?loadFeed=calendar') + break + case 'oleville': + ctx.body = await getGoogleCalendar('opha089fhthpchc0pkdqinca44nl7svk@import.calendar.google.com') + break + } + }, +}) diff --git a/source/ccci-stolaf-college/v1/calendar.ts b/source/ccci-stolaf-college/v1/calendar.ts index 7d153078..d26a6e66 100644 --- a/source/ccci-stolaf-college/v1/calendar.ts +++ b/source/ccci-stolaf-college/v1/calendar.ts @@ -1,59 +1 @@ -import {googleCalendar} from '../../calendar/google.js' -import {ical} from '../../calendar/ical.js' -import {ONE_MINUTE} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' - -export const getGoogleCalendar = mem(googleCalendar, {maxAge: ONE_MINUTE}) -export const getInternetCalendar = mem(ical, {maxAge: ONE_MINUTE}) - -export async function google(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let calendarId = ctx.URL.searchParams.get('id') - ctx.assert(calendarId, 400, '?id is required') - ctx.body = await getGoogleCalendar(calendarId) -} - -export async function ics(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let calendarUrl = ctx.URL.searchParams.get('url') - ctx.assert(calendarUrl, 400, '?id is required') - ctx.body = await getInternetCalendar(new URL(calendarUrl)) -} - -export async function stolaf(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = '5g91il39n0sv4c2bjdv1jrvcpq4ulm4r@import.calendar.google.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function oleville(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'opha089fhthpchc0pkdqinca44nl7svk@import.calendar.google.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function northfield(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'thisisnorthfield@gmail.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function krlx(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'krlxradio88.1@gmail.com' - ctx.body = await getGoogleCalendar(id) -} - -export async function ksto(ctx: Context) { - ctx.cacheControl(ONE_MINUTE) - - let id = 'stolaf.edu_7u3lgo4rr3o9dchr50q982ribk@group.calendar.google.com' - ctx.body = await getGoogleCalendar(id) -} +export * from '../../ccci-shared/us/minnesota/northfield/calendar.js' diff --git a/source/ccci-stolaf-college/v1/contacts.ts b/source/ccci-stolaf-college/v1/contacts.ts index 016e7ffe..471c34e5 100644 --- a/source/ccci-stolaf-college/v1/contacts.ts +++ b/source/ccci-stolaf-college/v1/contacts.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {ContactResponseSchema} from '../../ccc-frog-pond/contact.js' -const GET = mem(get, {maxAge: ONE_DAY}) - -let url = GH_PAGES('contact-info.json') - -export function getContacts() { - return GET(url).json() +export async function getContacts() { + return ContactResponseSchema.parse(await get(GH_PAGES('contact-info.json')).json()) } -export async function contacts(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getContacts() -} +export const getContactsRoute = createRouteSpec({ + method: 'get', + path: '/contacts', + validate: {response: ContactResponseSchema}, + handler: async (ctx) => { + ctx.body = await getContacts() + }, +}) diff --git a/source/ccci-stolaf-college/v1/dictionary.ts b/source/ccci-stolaf-college/v1/dictionary.ts index c246cdfa..5973570e 100644 --- a/source/ccci-stolaf-college/v1/dictionary.ts +++ b/source/ccci-stolaf-college/v1/dictionary.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {DictionaryResponseSchema} from '../../ccc-frog-pond/dictionary.js' -const GET = mem(get, {maxAge: ONE_DAY}) - -let url = GH_PAGES('dictionary.json') - -export function getDictionary() { - return GET(url).json() +export async function getDictionary() { + return DictionaryResponseSchema.parse(await get(GH_PAGES('dictionary-carls.json')).json()) } -export async function dictionary(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getDictionary() -} +export const getDictionaryRoute = createRouteSpec({ + method: 'get', + path: '/dictionary', + validate: {response: DictionaryResponseSchema}, + handler: async (ctx) => { + ctx.body = await getDictionary() + }, +}) diff --git a/source/ccci-stolaf-college/v1/faqs.ts b/source/ccci-stolaf-college/v1/faqs.ts index 10d720ff..377d0329 100644 --- a/source/ccci-stolaf-college/v1/faqs.ts +++ b/source/ccci-stolaf-college/v1/faqs.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {FaqsSchema} from '../../ccc-frog-pond/faqs.js' -const GET = mem(get, {maxAge: ONE_DAY}) - -let url = GH_PAGES('faqs.json') - -export function getFaqs() { - return GET(url).json() +export async function getFaqs() { + return FaqsSchema.parse(await get(GH_PAGES('faqs.json')).json()) } -export async function faqs(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getFaqs() -} +export const getFaqsRoute = createRouteSpec({ + method: 'get', + path: '/faqs', + validate: {response: FaqsSchema}, + handler: async (ctx) => { + ctx.body = await getFaqs() + }, +}) diff --git a/source/ccci-stolaf-college/v1/help.ts b/source/ccci-stolaf-college/v1/help.ts index 4be7813d..9c0f91c2 100644 --- a/source/ccci-stolaf-college/v1/help.ts +++ b/source/ccci-stolaf-college/v1/help.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {HelpResponseSchema} from '../../ccc-frog-pond/help.js' -const GET = mem(get, {maxAge: ONE_DAY}) - -let url = GH_PAGES('help.json') - -export function getHelp() { - return GET(url).json() +export async function getHelp() { + return HelpResponseSchema.parse(await get(GH_PAGES('help.json')).json()) } -export async function help(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getHelp() -} +export const getHelpRoute = createRouteSpec({ + method: 'get', + path: '/tools/help', + validate: {response: HelpResponseSchema}, + handler: async (ctx) => { + ctx.body = await getHelp() + }, +}) diff --git a/source/ccci-stolaf-college/v1/hours.ts b/source/ccci-stolaf-college/v1/hours.ts index 9f723d23..2976de9b 100644 --- a/source/ccci-stolaf-college/v1/hours.ts +++ b/source/ccci-stolaf-college/v1/hours.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {BuildingHoursResponseSchema} from '../../ccc-frog-pond/building-hours.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -let url = GH_PAGES('building-hours.json') - -export function getBuildingHours() { - return GET(url).json() +export async function getBuildingHours() { + return BuildingHoursResponseSchema.parse(await get(GH_PAGES('building-hours.json')).json()) } -export async function buildingHours(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getBuildingHours() -} +export const getBuildingHoursRoute = createRouteSpec({ + method: 'get', + path: '/spaces/hours', + validate: {response: BuildingHoursResponseSchema}, + handler: async (ctx) => { + ctx.body = await getBuildingHours() + }, +}) diff --git a/source/ccci-stolaf-college/v1/index.ts b/source/ccci-stolaf-college/v1/index.ts index 1bbe5987..39469f53 100644 --- a/source/ccci-stolaf-college/v1/index.ts +++ b/source/ccci-stolaf-college/v1/index.ts @@ -1,4 +1,5 @@ -import Router from 'koa-router' +import zodRouter from 'koa-zod-router' + import * as atoz from './a-z.js' import * as calendar from './calendar.js' import * as contacts from './contacts.js' @@ -18,74 +19,45 @@ import * as streams from './streams.js' import * as transit from './transit.js' import * as util from './util.js' import * as webcams from './webcams.js' -import type {Context, ContextState, RouterState} from '../../ccc-server/context.js' -const api = new Router({prefix: '/v1'}) +export const api = zodRouter({ + zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, + koaRouter: {prefix: '/v1'}, +}) // food -api.get('/food/item/:itemId', menus.bonAppNutrition) -api.get('/food/menu/:cafeId', menus.bonAppMenu) -api.get('/food/cafe/:cafeId', menus.bonAppCafe) - -api.get('/food/named/menu/the-pause', menus.pauseMenu) - -api.get('/food/named/cafe/stav-hall', menus.stavCafe) -api.get('/food/named/menu/stav-hall', menus.stavMenu) - -api.get('/food/named/cafe/the-cage', menus.cageCafe) -api.get('/food/named/menu/the-cage', menus.cageMenu) - -api.get('/food/named/cafe/kings-room', menus.kingsRoomCafe) -api.get('/food/named/menu/kings-room', menus.kingsRoomMenu) - -api.get('/food/named/cafe/the-cave', menus.caveCafe) -api.get('/food/named/menu/the-cave', menus.caveMenu) - -api.get('/food/named/cafe/burton', menus.burtonCafe) -api.get('/food/named/menu/burton', menus.burtonMenu) - -api.get('/food/named/cafe/ldc', menus.ldcCafe) -api.get('/food/named/menu/ldc', menus.ldcMenu) - -api.get('/food/named/cafe/sayles', menus.saylesCafe) -api.get('/food/named/menu/sayles', menus.saylesMenu) - -api.get('/food/named/cafe/weitz', menus.weitzCafe) -api.get('/food/named/menu/weitz', menus.weitzMenu) - -api.get('/food/named/cafe/schulze', menus.schulzeCafe) -api.get('/food/named/menu/schulze', menus.schulzeMenu) +api.register(menus.getBonAppItemNutritionRoute) +api.register(menus.getBonAppMenuRoute) +api.register(menus.getBonAppCafeRoute) +api.register(menus.getNamedMenuRoute) +api.register(menus.getNamedCafeRoute) // calendar -api.get('/calendar/google', calendar.google) -api.get('/calendar/ics', calendar.ics) -api.get('/calendar/named/stolaf', calendar.stolaf) -api.get('/calendar/named/oleville', calendar.oleville) -api.get('/calendar/named/northfield', calendar.northfield) -api.get('/calendar/named/krlx-schedule', calendar.krlx) -api.get('/calendar/named/ksto-schedule', calendar.ksto) +api.register(calendar.getGoogleCalendarRoute) +api.register(calendar.getInternetCalendarRoute) +api.register(calendar.getKnownCalendarRoute) // a-to-z api.get('/a-to-z', atoz.atoz) // dictionary -api.get('/dictionary', dictionary.dictionary) +api.register(dictionary.getDictionaryRoute) // directory api.get('/directory/departments', departments.departments) api.get('/directory/majors', majors.majors) // important contacts -api.get('/contacts', contacts.contacts) +api.register(contacts.getContactsRoute) // help tools -api.get('/tools/help', help.help) +api.register(help.getHelpRoute) // faqs -api.get('/faqs', faqs.faqs) +api.register(faqs.getFaqsRoute) // webcams -api.get('/webcams', webcams.webcams) +api.register(webcams.getWebcamsRoute) // jobs api.get('/jobs', jobs.jobs) @@ -94,21 +66,16 @@ api.get('/jobs', jobs.jobs) api.get('/orgs', orgs.orgs) // news -api.get('/news/rss', news.rss) -api.get('/news/wpjson', news.wpJson) -api.get('/news/named/stolaf', news.stolaf) -api.get('/news/named/oleville', news.oleville) -api.get('/news/named/politicole', news.politicole) -api.get('/news/named/mess', news.mess) -api.get('/news/named/ksto', news.ksto) -api.get('/news/named/krlx', news.krlx) +api.register(news.getRssFeedRoute) +api.register(news.getWpJsonFeedRoute) +api.register(news.getKnownFeedRoute) // hours -api.get('/spaces/hours', hours.buildingHours) +api.register(hours.getBuildingHoursRoute) // transit -api.get('/transit/bus', transit.bus) -api.get('/transit/modes', transit.modes) +api.register(transit.getBusTimesRoute) +api.register(transit.getTransitModesRoute) // streams api.get('/streams/archived', streams.archived) @@ -121,18 +88,16 @@ api.get('/printing/color-printers', printing.colorPrinters) api.get('/reports/stav', reports.stavMealtimeReport) // utilities -api.get('/util/html-to-md', util.htmlToMarkdown) +api.register(util.htmlToMarkdownRoute) // sitemap -api.get('/routes', (ctx: Context) => { +api.get('/routes', (ctx) => { const leadingVersionRegex = /\/v[0-9]\// ctx.body = api.stack .map((layer) => ({ - path: layer.path, - displayName: layer.path.split(leadingVersionRegex).slice(1).join(), + path: layer.path.toString(), + displayName: layer.path.toString().split(leadingVersionRegex).slice(1).join(), params: layer.paramNames.map((param) => param.name), })) .sort((a, b) => a.path.localeCompare(b.path)) }) - -export {api} diff --git a/source/ccci-stolaf-college/v1/menu.test.ts b/source/ccci-stolaf-college/v1/menu.test.ts deleted file mode 100644 index 30e961f6..00000000 --- a/source/ccci-stolaf-college/v1/menu.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import test from 'ava' -import {noop} from 'lodash-es' - -import * as menu from './menu.js' -import {CafeInfoResponseSchema, CafeMenuResponseSchema} from '../../menus-bonapp/types.js' -import type {Context} from '../../ccc-server/context.js' -import {keysOf} from '../../ccc-lib/keysOf.js' - -const cafeInfoFunctions: Record Promise> = { - stav: menu.stavCafe, - cage: menu.cageCafe, - kings: menu.kingsRoomCafe, - cave: menu.caveCafe, - burton: menu.burtonCafe, - ldc: menu.ldcCafe, - sayles: menu.saylesCafe, - weitz: menu.weitzCafe, - schulze: menu.schulzeCafe, -} as const - -const cafeMenuFunctions: Record Promise> = { - stav: menu.stavMenu, - cage: menu.cageMenu, - kings: menu.kingsRoomMenu, - cave: menu.caveMenu, - burton: menu.burtonMenu, - ldc: menu.ldcMenu, - sayles: menu.saylesMenu, - weitz: menu.weitzMenu, - schulze: menu.schulzeMenu, -} as const - -for (const cafe of keysOf(menu.CAFE_URLS)) { - test(`${cafe} cafe endpoint should return a BamcoCafeInfo struct`, async (t) => { - let ctx = {cacheControl: noop, body: null} as Context - await t.notThrowsAsync(() => cafeInfoFunctions[cafe](ctx)) - t.notThrows(() => CafeInfoResponseSchema.parse(ctx.body)) - }) - - test(`${cafe} menu endpoint should return a CafeMenu struct`, async (t) => { - let ctx = {cacheControl: noop, body: null} as Context - await t.notThrowsAsync(() => cafeMenuFunctions[cafe](ctx)) - t.notThrows(() => CafeMenuResponseSchema.parse(ctx.body)) - }) -} diff --git a/source/ccci-stolaf-college/v1/menu.ts b/source/ccci-stolaf-college/v1/menu.ts index a5eb62d6..8c6c9de4 100644 --- a/source/ccci-stolaf-college/v1/menu.ts +++ b/source/ccci-stolaf-college/v1/menu.ts @@ -1,188 +1 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY, ONE_HOUR} from '../../ccc-lib/constants.js' -import * as bonapp from '../../menus-bonapp/index.js' -import mem from 'memoize' -import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' - -const pauseMenuUrl = GH_PAGES('pause-menu.json') -const GET_DAY = mem(get, {maxAge: ONE_DAY}) -export const getPauseMenu = () => GET_DAY(pauseMenuUrl).json() - -const getMenu = mem(bonapp.menu, {maxAge: ONE_HOUR}) -const getInfo = mem(bonapp.cafe, {maxAge: ONE_HOUR}) -const getNutrition = mem(bonapp.nutrition, {maxAge: ONE_HOUR}) - -export const CAFE_URLS = { - stav: 'https://stolaf.cafebonappetit.com/cafe/stav-hall/', - cage: 'https://stolaf.cafebonappetit.com/cafe/the-cage/', - kings: 'https://stolaf.cafebonappetit.com/cafe/the-kings-room/', - cave: 'https://stolaf.cafebonappetit.com/cafe/the-cave/', - burton: 'https://carleton.cafebonappetit.com/cafe/burton/', - ldc: 'https://carleton.cafebonappetit.com/cafe/east-hall/', - sayles: 'https://carleton.cafebonappetit.com/cafe/sayles-cafe/', - weitz: 'https://carleton.cafebonappetit.com/cafe/weitz-cafe/', - schulze: 'https://carleton.cafebonappetit.com/cafe/schulze-cafe/', -} as const - -export const CAFE_ID_TO_URL = { - 261: 'stav', - 262: 'cage', - 263: 'kingsRoom', - 35: 'burton', - 36: 'ldc', - 34: 'sayles', - 458: 'weitz', -} as const - -function isKeyofCafeIdToUrl(s: string | number): s is keyof typeof CAFE_ID_TO_URL { - return s in CAFE_ID_TO_URL -} - -export async function pauseMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getPauseMenu() -} - -export async function bonAppMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let cafeId = ctx.URL.searchParams.get('cafeId') - ctx.assert(cafeId, 400, '?cafeId is required') - ctx.assert( - isKeyofCafeIdToUrl(cafeId), - 400, - `?cafeId must be one of ${Object.values(CAFE_ID_TO_URL).join(', ')}`, - ) - ctx.body = await getMenu(CAFE_URLS[CAFE_ID_TO_URL[cafeId]]) -} - -export async function bonAppCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let cafeId = ctx.URL.searchParams.get('cafeId') - ctx.assert(cafeId, 400, '?cafeId is required') - ctx.assert( - isKeyofCafeIdToUrl(cafeId), - 400, - `?cafeId must be one of ${Object.values(CAFE_ID_TO_URL).join(', ')}`, - ) - ctx.body = await getInfo(CAFE_URLS[CAFE_ID_TO_URL[cafeId]]) -} - -export async function bonAppNutrition(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let itemId = ctx.URL.searchParams.get('itemId') - ctx.assert(itemId, 400, '?itemId is required') - ctx.body = await getNutrition(itemId) -} - -export async function stavCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.stav) -} - -export async function stavMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.stav) -} - -export async function cageCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.cage) -} - -export async function cageMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.cage) -} - -export async function kingsRoomCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.kings) -} - -export async function kingsRoomMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.kings) -} - -export async function caveCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.cave) -} - -export async function caveMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.cave) -} - -export async function burtonCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.burton) -} - -export async function burtonMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.burton) -} - -export async function ldcCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.ldc) -} - -export async function ldcMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.ldc) -} - -export async function saylesCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.sayles) -} - -export async function saylesMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.sayles) -} - -export async function weitzCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.weitz) -} - -export async function weitzMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.weitz) -} - -export async function schulzeCafe(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getInfo(CAFE_URLS.schulze) -} - -export async function schulzeMenu(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getMenu(CAFE_URLS.schulze) -} +export * from '../../ccci-shared/us/minnesota/northfield/menu.js' diff --git a/source/ccci-stolaf-college/v1/news.ts b/source/ccci-stolaf-college/v1/news.ts index 45be41e7..fdb42dc4 100644 --- a/source/ccci-stolaf-college/v1/news.ts +++ b/source/ccci-stolaf-college/v1/news.ts @@ -1,62 +1,68 @@ -import {ONE_HOUR} from '../../ccc-lib/constants.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' +import {FeedItemSchema} from '../../feeds/types.js' import {fetchRssFeed} from '../../feeds/rss.js' -import {fetchWpJson, deprecatedWpJson} from '../../feeds/wp-json.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' +import {deprecatedWpJson, fetchWpJson} from '../../feeds/wp-json.js' -const cachedRssFeed = mem(fetchRssFeed, {maxAge: ONE_HOUR}) -const cachedWpJsonFeed = mem(fetchWpJson, {maxAge: ONE_HOUR}) +export const getRssFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/rss', + validate: { + query: z.object({url: z.string().url()}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await fetchRssFeed(ctx.request.query.url) + }, +}) -export async function rss(ctx: Context) { - ctx.cacheControl(ONE_HOUR) +export const getWpJsonFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/wpjson', + validate: { + query: z.object({url: z.string().url()}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await fetchWpJson(ctx.request.query.url) + }, +}) - let urlToFetch = ctx.URL.searchParams.get('url') - ctx.assert(urlToFetch, 400, '?url is required') - ctx.body = await cachedRssFeed(urlToFetch) -} +const KnownFeeds = z.enum(['stolaf', 'oleville', 'politicole', 'mess', 'ksto', 'krlx']) -export async function wpJson(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - let urlToFetch = ctx.URL.searchParams.get('url') - ctx.assert(urlToFetch, 400, '?url is required') - ctx.body = await cachedWpJsonFeed(urlToFetch) -} - -export async function stolaf(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedWpJsonFeed(new URL('https://wp.stolaf.edu/wp-json/wp/v2/posts'), { - per_page: 10, - _embed: true, - }) -} - -export async function oleville(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedRssFeed(new URL('https://www.oleville.com/blog-feed.xml')) -} - -export function politicole(ctx: Context) { - ctx.body = deprecatedWpJson() -} - -export async function mess(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedWpJsonFeed( - new URL('https://www.theolafmessenger.com/wp-json/wp/v2/posts/'), - {per_page: 10, _embed: true}, - ) -} - -export function ksto(ctx: Context) { - ctx.body = deprecatedWpJson() -} - -export async function krlx(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await cachedRssFeed(new URL('https://content.krlx.org/feed/')) -} +export const getKnownFeedRoute = createRouteSpec({ + method: 'get', + path: '/news/named/:feed', + validate: { + params: z.object({feed: KnownFeeds}), + response: FeedItemSchema.array(), + }, + handler: async (ctx) => { + switch (ctx.request.params.feed) { + case 'stolaf': + ctx.body = await fetchWpJson(new URL('https://wp.stolaf.edu/wp-json/wp/v2/posts'), { + per_page: 10, + _embed: true, + }) + break + case 'krlx': + ctx.body = await fetchRssFeed(new URL('https://content.krlx.org/feed/')) + break + case 'ksto': + ctx.body = deprecatedWpJson() + break + case 'oleville': + ctx.body = await fetchRssFeed(new URL('https://www.oleville.com/blog-feed.xml')) + break + case 'politicole': + ctx.body = deprecatedWpJson() + break + case 'mess': + ctx.body = await fetchWpJson(new URL('https://www.theolafmessenger.com/wp-json/wp/v2/posts/'), { + per_page: 10, + _embed: true, + }) + break + } + }, +}) diff --git a/source/ccci-stolaf-college/v1/transit.ts b/source/ccci-stolaf-college/v1/transit.ts index 290a17db..3396dc8d 100644 --- a/source/ccci-stolaf-college/v1/transit.ts +++ b/source/ccci-stolaf-college/v1/transit.ts @@ -1,27 +1,34 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {BusTimesResponseSchema, TransitModesResponseSchema} from '../../ccc-frog-pond/transit.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -export function getBus() { - return GET(GH_PAGES('bus-times.json')).json() +export async function getBus() { + return BusTimesResponseSchema.parse(await get(GH_PAGES('bus-times.json')).json()) } -export async function bus(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getBus() -} +export const getBusTimesRoute = createRouteSpec({ + method: 'get', + path: '/transit/bus', + validate: { + response: BusTimesResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getBus() + }, +}) -export function getModes() { - return GET(GH_PAGES('transportation.json')).json() +export async function getTransitModes() { + return TransitModesResponseSchema.parse(await get(GH_PAGES('transportation.json')).json()) } -export async function modes(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getModes() -} +export const getTransitModesRoute = createRouteSpec({ + method: 'get', + path: '/transit/modes', + validate: { + response: TransitModesResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getTransitModes() + }, +}) diff --git a/source/ccci-stolaf-college/v1/util.ts b/source/ccci-stolaf-college/v1/util.ts index 0eb4286b..e6a8c5e9 100644 --- a/source/ccci-stolaf-college/v1/util.ts +++ b/source/ccci-stolaf-college/v1/util.ts @@ -1,14 +1,15 @@ -import {htmlToMarkdown as toMarkdown} from '../../ccc-lib/html-to-markdown.js' -import type {Context} from '../../ccc-server/context.js' +import {htmlToMarkdown} from '../../ccc-lib/html-to-markdown.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' -export function htmlToMarkdown(ctx: Context) { - ctx.assert( - typeof ctx.request.body === 'object' && - ctx.request.body && - 'text' in ctx.request.body && - typeof ctx.request.body.text === 'string', - 400, - 'request body .text property is required', - ) - ctx.response.body = toMarkdown(ctx.request.body.text) -} +export const htmlToMarkdownRoute = createRouteSpec({ + method: 'post', + path: '/util/html-to-md', + validate: { + body: z.object({text: z.string()}), + response: z.string(), + }, + handler: (ctx) => { + ctx.body = htmlToMarkdown(ctx.request.body.text) + }, +}) diff --git a/source/ccci-stolaf-college/v1/webcams.ts b/source/ccci-stolaf-college/v1/webcams.ts index ecc6ac1d..536b7b71 100644 --- a/source/ccci-stolaf-college/v1/webcams.ts +++ b/source/ccci-stolaf-college/v1/webcams.ts @@ -1,19 +1,17 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {WebcamResponseSchema} from '../../ccc-frog-pond/webcams.js' -const GET = mem(get, {maxAge: ONE_DAY}) - -let url = GH_PAGES('webcams.json') - -export function getWebcams() { - return GET(url).json() +export async function getWebcams() { + return WebcamResponseSchema.parse(await get(GH_PAGES('webcams.json')).json()) } -export async function webcams(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getWebcams() -} +export const getWebcamsRoute = createRouteSpec({ + method: 'get', + path: '/webcams', + validate: {response: WebcamResponseSchema}, + handler: async (ctx) => { + ctx.body = await getWebcams() + }, +}) From 3d8c71fdc1ac010c6bff7046da9ec3e585a5639d Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 13:52:12 -0400 Subject: [PATCH 18/24] finish porting stolaf to koa-zod-router --- source/carleton-edu/convocation.ts | 88 +++++++ .../v1/news => carleton-edu}/nnb.ts | 2 +- source/carleton-edu/student-org.ts | 113 +++++++++ source/carleton-edu/student-work.ts | 91 ++++++++ source/ccc-frog-pond/a-to-z.ts | 11 + source/ccci-carleton-college/v1/convos.ts | 93 +------- source/ccci-carleton-college/v1/jobs.ts | 94 +------- source/ccci-carleton-college/v1/news.ts | 2 +- source/ccci-carleton-college/v1/orgs.ts | 114 +-------- source/ccci-stolaf-college/v1/a-z.ts | 81 ++----- source/ccci-stolaf-college/v1/departments.ts | 17 -- source/ccci-stolaf-college/v1/directory.ts | 29 +++ source/ccci-stolaf-college/v1/index.ts | 19 +- source/ccci-stolaf-college/v1/jobs.ts | 217 ++---------------- source/ccci-stolaf-college/v1/orgs.ts | 19 +- source/ccci-stolaf-college/v1/printing.ts | 30 +-- source/ccci-stolaf-college/v1/streams.ts | 119 +++------- source/stolaf-edu/a-to-z.ts | 55 +++++ source/stolaf-edu/directory.ts | 34 +++ source/stolaf-edu/streaming.ts | 55 +++++ source/stolaf-edu/student-work.ts | 212 +++++++++++++++++ source/student-orgs/presence.ts | 5 +- 22 files changed, 804 insertions(+), 696 deletions(-) create mode 100644 source/carleton-edu/convocation.ts rename source/{ccci-carleton-college/v1/news => carleton-edu}/nnb.ts (96%) create mode 100644 source/carleton-edu/student-org.ts create mode 100644 source/carleton-edu/student-work.ts create mode 100644 source/ccc-frog-pond/a-to-z.ts delete mode 100644 source/ccci-stolaf-college/v1/departments.ts create mode 100644 source/ccci-stolaf-college/v1/directory.ts create mode 100644 source/stolaf-edu/a-to-z.ts create mode 100644 source/stolaf-edu/directory.ts create mode 100644 source/stolaf-edu/streaming.ts create mode 100644 source/stolaf-edu/student-work.ts diff --git a/source/carleton-edu/convocation.ts b/source/carleton-edu/convocation.ts new file mode 100644 index 00000000..a7852168 --- /dev/null +++ b/source/carleton-edu/convocation.ts @@ -0,0 +1,88 @@ +import {z} from 'zod' +import {JSDOM} from 'jsdom' +import moment from 'moment/moment.js' +import {get} from '../ccc-lib/http.js' +import assert from 'node:assert/strict' +import {htmlToMarkdown} from '../ccc-lib/html-to-markdown.js' +import {makeAbsoluteUrl} from '../ccc-lib/url.js' + +const archiveBase = 'https://feed.podbean.com/carletonconvos/feed.xml' + +export type ConvocationEpisodeType = z.infer +export const ConvocationEpisodeSchema = z.object({ + title: z.string(), + description: z.string(), + pubDate: z.string().datetime(), + enclosure: z.nullable( + z.object({ + url: z.string().url(), + length: z.string(), + type: z.string(), + }), + ), +}) + +export type UpcomingConvocationEventType = z.infer +export const UpcomingConvocationEventSchema = z.object({ + sponsor: z.string(), + content: z.string(), + images: z.string().url().array(), +}) + +function processConvocation(event: Element): ConvocationEpisodeType { + let title = JSDOM.fragment(event.querySelector('title')?.textContent ?? '').textContent?.trim() ?? '' + + let description = JSDOM.fragment(event.querySelector('description')?.textContent ?? '').textContent?.trim() ?? '' + + let pubDate = moment(event.querySelector('pubDate')?.textContent).toISOString() + + let enclosureEl = event.querySelector('enclosure') + let enclosure = enclosureEl + ? { + type: enclosureEl.getAttribute('type') ?? '', + url: enclosureEl.getAttribute('url') ?? '', + length: enclosureEl.getAttribute('length') ?? '', + } + : null + + return ConvocationEpisodeSchema.parse({title, description, pubDate, enclosure}) +} + +export async function fetchArchived(): Promise { + let body = await get(archiveBase).text() + let dom = new JSDOM(body, {contentType: 'text/xml'}) + let convos = Array.from(dom.window.document.querySelectorAll('rss channel item')).map(processConvocation) + return Promise.all(convos.slice(0, 100)) +} + +export async function fetchUpcomingDetail(eventId: string): Promise { + let baseUrl = 'https://www.carleton.edu/convocations/calendar/' + let url = 'https://www.carleton.edu/convocations/calendar/' + let body = await get(url, {searchParams: {eId: eventId}}).text() + + let dom = new JSDOM(body) + + let eventEl = dom.window.document.querySelector('.campus-calendar--event') + assert(eventEl) + + let descText = htmlToMarkdown(eventEl.querySelector('.event_description')?.innerHTML ?? '', { + baseUrl, + }) + + let images = Array.from(eventEl.querySelectorAll('.single_event_image a')) + .flatMap((imgLink) => { + let href = imgLink.getAttribute('href') + return href ? [href] : [] + }) + .map((href) => makeAbsoluteUrl(href, {baseUrl})) + + let sponsorText = htmlToMarkdown(eventEl.querySelector('.sponsorContactInfo')?.innerHTML ?? '', { + baseUrl, + }) + + return UpcomingConvocationEventSchema.parse({ + images, + content: descText, + sponsor: sponsorText, + }) +} diff --git a/source/ccci-carleton-college/v1/news/nnb.ts b/source/carleton-edu/nnb.ts similarity index 96% rename from source/ccci-carleton-college/v1/news/nnb.ts rename to source/carleton-edu/nnb.ts index f3b713f4..2f053300 100644 --- a/source/ccci-carleton-college/v1/news/nnb.ts +++ b/source/carleton-edu/nnb.ts @@ -1,4 +1,4 @@ -import {get} from '../../../ccc-lib/http.js' +import {get} from '../ccc-lib/http.js' import {JSDOM} from 'jsdom' import {groupBy} from 'lodash-es' import {z} from 'zod' diff --git a/source/carleton-edu/student-org.ts b/source/carleton-edu/student-org.ts new file mode 100644 index 00000000..89e3e68e --- /dev/null +++ b/source/carleton-edu/student-org.ts @@ -0,0 +1,113 @@ +import {z} from 'zod' +import {get} from '../ccc-lib/http.js' +import {JSDOM} from 'jsdom' +import {sortBy} from 'lodash-es' + +export type CarletonStudentOrgType = z.infer +export const CarletonStudentOrgSchema = z.object({ + id: z.string(), + contacts: z.string().array(), + categories: z.string().array(), + socialLinks: z.string().url().array(), + adminLink: z.string().url(), + description: z.string(), + website: z.string().url(), + name: z.string().min(1), +}) + +export type SortableCarletonStudentOrgType = z.infer +export const SortableCarletonStudentOrgSchema = CarletonStudentOrgSchema.extend({ + /** The name, but with leading common prefixes stripped, such as "The" */ + $sortableName: z.string(), + $groupableName: z.string(), +}) + +function domToOrg(orgNode: Element, sortableRegex: RegExp): SortableCarletonStudentOrgType { + let name = + orgNode + .querySelector('h4') + ?.textContent?.replace(/ Manage$/, '') + .trim() ?? '' + + let adminLink = orgNode.querySelector('h4 > a')?.getAttribute('href') + adminLink = adminLink ? `https://apps.carleton.edu${adminLink}` : '' + + const ids = Array.from(orgNode.querySelectorAll('a[name]')).map((n) => n.getAttribute('name')) + const id = ids[0] ?? name + + const description = orgNode.querySelector('.orgDescription')?.textContent?.trim() ?? '' + + let contacts = Array.from( + new Set( + orgNode + .querySelector('.contacts') + ?.textContent?.trim() + .replace(/^Contact: /, '') + .split(', ') ?? [], + ), + ) + + const websiteEls = Array.from(orgNode.querySelectorAll('.site a')).flatMap((n) => { + let href = n.getAttribute('href') + return href ? [href] : [] + }) + let website = websiteEls[0] ?? '' + if (website.length && !/^https?:\/\//.test(website)) { + website = `http://${website}` + } + + const socialLinks = Array.from(orgNode.querySelectorAll('a > img')).flatMap((n) => { + let href = n.parentElement?.getAttribute('href') + return href ? [href] : [] + }) + + let sortableName = name.replace(sortableRegex, '') + + let orgObj: SortableCarletonStudentOrgType = { + id, + contacts, + description, + name, + website, + categories: [], + socialLinks, + adminLink, + $sortableName: sortableName, + $groupableName: sortableName.at(0)?.toLocaleUpperCase() ?? '', + } + + return SortableCarletonStudentOrgSchema.parse(orgObj) +} + +export async function getAllOrgs(): Promise { + let orgsUrl = 'https://apps.carleton.edu/student/orgs/' + let body = await get(orgsUrl).text() + let dom = new JSDOM(body) + + const allOrgWrappers = dom.window.document.querySelectorAll('.orgContainer, .careerField') + + const allOrgs = new Map() + const sortableRegex = /^(Carleton( College)?|The) +/i + let currentCategory = null + for (const orgNode of allOrgWrappers) { + if (orgNode.classList.contains('careerField')) { + currentCategory = orgNode.textContent?.trim() + continue + } + + const org = domToOrg(orgNode, sortableRegex) + if (!allOrgs.has(org.id)) { + allOrgs.set(org.id, org) + } + + const stored = allOrgs.get(org.id) + if (!stored || !currentCategory) { + continue + } + if (!stored.categories.includes(currentCategory)) { + stored.categories.push(currentCategory) + } + } + + return sortBy(Array.from(allOrgs.values()), '$sortableName') +} diff --git a/source/carleton-edu/student-work.ts b/source/carleton-edu/student-work.ts new file mode 100644 index 00000000..f268970f --- /dev/null +++ b/source/carleton-edu/student-work.ts @@ -0,0 +1,91 @@ +import {get} from '../ccc-lib/http.js' +import {ONE_DAY} from '../ccc-lib/constants.js' +import {z} from 'zod' +import assert from 'node:assert/strict' +import {JSDOM} from 'jsdom' +import {buildDetailMap} from '../ccc-lib/html.js' +import getUrls from 'get-urls' +import pMap from 'p-map' +import mem from 'memoize' + +const GET_ONE_DAY = mem(get, {maxAge: ONE_DAY}) + +const jobsUrl = 'https://apps.carleton.edu/campus/sfs/employment/feeds/jobs' + +const BOOLEAN_KEYS = ['Position available during term', 'Position available during break'] + +const PARAGRAPH_KEYS = ['Description'] + +type JobType = z.infer +const JobSchema = z.object({ + dateOpen: z.string(), + department: z.string().nullable(), + description: z.string(), + duringBreak: z.boolean(), + duringTerm: z.boolean(), + id: z.string(), + links: z.string().url().array(), + offCampus: z.boolean(), + title: z.string(), +}) + +export async function fetchJob(link: URL): Promise { + let id = link.searchParams.get('job_id') + assert(id) + + if (link.protocol === 'http:') { + link.protocol = 'https:' + } + + const body = await GET_ONE_DAY(link).text() + const dom = new JSDOM(body) + + const jobs = dom.window.document.querySelector('#jobs') + assert(jobs) + const title = jobs.querySelector('h3') + assert(title) + + let titleText = title.textContent?.trim() ?? '' + const offCampus = titleText.startsWith('Off Campus') + if (offCampus) { + titleText = titleText.replace(/^Off Campus: +/, '') + } + + let details = jobs.querySelectorAll('ul:first-of-type > li') + let detailMap = buildDetailMap(details, {paragraphs: PARAGRAPH_KEYS, boolean: BOOLEAN_KEYS}) + + const description = detailMap.get('Description') ?? '' + assert(typeof description === 'string') + const links = Array.from(getUrls(description)) + + const department = detailMap.get('Department or Office') + assert(typeof department === 'string') + + const dateOpen = detailMap.get('Date Open') ?? 'Unknown' + assert(typeof dateOpen === 'string') + + return JobSchema.parse({ + id: id, + title: titleText, + offCampus: offCampus, + department, + dateOpen, + duringTerm: Boolean(detailMap.get('Position available during term')), + duringBreak: Boolean(detailMap.get('Position available during break')), + description, + links: links, + }) +} + +export type StudentWorkResponseType = z.infer +export const StudentWorkResponseSchema = z.array(JobSchema) + +export async function getAllJobs(): Promise { + let body = await GET_ONE_DAY(jobsUrl).text() + let dom = new JSDOM(body, {contentType: 'text/xml'}) + let jobLinks = Array.from(dom.window.document.querySelectorAll('rss channel item link')).flatMap((link) => { + let href = link.textContent?.trim() ?? '' + return URL.canParse(href) ? [new URL(href)] : [] + }) + return pMap(jobLinks, fetchJob, {concurrency: 4}) +} diff --git a/source/ccc-frog-pond/a-to-z.ts b/source/ccc-frog-pond/a-to-z.ts new file mode 100644 index 00000000..137da608 --- /dev/null +++ b/source/ccc-frog-pond/a-to-z.ts @@ -0,0 +1,11 @@ +import {z} from 'zod' + +export type AllAboutOlafExtraAzResponseType = z.infer +export const AllAboutOlafExtraAzResponseSchema = z.object({ + data: z.array( + z.object({ + letter: z.string(), + values: z.array(z.object({label: z.string(), url: z.string().url()})), + }), + ), +}) diff --git a/source/ccci-carleton-college/v1/convos.ts b/source/ccci-carleton-college/v1/convos.ts index 319340db..27afbf00 100644 --- a/source/ccci-carleton-college/v1/convos.ts +++ b/source/ccci-carleton-college/v1/convos.ts @@ -1,94 +1,13 @@ -import {get} from '../../ccc-lib/http.js' -import {makeAbsoluteUrl} from '../../ccc-lib/url.js' -import {htmlToMarkdown} from '../../ccc-lib/html-to-markdown.js' -import {JSDOM} from 'jsdom' -import moment from 'moment' -import assert from 'node:assert/strict' import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' import {EventSchema} from '../../calendar/types.js' import {CARLETON_UPCOMING_CONVOCATIONS_URL, getInternetCalendar} from './calendar.js' - -const archiveBase = 'https://feed.podbean.com/carletonconvos/feed.xml' - -type ConvocationEpisodeType = z.infer -const ConvocationEpisodeSchema = z.object({ - title: z.string(), - description: z.string(), - pubDate: z.string().datetime(), - enclosure: z.nullable( - z.object({ - url: z.string().url(), - length: z.string(), - type: z.string(), - }), - ), -}) - -type UpcomingConvocationEventType = z.infer -const UpcomingConvocationEventSchema = z.object({ - sponsor: z.string(), - content: z.string(), - images: z.string().url().array(), -}) - -function processConvocation(event: Element): ConvocationEpisodeType { - let title = JSDOM.fragment(event.querySelector('title')?.textContent ?? '').textContent?.trim() ?? '' - - let description = JSDOM.fragment(event.querySelector('description')?.textContent ?? '').textContent?.trim() ?? '' - - let pubDate = moment(event.querySelector('pubDate')?.textContent).toISOString() - - let enclosureEl = event.querySelector('enclosure') - let enclosure = enclosureEl - ? { - type: enclosureEl.getAttribute('type') ?? '', - url: enclosureEl.getAttribute('url') ?? '', - length: enclosureEl.getAttribute('length') ?? '', - } - : null - - return {title, description, pubDate, enclosure} -} - -async function fetchUpcomingDetail(eventId: string): Promise { - let baseUrl = 'https://www.carleton.edu/convocations/calendar/' - let url = 'https://www.carleton.edu/convocations/calendar/' - let body = await get(url, {searchParams: {eId: eventId}}).text() - - let dom = new JSDOM(body) - - let eventEl = dom.window.document.querySelector('.campus-calendar--event') - assert(eventEl) - - let descText = htmlToMarkdown(eventEl.querySelector('.event_description')?.innerHTML ?? '', { - baseUrl, - }) - - let images = Array.from(eventEl.querySelectorAll('.single_event_image a')) - .flatMap((imgLink) => { - let href = imgLink.getAttribute('href') - return href ? [href] : [] - }) - .map((href) => makeAbsoluteUrl(href, {baseUrl})) - - let sponsorText = htmlToMarkdown(eventEl.querySelector('.sponsorContactInfo')?.innerHTML ?? '', { - baseUrl, - }) - - return UpcomingConvocationEventSchema.parse({ - images, - content: descText, - sponsor: sponsorText, - }) -} - -async function fetchArchived() { - let body = await get(archiveBase).text() - let dom = new JSDOM(body, {contentType: 'text/xml'}) - let convos = Array.from(dom.window.document.querySelectorAll('rss channel item')).map(processConvocation) - return Promise.all(convos.slice(0, 100)) -} +import { + ConvocationEpisodeSchema, + fetchArchived, + fetchUpcomingDetail, + UpcomingConvocationEventSchema, +} from '../../carleton-edu/convocation.js' export const getConvocationDetail = createRouteSpec({ method: 'get', diff --git a/source/ccci-carleton-college/v1/jobs.ts b/source/ccci-carleton-college/v1/jobs.ts index a991bd63..56d951fc 100644 --- a/source/ccci-carleton-college/v1/jobs.ts +++ b/source/ccci-carleton-college/v1/jobs.ts @@ -1,101 +1,11 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import {JSDOM} from 'jsdom' -import getUrls from 'get-urls' -import pMap from 'p-map' -import assert from 'node:assert/strict' -import {buildDetailMap} from '../../ccc-lib/html.js' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -const GET_ONE_DAY = mem(get, {maxAge: ONE_DAY}) - -const jobsUrl = 'https://apps.carleton.edu/campus/sfs/employment/feeds/jobs' - -const BOOLEAN_KEYS = ['Position available during term', 'Position available during break'] - -const PARAGRAPH_KEYS = ['Description'] - -type JobType = z.infer -const JobSchema = z.object({ - dateOpen: z.string(), - department: z.string().nullable(), - description: z.string(), - duringBreak: z.boolean(), - duringTerm: z.boolean(), - id: z.string(), - links: z.string().url().array(), - offCampus: z.boolean(), - title: z.string(), -}) - -export async function fetchJob(link: URL): Promise { - let id = link.searchParams.get('job_id') - assert(id) - - if (link.protocol === 'http:') { - link.protocol = 'https:' - } - - const body = await GET_ONE_DAY(link).text() - const dom = new JSDOM(body) - - const jobs = dom.window.document.querySelector('#jobs') - assert(jobs) - const title = jobs.querySelector('h3') - assert(title) - - let titleText = title.textContent?.trim() ?? '' - const offCampus = titleText.startsWith('Off Campus') - if (offCampus) { - titleText = titleText.replace(/^Off Campus: +/, '') - } - - let details = jobs.querySelectorAll('ul:first-of-type > li') - let detailMap = buildDetailMap(details, {paragraphs: PARAGRAPH_KEYS, boolean: BOOLEAN_KEYS}) - - const description = detailMap.get('Description') ?? '' - assert(typeof description === 'string') - const links = Array.from(getUrls(description)) - - const department = detailMap.get('Department or Office') - assert(typeof department === 'string') - - const dateOpen = detailMap.get('Date Open') ?? 'Unknown' - assert(typeof dateOpen === 'string') - - return JobSchema.parse({ - id: id, - title: titleText, - offCampus: offCampus, - department, - dateOpen, - duringTerm: Boolean(detailMap.get('Position available during term')), - duringBreak: Boolean(detailMap.get('Position available during break')), - description, - links: links, - }) -} - -type ResponseType = z.infer -const ResponseSchema = z.array(JobSchema) - -async function getAllJobs(): Promise { - let body = await GET_ONE_DAY(jobsUrl).text() - let dom = new JSDOM(body, {contentType: 'text/xml'}) - let jobLinks = Array.from(dom.window.document.querySelectorAll('rss channel item link')).flatMap((link) => { - let href = link.textContent?.trim() ?? '' - return URL.canParse(href) ? [new URL(href)] : [] - }) - return pMap(jobLinks, fetchJob, {concurrency: 4}) -} +import {getAllJobs, StudentWorkResponseSchema} from '../../carleton-edu/student-work.js' export const getJobsRoute = createRouteSpec({ method: 'get', path: '/jobs', validate: { - response: ResponseSchema, + response: StudentWorkResponseSchema, }, handler: async (ctx) => { ctx.body = await getAllJobs() diff --git a/source/ccci-carleton-college/v1/news.ts b/source/ccci-carleton-college/v1/news.ts index 57b09110..9340242a 100644 --- a/source/ccci-carleton-college/v1/news.ts +++ b/source/ccci-carleton-college/v1/news.ts @@ -3,7 +3,7 @@ import {z} from 'zod' import {FeedItemSchema} from '../../feeds/types.js' import {fetchRssFeed} from '../../feeds/rss.js' import {deprecatedWpJson, fetchWpJson} from '../../feeds/wp-json.js' -import {NnbResponseSchema, noonNewsBulletin} from './news/nnb.js' +import {NnbResponseSchema, noonNewsBulletin} from '../../carleton-edu/nnb.js' export const getRssFeedRoute = createRouteSpec({ method: 'get', diff --git a/source/ccci-carleton-college/v1/orgs.ts b/source/ccci-carleton-college/v1/orgs.ts index 9dacba26..810f9196 100644 --- a/source/ccci-carleton-college/v1/orgs.ts +++ b/source/ccci-carleton-college/v1/orgs.ts @@ -1,117 +1,5 @@ -import {get} from '../../ccc-lib/http.js' -import {JSDOM} from 'jsdom' -import {sortBy} from 'lodash-es' -import {z} from 'zod' import {createRouteSpec} from 'koa-zod-router' - -export type CarletonStudentOrgType = z.infer -export const CarletonStudentOrgSchema = z.object({ - id: z.string(), - contacts: z.string().array(), - categories: z.string().array(), - socialLinks: z.string().url().array(), - adminLink: z.string().url(), - description: z.string(), - website: z.string().url(), - name: z.string().min(1), -}) - -export type SortableCarletonStudentOrgType = z.infer -export const SortableCarletonStudentOrgSchema = CarletonStudentOrgSchema.extend({ - /** The name, but with leading common prefixes stripped, such as "The" */ - $sortableName: z.string(), - $groupableName: z.string(), -}) - -function domToOrg(orgNode: Element, sortableRegex: RegExp): SortableCarletonStudentOrgType { - let name = - orgNode - .querySelector('h4') - ?.textContent?.replace(/ Manage$/, '') - .trim() ?? '' - - let adminLink = orgNode.querySelector('h4 > a')?.getAttribute('href') - adminLink = adminLink ? `https://apps.carleton.edu${adminLink}` : '' - - const ids = Array.from(orgNode.querySelectorAll('a[name]')).map((n) => n.getAttribute('name')) - const id = ids[0] ?? name - - const description = orgNode.querySelector('.orgDescription')?.textContent?.trim() ?? '' - - let contacts = Array.from( - new Set( - orgNode - .querySelector('.contacts') - ?.textContent?.trim() - .replace(/^Contact: /, '') - .split(', ') ?? [], - ), - ) - - const websiteEls = Array.from(orgNode.querySelectorAll('.site a')).flatMap((n) => { - let href = n.getAttribute('href') - return href ? [href] : [] - }) - let website = websiteEls[0] ?? '' - if (website.length && !/^https?:\/\//.test(website)) { - website = `http://${website}` - } - - const socialLinks = Array.from(orgNode.querySelectorAll('a > img')).flatMap((n) => { - let href = n.parentElement?.getAttribute('href') - return href ? [href] : [] - }) - - let sortableName = name.replace(sortableRegex, '') - - let orgObj: SortableCarletonStudentOrgType = { - id, - contacts, - description, - name, - website, - categories: [], - socialLinks, - adminLink, - $sortableName: sortableName, - $groupableName: sortableName.at(0)?.toLocaleUpperCase() ?? '', - } - - return SortableCarletonStudentOrgSchema.parse(orgObj) -} - -async function getAllOrgs(): Promise { - let orgsUrl = 'https://apps.carleton.edu/student/orgs/' - let body = await get(orgsUrl).text() - let dom = new JSDOM(body) - - const allOrgWrappers = dom.window.document.querySelectorAll('.orgContainer, .careerField') - - const allOrgs = new Map() - const sortableRegex = /^(Carleton( College)?|The) +/i - let currentCategory = null - for (const orgNode of allOrgWrappers) { - if (orgNode.classList.contains('careerField')) { - currentCategory = orgNode.textContent?.trim() - continue - } - - const org = domToOrg(orgNode, sortableRegex) - if (!allOrgs.has(org.id)) { - allOrgs.set(org.id, org) - } - - const stored = allOrgs.get(org.id) - if (!stored || !currentCategory) { - continue - } - if (!stored.categories.includes(currentCategory)) { - stored.categories.push(currentCategory) - } - } - - return sortBy(Array.from(allOrgs.values()), '$sortableName') -} +import {getAllOrgs, SortableCarletonStudentOrgSchema} from '../../carleton-edu/student-org.js' export const getStudentOrgsRoute = createRouteSpec({ method: 'get', diff --git a/source/ccci-stolaf-college/v1/a-z.ts b/source/ccci-stolaf-college/v1/a-z.ts index 946a567f..e6075156 100644 --- a/source/ccci-stolaf-college/v1/a-z.ts +++ b/source/ccci-stolaf-college/v1/a-z.ts @@ -1,73 +1,22 @@ +import {createRouteSpec} from 'koa-zod-router' +import {AToZResponseSchema, combineResponses, getOlafAtoZ} from '../../stolaf-edu/a-to-z.js' +import {AllAboutOlafExtraAzResponseSchema} from '../../ccc-frog-pond/a-to-z.js' import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' -import {z} from 'zod' -const GET = mem(get, {maxAge: ONE_DAY}) - -type StOlafAzResponseType = z.infer -const StOlafAzResponseSchema = z.object({ - az_nav: z.object({ - menu_items: z.array( - z.object({ - letter: z.string(), - values: z.array(z.object({label: z.string(), url: z.string().url()})), - }), - ), - }), -}) - -type AllAboutOlafExtraAzResponseType = z.infer -const AllAboutOlafExtraAzResponseSchema = z.object({ - data: z.array( - z.object({ - letter: z.string(), - values: z.array(z.object({label: z.string(), url: z.string().url()})), - }), - ), -}) - -async function getOlafAtoZ() { - let url = 'https://wp.stolaf.edu/wp-json/site-data/sidebar/a-z' - return StOlafAzResponseSchema.parse(await GET(url).json()) -} - -async function getPagesAtoZ() { - return AllAboutOlafExtraAzResponseSchema.parse(await GET(GH_PAGES('a-to-z.json')).json()) +export async function getPagesAtoZ() { + return AllAboutOlafExtraAzResponseSchema.parse(await get(GH_PAGES('a-to-z.json')).json()) } -// merge custom entries defined on GH pages with the fetched WP-JSON -function combineResponses( - pagesResponse: AllAboutOlafExtraAzResponseType, - olafResponse: StOlafAzResponseType, -) { - let olafData = olafResponse.az_nav.menu_items - - pagesResponse.data.forEach(({letter, values}) => { - // find the matching keyed letter to add our own values to - let targetIndex = olafData.findIndex((entry) => entry.letter === letter) - let targetData = olafData[targetIndex] - - if (targetData) { - // add our custom values and only resort the impacted indices - targetData.values.push(...values) - targetData.values.sort((a, b) => a.label.localeCompare(b.label)) - } - }) - - return olafData.map(({letter, values}) => ({ - title: letter[0], - data: values, - })) +export async function getAToZ() { + return AToZResponseSchema.parse(await combineResponses(getPagesAtoZ(), getOlafAtoZ())) } -export async function atoz(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - let pagesResponse = await getPagesAtoZ() - let olafResponse = await getOlafAtoZ() - - ctx.body = combineResponses(pagesResponse, olafResponse) -} +export const getAToZRoute = createRouteSpec({ + method: 'get', + path: '/a-to-z', + validate: {response: AToZResponseSchema}, + handler: async (ctx) => { + ctx.body = await getAToZ() + }, +}) diff --git a/source/ccci-stolaf-college/v1/departments.ts b/source/ccci-stolaf-college/v1/departments.ts deleted file mode 100644 index ae0b181f..00000000 --- a/source/ccci-stolaf-college/v1/departments.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' - -const GET = mem(get, {maxAge: ONE_DAY}) - -export function getDepartments() { - let url = 'https://www.stolaf.edu/directory/departments' - return GET(url, {searchParams: {format: 'json'}}).json() -} - -export async function departments(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getDepartments() -} diff --git a/source/ccci-stolaf-college/v1/directory.ts b/source/ccci-stolaf-college/v1/directory.ts new file mode 100644 index 00000000..4eefb461 --- /dev/null +++ b/source/ccci-stolaf-college/v1/directory.ts @@ -0,0 +1,29 @@ +import {createRouteSpec} from 'koa-zod-router' +import { + DepartmentsResponseSchema, + getDirectoryDepartments, + getDirectoryMajors, + MajorsResponseSchema, +} from '../../stolaf-edu/directory.js' + +export const getDepartmentsRoute = createRouteSpec({ + method: 'get', + path: '/directory/departments', + validate: { + response: DepartmentsResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getDirectoryDepartments() + }, +}) + +export const getMajorsRoute = createRouteSpec({ + method: 'get', + path: '/directory/majors', + validate: { + response: MajorsResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getDirectoryMajors() + }, +}) diff --git a/source/ccci-stolaf-college/v1/index.ts b/source/ccci-stolaf-college/v1/index.ts index 39469f53..a18ffb3a 100644 --- a/source/ccci-stolaf-college/v1/index.ts +++ b/source/ccci-stolaf-college/v1/index.ts @@ -3,13 +3,12 @@ import zodRouter from 'koa-zod-router' import * as atoz from './a-z.js' import * as calendar from './calendar.js' import * as contacts from './contacts.js' -import * as departments from './departments.js' +import * as directory from './directory.js' import * as dictionary from './dictionary.js' import * as faqs from './faqs.js' import * as help from './help.js' import * as hours from './hours.js' import * as jobs from './jobs.js' -import * as majors from './majors.js' import * as menus from './menu.js' import * as news from './news.js' import * as orgs from './orgs.js' @@ -38,14 +37,14 @@ api.register(calendar.getInternetCalendarRoute) api.register(calendar.getKnownCalendarRoute) // a-to-z -api.get('/a-to-z', atoz.atoz) +api.register(atoz.getAToZRoute) // dictionary api.register(dictionary.getDictionaryRoute) // directory -api.get('/directory/departments', departments.departments) -api.get('/directory/majors', majors.majors) +api.register(directory.getDepartmentsRoute) +api.register(directory.getMajorsRoute) // important contacts api.register(contacts.getContactsRoute) @@ -60,10 +59,10 @@ api.register(faqs.getFaqsRoute) api.register(webcams.getWebcamsRoute) // jobs -api.get('/jobs', jobs.jobs) +api.register(jobs.getJobsRoute) // orgs -api.get('/orgs', orgs.orgs) +api.register(orgs.getStudentOrgsRoute) // news api.register(news.getRssFeedRoute) @@ -78,11 +77,11 @@ api.register(transit.getBusTimesRoute) api.register(transit.getTransitModesRoute) // streams -api.get('/streams/archived', streams.archived) -api.get('/streams/upcoming', streams.upcoming) +api.register(streams.getArchivedRoute) +api.register(streams.getUpcomingRoute) // stoprint -api.get('/printing/color-printers', printing.colorPrinters) +api.register(printing.getColorPrintersRoute) // reports api.get('/reports/stav', reports.stavMealtimeReport) diff --git a/source/ccci-stolaf-college/v1/jobs.ts b/source/ccci-stolaf-college/v1/jobs.ts index cc88f143..f10e12dd 100644 --- a/source/ccci-stolaf-college/v1/jobs.ts +++ b/source/ccci-stolaf-college/v1/jobs.ts @@ -1,204 +1,13 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import {cleanTextBlock, findHtmlKey, buildDetailMap} from '../../ccc-lib/html.js' -import mem from 'memoize' -import pMap from 'p-map' -import {JSDOM} from 'jsdom' -import getUrls from 'get-urls' -import type {Context} from '../../ccc-server/context.js' -import {z} from 'zod' - -const GET_ONE_DAY = mem(get, {maxAge: ONE_DAY}) -const GET_TWO_DAYS = mem(get, {maxAge: ONE_DAY * 2}) - -const getJobsUrl = () => new URL('https://wp.stolaf.edu/student-jobs/wp-json/wp/v2/pages/80') - -/** - * Set of keys in the html to target when looking at long-form content - * that we need to parse line breaks and special characters within. This - * is specific to the stolaf-college jobs detail page. - */ -const PARAGRAPHICAL_KEYS = [ - 'Job Description', - 'Skills Needed', - 'Additional Comments', - 'How to Apply', - 'Hiring Timeline', -] as const - -/** - * Builds a json response suitable for the client to render - * - * @param {*} url the canonical url for the job detail page - * @param {*} dom the JSDOM used for extracting one-off selectors - * @param {*} detailMap the parsed information - * @returns a cleaned, parsed, and formatted version of the data as JSON - */ -function buildJobDetailResponse(url: URL, dom: JSDOM, detailMap: Map) { - const id = url.pathname.replace(/\D/g, '') - const title = cleanTextBlock( - dom.window.document.querySelector('.gv-list-view-title > h3')?.textContent ?? '', - ) - - const [contactFirstName = '', contactLastName = ''] = findHtmlKey( - 'Contact Person', - detailMap, - ).split(' ') - const contactName = `${contactFirstName} ${contactLastName}`.trim() - - const description = cleanTextBlock(findHtmlKey('Job Description', detailMap)) - const comments = cleanTextBlock(findHtmlKey('Additional Comments', detailMap)) - const skills = cleanTextBlock(findHtmlKey('Skills Needed', detailMap)) - const howToApply = cleanTextBlock(findHtmlKey('How to Apply', detailMap)) - const links = getLinksFromJob(description, comments, skills, howToApply) - - return { - comments, - contactEmail: fixupEmailFormat(findHtmlKey('Contact Email', detailMap)), - contactName: contactName, - contactPhone: fixupPhoneFormat(findHtmlKey('Phone Extension', detailMap)), - description, - goodForIncomingStudents: Boolean( - findHtmlKey('Appropriate for incoming/first-year students', detailMap), - ), - hoursPerWeek: findHtmlKey('Hours/week', detailMap), - howToApply, - id: id, - lastModified: findHtmlKey('Date Posted', detailMap), - links: links, - office: findHtmlKey('Office', detailMap), - openPositions: findHtmlKey('Number of Available Positions', detailMap), - skills, - timeline: cleanTextBlock(findHtmlKey('Hiring Timeline', detailMap)), - timeOfHours: findHtmlKey('Time of Hours', detailMap), - title, - type: findHtmlKey('Job Type', detailMap), - url: url.toString(), - year: findHtmlKey('Job Year', detailMap), - } -} - -async function fetchDetail(url: URL) { - const body = await GET_TWO_DAYS(url).text() - - /** - * run-scripts value is needed to properly evaluate javascript to display an email address. - * see the jsdom documentation for more details https://github.com/jsdom/jsdom#executing-scripts - */ - const dom = new JSDOM(body, { - contentType: 'text/html', - runScripts: 'dangerously', - }) - - /** - * Details is a node list of HTMLDivElement. It is a scoped version of the webpage containing - * all the text elements we need to parse (both keys and values) via `buildDetailMap`. - */ - const details = dom.window.document.querySelectorAll('div') - - /** A key-value Map for querying text elements from html data. */ - const detailMap = buildDetailMap(details, {paragraphs: PARAGRAPHICAL_KEYS}) - - return buildJobDetailResponse(url, dom, detailMap) -} - -/** Clean up carriage returns, newlines, tabs, and misc symbols, and search for application links in the text */ -export function getLinksFromJob(...texts: string[]) { - return Array.from(new Set(texts.map((text) => getUrls(text)))) -} - -function fixupPhoneFormat(phoneNumber: string) { - return phoneNumber.length === 4 ? `507-786-${phoneNumber}` : phoneNumber -} - -function fixupEmailFormat(email: string) { - if (!email.includes('@')) { - // No @ in address ... e.g. smith - return `${email}@stolaf.edu` - } else if (email.endsWith('@')) { - // @ at end ... e.g. smith@ - return `${email}stolaf.edu` - } else { - // Defined address ... e.g. smith@stolaf.edu - return email - } -} - -/** - * The paginator is included within the nested html. We can see if we need to continue requesting - * more data by checking if the button dedicated to clicking next is present. - * - * While there are a few ways to go about parsing whether we've reached the last page, the - * paginator looks like it doesn't even know when it has reached the end of the results! Instead - * of trying to keep tracking of the amount of items we can opt to check the dom for the presence - * of the button. - */ -function nextPageExists(dom: JSDOM) { - return Boolean(dom.window.document.querySelector('.next.page-numbers')) -} - -/** - * The top-level results html provides a bunch of html with links to each posting. We can gather - * each link's href from these pages. - */ -export function findPageUrls(dom: JSDOM) { - return Array.from( - dom.window.document.querySelectorAll('.gv-list-view > .gv-list-view-title > h3 > a'), - ).flatMap((anchor) => { - let href = anchor.getAttribute('href') - return href ? [new URL(href)] : [] - }) -} - -/** - * The coldfusion endpoint stopped providing a robust json api response and started serving - * a much rougher wp-json endpoint according to our logs on 09/27/2022. Implementation as of - * 10/16/2022 is designed around making a series of network requests that involve both json - * and html parsing to deliver the same set of props to match the client's api contract. - * - * In short, we are performing the following fetching and parsing steps: - * 1. [json] wp-json paginated requests to get page urls (1-3 requests for 25->50->75 entries) - * where we target html stored within a json field response - * 2. [html] detail page html requests, involves html parsing and returning json (easily 50+ requests) - * - * So we end up making multiple requests to the paginated wp-json endpoint to build a list of - * all job posting urls, and finally request each url we find to build our data. - */ -async function _getJobs() { - let allUrls: URL[] = [] - - /** - * The top-level wp-json endpoint which provides the list of job postings responds to a query - * parameter named `pagenum`. The paginator as of 10/16/2022 lists 25 entries at a time, so if - * we see 51 entries, we would expect to query this endpoint three times. - */ - let pageNumber = 1 - - let previousDom = undefined - - do { - let jobsUrl = getJobsUrl() - jobsUrl.searchParams.set('pagenum', pageNumber.toString()) - - const rendered = z - .object({content: z.object({rendered: z.string()})}) - // eslint-disable-next-line no-await-in-loop - .parse(await GET_ONE_DAY(jobsUrl).json()).content.rendered - - const dom = new JSDOM(rendered, {contentType: 'text/html'}) - previousDom = dom - pageNumber += 1 - - allUrls.push(...findPageUrls(dom)) - } while (nextPageExists(previousDom)) - - return pMap(allUrls, fetchDetail, {concurrency: 4}) -} - -export const getJobs = mem(_getJobs, {maxAge: ONE_DAY}) - -export async function jobs(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getJobs() -} +import {getJobs, JobSchema} from '../../stolaf-edu/student-work.js' +import {createRouteSpec} from 'koa-zod-router' + +export const getJobsRoute = createRouteSpec({ + method: 'get', + path: '/jobs', + validate: { + response: JobSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getJobs() + }, +}) diff --git a/source/ccci-stolaf-college/v1/orgs.ts b/source/ccci-stolaf-college/v1/orgs.ts index cdeb1417..6e7e93b9 100644 --- a/source/ccci-stolaf-college/v1/orgs.ts +++ b/source/ccci-stolaf-college/v1/orgs.ts @@ -1,15 +1,20 @@ import {ONE_HOUR} from '../../ccc-lib/constants.js' import mem from 'memoize' -import {presence as _presence} from '../../student-orgs/presence.js' -import type {Context} from '../../ccc-server/context.js' +import {presence as _presence, StudentOrgResponseSchema} from '../../student-orgs/presence.js' +import {createRouteSpec} from 'koa-zod-router' const CACHE_DURATION = ONE_HOUR * 36 export const presence = mem(_presence, {maxAge: CACHE_DURATION}) -export async function orgs(ctx: Context) { - ctx.cacheControl(CACHE_DURATION) - - ctx.body = await presence('stolaf') -} +export const getStudentOrgsRoute = createRouteSpec({ + method: 'get', + path: '/orgs', + validate: { + response: StudentOrgResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await presence('stolaf') + }, +}) diff --git a/source/ccci-stolaf-college/v1/printing.ts b/source/ccci-stolaf-college/v1/printing.ts index cd397736..f1e63c44 100644 --- a/source/ccci-stolaf-college/v1/printing.ts +++ b/source/ccci-stolaf-college/v1/printing.ts @@ -1,19 +1,23 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' import {GH_PAGES} from './gh-pages.js' -import type {Context} from '../../ccc-server/context.js' +import {createRouteSpec} from 'koa-zod-router' +import {z} from 'zod' -const GET = mem(get, {maxAge: ONE_DAY}) +export const ColorPrintersResponseSchema = z.object({ + data: z.object({ + colorPrinters: z.array(z.string()), + }), +}) -let url = GH_PAGES('color-printers.json') - -export function getColorPrinters() { - return GET(url).json() +export async function getColorPrinters() { + return ColorPrintersResponseSchema.parse(await get(GH_PAGES('color-printers.json')).json()) } -export async function colorPrinters(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getColorPrinters() -} +export const getColorPrintersRoute = createRouteSpec({ + method: 'get', + path: '/printing/color-printers', + validate: {response: ColorPrintersResponseSchema}, + handler: async (ctx) => { + ctx.body = await getColorPrinters() + }, +}) diff --git a/source/ccci-stolaf-college/v1/streams.ts b/source/ccci-stolaf-college/v1/streams.ts index a69008cf..f8c8999c 100644 --- a/source/ccci-stolaf-college/v1/streams.ts +++ b/source/ccci-stolaf-college/v1/streams.ts @@ -1,88 +1,39 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' import moment from 'moment-timezone' -import type {Context} from '../../ccc-server/context.js' -import {z} from 'zod' - -const StreamEntry = z.object({ - starttime: z.string(), - location: z.string(), - eid: z.unknown(), - performer: z.string(), - subtitle: z.string(), - poster: z.string().url(), - player: z.string().url(), - status: z.string(), - category: z.string(), - hptitle: z.string(), - category_textcolor: z.string(), - category_color: z.string(), - thumb: z.string().url(), - title: z.string(), - iframesrc: z.string().url(), -}) - -const StreamEntryCollection = z.object({ - results: StreamEntry.array(), +import {getStreams, GetStreamsParamsSchema, StreamsResponseSchema} from '../../stolaf-edu/streaming.js' +import {createRouteSpec} from 'koa-zod-router' + +export const getUpcomingRoute = createRouteSpec({ + method: 'get', + path: '/streams/upcoming', + validate: { + query: GetStreamsParamsSchema, + response: StreamsResponseSchema, + }, + handler: async (ctx) => { + const now = moment().tz('America/Chicago') + ctx.body = await getStreams({ + class: 'current', + date_from: ctx.request.query.dateFrom ?? now.format('YYYY-MM-DD'), + date_to: ctx.request.query.dateTo ?? now.add(2, 'month').format('YYYY-MM-DD'), + sort: ctx.request.query.sort, + }) + }, }) -const GetStreamsParamsSchema = z.object({ - dateFrom: z.string().date().optional(), - dateTo: z.string().date().optional(), - sort: z.enum(['ascending', 'descending']).default('ascending'), +export const getArchivedRoute = createRouteSpec({ + method: 'get', + path: '/streams/archived', + validate: { + query: GetStreamsParamsSchema, + response: StreamsResponseSchema, + }, + handler: async (ctx) => { + const now = moment().tz('America/Chicago') + ctx.body = await getStreams({ + class: 'archived', + date_from: ctx.request.query.dateFrom ?? now.subtract(2, 'month').format('YYYY-MM-DD'), + date_to: ctx.request.query.dateTo ?? now.format('YYYY-MM-DD'), + sort: ctx.request.query.sort, + }) + }, }) - -type StOlafStreamsParamsType = z.infer -const StOlafStreamsParamsSchema = z.object({ - date_from: z.string().date(), - date_to: z.string().date(), - sort: z.enum(['ascending', 'descending']), - class: z.enum(['current', 'archived']), -}) - -export async function getStreams(params: StOlafStreamsParamsType) { - const url = 'https://www.stolaf.edu/multimedia/api/collection' - const data = StreamEntryCollection.parse(await get(url, {searchParams: params}).json()) - - return data.results.map((stream) => { - let {starttime} = stream - return { - ...stream, - starttime: moment.tz(starttime, 'YYYY-MM-DD HH:mm', 'America/Chicago').toISOString(), - } - }) -} - -export async function upcoming(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - const { - dateFrom = moment().tz('America/Chicago').format('YYYY-MM-DD'), - dateTo = moment().add(2, 'month').tz('America/Chicago').format('YYYY-MM-DD'), - sort, - } = GetStreamsParamsSchema.parse(Object.fromEntries(ctx.URL.searchParams.entries())) - - ctx.body = await getStreams({ - class: 'current', - date_from: dateFrom, - date_to: dateTo, - sort, - }) -} - -export async function archived(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - const { - dateFrom = moment().subtract(2, 'month').tz('America/Chicago').format('YYYY-MM-DD'), - dateTo = moment().tz('America/Chicago').format('YYYY-MM-DD'), - sort, - } = GetStreamsParamsSchema.parse(Object.fromEntries(ctx.URL.searchParams.entries())) - - ctx.body = await getStreams({ - class: 'archived', - date_from: dateFrom, - date_to: dateTo, - sort, - }) -} diff --git a/source/stolaf-edu/a-to-z.ts b/source/stolaf-edu/a-to-z.ts new file mode 100644 index 00000000..97e2b894 --- /dev/null +++ b/source/stolaf-edu/a-to-z.ts @@ -0,0 +1,55 @@ +import {z} from 'zod' +import {get} from '../ccc-lib/http.js' +import {type AllAboutOlafExtraAzResponseType} from '../ccc-frog-pond/a-to-z.js' + +type StOlafAzResponseType = z.infer +const StOlafAzResponseSchema = z.object({ + az_nav: z.object({ + menu_items: z.array( + z.object({ + letter: z.string(), + values: z.array(z.object({label: z.string(), url: z.string().url()})), + }), + ), + }), +}) + +export const AToZResponseSchema = z.array( + z.object({ + title: z.string(), + data: z.object({}).array(), + }), +) + +export async function getOlafAtoZ() { + let url = 'https://wp.stolaf.edu/wp-json/site-data/sidebar/a-z' + return StOlafAzResponseSchema.parse(await get(url).json()) +} + +// merge custom entries defined on GH pages with the fetched WP-JSON +export async function combineResponses( + pages: Promise, + stolaf: Promise, +) { + let pagesResponse = await pages + let olafResponse = await stolaf + + let olafData = olafResponse.az_nav.menu_items + + pagesResponse.data.forEach(({letter, values}) => { + // find the matching keyed letter to add our own values to + let targetIndex = olafData.findIndex((entry) => entry.letter === letter) + let targetData = olafData[targetIndex] + + if (targetData) { + // add our custom values and only resort the impacted indices + targetData.values.push(...values) + targetData.values.sort((a, b) => a.label.localeCompare(b.label)) + } + }) + + return olafData.map(({letter, values}) => ({ + title: letter[0], + data: values, + })) +} diff --git a/source/stolaf-edu/directory.ts b/source/stolaf-edu/directory.ts new file mode 100644 index 00000000..7f0176c1 --- /dev/null +++ b/source/stolaf-edu/directory.ts @@ -0,0 +1,34 @@ +import {z} from 'zod' +import {get} from '../ccc-lib/http.js' + +export const DepartmentsResponseSchema = z.object({ + results: z.object({ + buildingroom: z.string().nullable(), + buildingabbr: z.string().nullable(), + buildingname: z.string().nullable(), + extension: z.string().nullable(), + text: z.string().nullable(), + headcount: z.number(), + email: z.string().email().nullable(), + fax: z.string().nullable(), + name: z.string(), + website: z.string().url().nullable(), + }), +}) + +export async function getDirectoryDepartments() { + let url = new URL('https://www.stolaf.edu/directory/departments') + return DepartmentsResponseSchema.parse(await get(url, {searchParams: {format: 'json'}}).json()) +} + +export const MajorsResponseSchema = z.object({ + results: z.object({ + headcount: z.number(), + name: z.string(), + }), +}) + +export async function getDirectoryMajors() { + let url = new URL('https://www.stolaf.edu/directory/majors') + return DepartmentsResponseSchema.parse(await get(url, {searchParams: {format: 'json'}}).json()) +} diff --git a/source/stolaf-edu/streaming.ts b/source/stolaf-edu/streaming.ts new file mode 100644 index 00000000..f8483ec4 --- /dev/null +++ b/source/stolaf-edu/streaming.ts @@ -0,0 +1,55 @@ +import moment from 'moment-timezone' +import {z} from 'zod' +import {get} from '../ccc-lib/http.js' + +const StreamEntry = z.object({ + starttime: z.string(), + location: z.string(), + eid: z.unknown(), + performer: z.string(), + subtitle: z.string(), + poster: z.string().url(), + player: z.string().url(), + status: z.string(), + category: z.string(), + hptitle: z.string(), + category_textcolor: z.string(), + category_color: z.string(), + thumb: z.string().url(), + title: z.string(), + iframesrc: z.string().url(), +}) + +const StreamEntryCollection = z.object({ + results: StreamEntry.array(), +}) + +export const GetStreamsParamsSchema = z.object({ + dateFrom: z.string().date().optional(), + dateTo: z.string().date().optional(), + sort: z.enum(['ascending', 'descending']).default('ascending'), +}) + +export type StOlafStreamsParamsType = z.infer +export const StOlafStreamsParamsSchema = z.object({ + date_from: z.string().date(), + date_to: z.string().date(), + sort: z.enum(['ascending', 'descending']), + class: z.enum(['current', 'archived']), +}) + +export type StreamsResponseType = z.infer +export const StreamsResponseSchema = StreamEntry.array() + +export async function getStreams(params: StOlafStreamsParamsType): Promise { + const url = 'https://www.stolaf.edu/multimedia/api/collection' + const data = StreamEntryCollection.parse(await get(url, {searchParams: params}).json()) + + return data.results.map((stream) => { + let {starttime} = stream + return { + ...stream, + starttime: moment.tz(starttime, 'YYYY-MM-DD HH:mm', 'America/Chicago').toISOString(), + } + }) +} diff --git a/source/stolaf-edu/student-work.ts b/source/stolaf-edu/student-work.ts new file mode 100644 index 00000000..0dfbf15d --- /dev/null +++ b/source/stolaf-edu/student-work.ts @@ -0,0 +1,212 @@ +import {JSDOM} from 'jsdom' +import {buildDetailMap, cleanTextBlock, findHtmlKey} from '../ccc-lib/html.js' +import getUrls from 'get-urls' +import {z} from 'zod' +import pMap from 'p-map' +import {get} from '../ccc-lib/http.js' +import {ONE_DAY} from '../ccc-lib/constants.js' +import mem from 'memoize' + +const GET_ONE_DAY = mem(get, {maxAge: ONE_DAY}) +const GET_TWO_DAYS = mem(get, {maxAge: ONE_DAY * 2}) + +const getJobsUrl = () => new URL('https://wp.stolaf.edu/student-jobs/wp-json/wp/v2/pages/80') + +export type JobType = z.infer +export const JobSchema = z.object({ + comments: z.string(), + contactEmail: z.string(), + contactName: z.string(), + contactPhone: z.string(), + description: z.string(), + goodForIncomingStudents: z.boolean(), + hoursPerWeek: z.string(), + howToApply: z.string(), + id: z.string(), + lastModified: z.string(), + links: z.string().array(), + office: z.string(), + openPositions: z.string(), + skills: z.string(), + timeOfHours: z.string(), + timeline: z.string(), + title: z.string(), + type: z.string(), + url: z.string(), + year: z.string(), +}) + +/** + * Set of keys in the html to target when looking at long-form content + * that we need to parse line breaks and special characters within. This + * is specific to the stolaf-college jobs detail page. + */ +const PARAGRAPHICAL_KEYS = [ + 'Job Description', + 'Skills Needed', + 'Additional Comments', + 'How to Apply', + 'Hiring Timeline', +] as const + +/** + * Builds a json response suitable for the client to render + * + * @param {*} url the canonical url for the job detail page + * @param {*} dom the JSDOM used for extracting one-off selectors + * @param {*} detailMap the parsed information + * @returns a cleaned, parsed, and formatted version of the data as JSON + */ +function buildJobDetailResponse(url: URL, dom: JSDOM, detailMap: Map): JobType { + const id = url.pathname.replace(/\D/g, '') + const title = cleanTextBlock(dom.window.document.querySelector('.gv-list-view-title > h3')?.textContent ?? '') + + const [contactFirstName = '', contactLastName = ''] = findHtmlKey('Contact Person', detailMap).split(' ') + const contactName = `${contactFirstName} ${contactLastName}`.trim() + + const description = cleanTextBlock(findHtmlKey('Job Description', detailMap)) + const comments = cleanTextBlock(findHtmlKey('Additional Comments', detailMap)) + const skills = cleanTextBlock(findHtmlKey('Skills Needed', detailMap)) + const howToApply = cleanTextBlock(findHtmlKey('How to Apply', detailMap)) + const links = getLinksFromJob(description, comments, skills, howToApply) + + return { + comments, + contactEmail: fixupEmailFormat(findHtmlKey('Contact Email', detailMap)), + contactName: contactName, + contactPhone: fixupPhoneFormat(findHtmlKey('Phone Extension', detailMap)), + description, + goodForIncomingStudents: Boolean(findHtmlKey('Appropriate for incoming/first-year students', detailMap)), + hoursPerWeek: findHtmlKey('Hours/week', detailMap), + howToApply, + id: id, + lastModified: findHtmlKey('Date Posted', detailMap), + links: links, + office: findHtmlKey('Office', detailMap), + openPositions: findHtmlKey('Number of Available Positions', detailMap), + skills, + timeline: cleanTextBlock(findHtmlKey('Hiring Timeline', detailMap)), + timeOfHours: findHtmlKey('Time of Hours', detailMap), + title, + type: findHtmlKey('Job Type', detailMap), + url: url.toString(), + year: findHtmlKey('Job Year', detailMap), + } +} + +async function fetchDetail(url: URL) { + const body = await GET_TWO_DAYS(url).text() + + /** + * run-scripts value is needed to properly evaluate javascript to display an email address. + * see the jsdom documentation for more details https://github.com/jsdom/jsdom#executing-scripts + */ + const dom = new JSDOM(body, { + contentType: 'text/html', + runScripts: 'dangerously', + }) + + /** + * Details is a node list of HTMLDivElement. It is a scoped version of the webpage containing + * all the text elements we need to parse (both keys and values) via `buildDetailMap`. + */ + const details = dom.window.document.querySelectorAll('div') + + /** A key-value Map for querying text elements from html data. */ + const detailMap = buildDetailMap(details, {paragraphs: PARAGRAPHICAL_KEYS}) + + return buildJobDetailResponse(url, dom, detailMap) +} + +/** Clean up carriage returns, newlines, tabs, and misc symbols, and search for application links in the text */ +export function getLinksFromJob(...texts: string[]): string[] { + return Array.from(new Set(texts.flatMap((text) => Array.from(getUrls(text))))) +} + +function fixupPhoneFormat(phoneNumber: string) { + return phoneNumber.length === 4 ? `507-786-${phoneNumber}` : phoneNumber +} + +function fixupEmailFormat(email: string) { + if (!email.includes('@')) { + // No @ in address ... e.g. smith + return `${email}@stolaf.edu` + } else if (email.endsWith('@')) { + // @ at end ... e.g. smith@ + return `${email}stolaf.edu` + } else { + // Defined address ... e.g. smith@stolaf.edu + return email + } +} + +/** + * The paginator is included within the nested html. We can see if we need to continue requesting + * more data by checking if the button dedicated to clicking next is present. + * + * While there are a few ways to go about parsing whether we've reached the last page, the + * paginator looks like it doesn't even know when it has reached the end of the results! Instead + * of trying to keep tracking of the amount of items we can opt to check the dom for the presence + * of the button. + */ +function nextPageExists(dom: JSDOM) { + return Boolean(dom.window.document.querySelector('.next.page-numbers')) +} + +/** + * The top-level results html provides a bunch of html with links to each posting. We can gather + * each link's href from these pages. + */ +export function findPageUrls(dom: JSDOM) { + return Array.from(dom.window.document.querySelectorAll('.gv-list-view > .gv-list-view-title > h3 > a')).flatMap( + (anchor) => { + let href = anchor.getAttribute('href') + return href ? [new URL(href)] : [] + }, + ) +} + +/** + * The coldfusion endpoint stopped providing a robust json api response and started serving + * a much rougher wp-json endpoint according to our logs on 09/27/2022. Implementation as of + * 10/16/2022 is designed around making a series of network requests that involve both json + * and html parsing to deliver the same set of props to match the client's api contract. + * + * In short, we are performing the following fetching and parsing steps: + * 1. [json] wp-json paginated requests to get page urls (1-3 requests for 25->50->75 entries) + * where we target html stored within a json field response + * 2. [html] detail page html requests, involves html parsing and returning json (easily 50+ requests) + * + * So we end up making multiple requests to the paginated wp-json endpoint to build a list of + * all job posting urls, and finally request each url we find to build our data. + */ +export async function getJobs(): Promise { + let allUrls: URL[] = [] + + /** + * The top-level wp-json endpoint which provides the list of job postings responds to a query + * parameter named `pagenum`. The paginator as of 10/16/2022 lists 25 entries at a time, so if + * we see 51 entries, we would expect to query this endpoint three times. + */ + let pageNumber = 1 + + let previousDom = undefined + + do { + let jobsUrl = getJobsUrl() + jobsUrl.searchParams.set('pagenum', pageNumber.toString()) + + const rendered = z + .object({content: z.object({rendered: z.string()})}) + // eslint-disable-next-line no-await-in-loop + .parse(await GET_ONE_DAY(jobsUrl).json()).content.rendered + + const dom = new JSDOM(rendered, {contentType: 'text/html'}) + previousDom = dom + pageNumber += 1 + + allUrls.push(...findPageUrls(dom)) + } while (nextPageExists(previousDom)) + + return pMap(allUrls, fetchDetail, {concurrency: 4}) +} diff --git a/source/student-orgs/presence.ts b/source/student-orgs/presence.ts index 681805e7..a5be184d 100644 --- a/source/student-orgs/presence.ts +++ b/source/student-orgs/presence.ts @@ -58,10 +58,13 @@ export function cleanOrg(org: DetailedPresenceOrgType, sortableRegex: RegExp) { }) } +export type StudentOrgResponseType = z.infer +export const StudentOrgResponseSchema = z.array(SortableStudentOrgSchema) + const fetchOrg = async (base: string, orgUri: string) => DetailedPresenceOrgSchema.parse(await get(`${base}/${orgUri}`).json()) -export async function presence(school: string): Promise { +export async function presence(school: string): Promise { let orgsUrl = `https://api.presence.io/${school}/v1/organizations` let body = BasicPresenceOrgSchema.array().parse(await http.get(orgsUrl).json()) From 6b6070593f3c3d1280ba048f99dc58b8f260c27d Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 13:53:33 -0400 Subject: [PATCH 19/24] re=import the stolaf half of the app --- source/ccc-server/server.ts | 14 +++++++------- source/student-orgs/presence.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 57752f26..1feab925 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -20,9 +20,7 @@ async function main() { const institutionResult = InstitutionSchema.safeParse(process.env['INSTITUTION']) if (institutionResult.error) { - console.error( - `the INSTITUTION environment variable must be one of ${InstitutionSchema.options.join(', ')}`, - ) + console.error(`the INSTITUTION environment variable must be one of ${InstitutionSchema.options.join(', ')}`) process.exit(1) } const institution = institutionResult.data @@ -38,13 +36,15 @@ async function main() { switch (institution) { case 'carleton-college': { - let v1 = (await import('../ccci-carleton-college/index.js')).v1 + let {v1} = await import('../ccci-carleton-college/index.js') + router.use(v1.routes()) + break + } + case 'stolaf-college': { + let {v1} = await import('../ccci-stolaf-college/index.js') router.use(v1.routes()) break } - // case 'stolaf-college': - // v1 = (await import('../ccci-stolaf-college/index.js')).v1 - // break } router.get({ diff --git a/source/student-orgs/presence.ts b/source/student-orgs/presence.ts index a5be184d..a8058d27 100644 --- a/source/student-orgs/presence.ts +++ b/source/student-orgs/presence.ts @@ -3,7 +3,7 @@ import {sortBy} from 'lodash-es' import {JSDOM} from 'jsdom' import pMap from 'p-map' import {z} from 'zod' -import {SortableStudentOrgSchema, type SortableStudentOrgType} from './types.js' +import {SortableStudentOrgSchema} from './types.js' const BasicPresenceOrgSchema = z.object({ subdomain: z.string(), From b58a332815696eaa21f12376818e810e2a2696cb Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 13:59:09 -0400 Subject: [PATCH 20/24] fix a few logical bugs --- source/ccc-frog-pond/building-hours.ts | 4 ++-- source/ccc-frog-pond/day-of-week.ts | 10 +--------- source/ccc-frog-pond/transit.ts | 2 +- source/stolaf-edu/a-to-z.ts | 7 ++++++- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/source/ccc-frog-pond/building-hours.ts b/source/ccc-frog-pond/building-hours.ts index 8f2f3fae..115b4f78 100644 --- a/source/ccc-frog-pond/building-hours.ts +++ b/source/ccc-frog-pond/building-hours.ts @@ -8,7 +8,7 @@ const LinkSchema = z.object({ }) const ScheduleBlockSchema = z.object({ - days: DayOfWeekSchema, + days: DayOfWeekSchema.array(), from: AmPmTimeSchema, to: AmPmTimeSchema, }) @@ -30,7 +30,7 @@ export const BuildingHoursSchema = z.object({ isNotice: z.boolean().optional(), noticeMessage: z.string().optional(), schedule: ScheduleSchema.array(), - links: LinkSchema.array(), + links: LinkSchema.array().optional(), }) export const BuildingHoursResponseSchema = z.object({ diff --git a/source/ccc-frog-pond/day-of-week.ts b/source/ccc-frog-pond/day-of-week.ts index 3975d1bd..f2c34492 100644 --- a/source/ccc-frog-pond/day-of-week.ts +++ b/source/ccc-frog-pond/day-of-week.ts @@ -1,11 +1,3 @@ import {z} from 'zod' -export const DayOfWeekSchema = z.union([ - z.literal('Mo'), - z.literal('Tu'), - z.literal('We'), - z.literal('Th'), - z.literal('Fr'), - z.literal('Sa'), - z.literal('Su'), -]) +export const DayOfWeekSchema = z.enum(['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']) diff --git a/source/ccc-frog-pond/transit.ts b/source/ccc-frog-pond/transit.ts index 504f4553..3e288e46 100644 --- a/source/ccc-frog-pond/transit.ts +++ b/source/ccc-frog-pond/transit.ts @@ -5,7 +5,7 @@ import {DayOfWeekSchema} from './day-of-week.js' const CoordinateSchema = z.tuple([z.number(), z.number()]) const BusScheduleSchema = z.object({ - days: DayOfWeekSchema, + days: DayOfWeekSchema.array(), coordinates: z.record(z.string(), CoordinateSchema), stops: z.array(z.string()), times: z.array(z.array(z.string())), diff --git a/source/stolaf-edu/a-to-z.ts b/source/stolaf-edu/a-to-z.ts index 97e2b894..d609a1e5 100644 --- a/source/stolaf-edu/a-to-z.ts +++ b/source/stolaf-edu/a-to-z.ts @@ -17,7 +17,12 @@ const StOlafAzResponseSchema = z.object({ export const AToZResponseSchema = z.array( z.object({ title: z.string(), - data: z.object({}).array(), + data: z + .object({ + url: z.string().url(), + label: z.string(), + }) + .array(), }), ) From 0d6a08e224b2fd25a323fc0e16a720d0f16f99d6 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 13:59:19 -0400 Subject: [PATCH 21/24] remove unused koa-ctx-cache-control typedef --- types/koa-ctx-cache-control.d.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 types/koa-ctx-cache-control.d.ts diff --git a/types/koa-ctx-cache-control.d.ts b/types/koa-ctx-cache-control.d.ts deleted file mode 100644 index 2673e5c0..00000000 --- a/types/koa-ctx-cache-control.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'koa-ctx-cache-control' { - import type Koa from 'koa' - export default function cacheControl(app: Koa) -} From d59460a542d4b0401089cd67027998d96cb47947 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 14:05:33 -0400 Subject: [PATCH 22/24] port stav mealtime reports to koa-zod-router --- source/ccci-stolaf-college/v1/index.ts | 2 +- source/ccci-stolaf-college/v1/majors.ts | 17 ------------ source/ccci-stolaf-college/v1/reports.ts | 33 ++++++++++++++---------- 3 files changed, 21 insertions(+), 31 deletions(-) delete mode 100644 source/ccci-stolaf-college/v1/majors.ts diff --git a/source/ccci-stolaf-college/v1/index.ts b/source/ccci-stolaf-college/v1/index.ts index a18ffb3a..42906c74 100644 --- a/source/ccci-stolaf-college/v1/index.ts +++ b/source/ccci-stolaf-college/v1/index.ts @@ -84,7 +84,7 @@ api.register(streams.getUpcomingRoute) api.register(printing.getColorPrintersRoute) // reports -api.get('/reports/stav', reports.stavMealtimeReport) +api.register(reports.getStavMealtimeReportRoute) // utilities api.register(util.htmlToMarkdownRoute) diff --git a/source/ccci-stolaf-college/v1/majors.ts b/source/ccci-stolaf-college/v1/majors.ts deleted file mode 100644 index 88f7bbee..00000000 --- a/source/ccci-stolaf-college/v1/majors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY} from '../../ccc-lib/constants.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' - -const GET = mem(get, {maxAge: ONE_DAY}) - -export function getMajors() { - let url = 'https://www.stolaf.edu/directory/majors' - return GET(url, {searchParams: {format: 'json'}}).json() -} - -export async function majors(ctx: Context) { - ctx.cacheControl(ONE_DAY) - - ctx.body = await getMajors() -} diff --git a/source/ccci-stolaf-college/v1/reports.ts b/source/ccci-stolaf-college/v1/reports.ts index 000f7b3d..d2ade5df 100644 --- a/source/ccci-stolaf-college/v1/reports.ts +++ b/source/ccci-stolaf-college/v1/reports.ts @@ -1,19 +1,26 @@ import {get} from '../../ccc-lib/http.js' -import {ONE_HOUR} from '../../ccc-lib/constants.js' import {GH_PAGES_FROM_REPO} from './gh-pages.js' -import mem from 'memoize' -import type {Context} from '../../ccc-server/context.js' +import {z} from 'zod' +import {createRouteSpec} from 'koa-zod-router' -const GET = mem(get, {maxAge: ONE_HOUR}) +const MealTimeSchema = z.array( + z.object({ + date: z.string().date(), + times: z.record(z.string().time(), z.number()), + }), +) -let stavMealtimesUrl = GH_PAGES_FROM_REPO('stav-mealtimes', 'two-weeks.json') - -export function getStavMealtimes() { - return GET(stavMealtimesUrl).json() +export async function getStavMealtimes() { + return MealTimeSchema.parse(await get(GH_PAGES_FROM_REPO('stav-mealtimes', 'two-weeks.json')).json()) } -export async function stavMealtimeReport(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getStavMealtimes() -} +export const getStavMealtimeReportRoute = createRouteSpec({ + method: 'get', + path: '/reports/stav', + validate: { + response: MealTimeSchema, + }, + handler: async (ctx) => { + ctx.body = await getStavMealtimes() + }, +}) From 1944a68dbe2a5fc3fefc231d6e695c5d71945d91 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 14:07:26 -0400 Subject: [PATCH 23/24] fix up reporting schema --- source/ccci-stolaf-college/v1/reports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/ccci-stolaf-college/v1/reports.ts b/source/ccci-stolaf-college/v1/reports.ts index d2ade5df..11226031 100644 --- a/source/ccci-stolaf-college/v1/reports.ts +++ b/source/ccci-stolaf-college/v1/reports.ts @@ -6,7 +6,7 @@ import {createRouteSpec} from 'koa-zod-router' const MealTimeSchema = z.array( z.object({ date: z.string().date(), - times: z.record(z.string().time(), z.number()), + times: z.record(z.string().regex(/^[0-2]\d:[0-5]\d$/), z.number()), }), ) From c569c5e00e3a8f4d1b94c6f378cbcddfb5c9d989 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Sat, 25 May 2024 20:04:06 -0400 Subject: [PATCH 24/24] prettier --- source/calendar/ical.ts | 4 +--- source/ccc-lib/html-to-markdown.ts | 6 +----- source/ccc-server/server.ts | 4 +++- .../us/minnesota/northfield/menu.test.ts | 6 +----- .../ccci-shared/us/minnesota/northfield/menu.ts | 6 +----- source/feeds/wp-json.ts | 12 +++--------- source/menus-bonapp/helpers.ts | 7 +------ source/menus-bonapp/index.ts | 16 +++++++--------- 8 files changed, 18 insertions(+), 43 deletions(-) diff --git a/source/calendar/ical.ts b/source/calendar/ical.ts index 71034122..253d06f6 100644 --- a/source/calendar/ical.ts +++ b/source/calendar/ical.ts @@ -35,9 +35,7 @@ export async function ical(url: string | URL, {onlyFuture = true} = {}, now = mo let body = await get(url, {headers: {accept: 'text/calendar'}}).text() let comp = InternetCalendar.Component.fromString(body) - let events = comp - .getAllSubcomponents('vevent') - .map((vevent) => new InternetCalendar.Event(vevent)) + let events = comp.getAllSubcomponents('vevent').map((vevent) => new InternetCalendar.Event(vevent)) if (onlyFuture) { events = events.filter((event) => moment(event.endDate.toString()).isAfter(now, 'day')) diff --git a/source/ccc-lib/html-to-markdown.ts b/source/ccc-lib/html-to-markdown.ts index ff088165..49bf1665 100644 --- a/source/ccc-lib/html-to-markdown.ts +++ b/source/ccc-lib/html-to-markdown.ts @@ -14,11 +14,7 @@ function turndown(content: string, {baseUrl = ''}: TurndownOptions = {}): string t.addRule('absolute-urls', { filter(node, options) { - return ( - options.linkStyle === 'inlined' && - node.nodeName === 'A' && - Boolean(node.getAttribute('href')) - ) + return options.linkStyle === 'inlined' && node.nodeName === 'A' && Boolean(node.getAttribute('href')) }, replacement(content, node) { diff --git a/source/ccc-server/server.ts b/source/ccc-server/server.ts index 1feab925..5e1e61f6 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -67,7 +67,9 @@ async function main() { router.get({ name: 'ping', path: '/ping', - validate: {response: z.string()}, + validate: { + response: z.string(), + }, handler: (ctx) => { ctx.body = 'pong' }, diff --git a/source/ccci-shared/us/minnesota/northfield/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts index e16b4e58..65bfa0a2 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.test.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -2,11 +2,7 @@ import baseTest, {type TestFn} from 'ava' import Koa from 'koa' import {listen} from 'async-listen' -import { - CafeInfoResponseSchema, - CafeMenuResponseSchema, - PauseMenuSchema, -} from '../../../../menus-bonapp/types.js' +import {CafeInfoResponseSchema, CafeMenuResponseSchema, PauseMenuSchema} from '../../../../menus-bonapp/types.js' import {keysOf} from '../../../../ccc-lib/keysOf.js' import * as menu from './menu.js' diff --git a/source/ccci-shared/us/minnesota/northfield/menu.ts b/source/ccci-shared/us/minnesota/northfield/menu.ts index d4d21b8e..e0ba5e60 100644 --- a/source/ccci-shared/us/minnesota/northfield/menu.ts +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -4,11 +4,7 @@ import {z} from 'zod' import {get} from '../../../../ccc-lib/http.js' import {ONE_DAY, ONE_HOUR} from '../../../../ccc-lib/constants.js' import * as bonapp from '../../../../menus-bonapp/index.js' -import { - CafeInfoResponseSchema, - CafeMenuResponseSchema, - PauseMenuSchema, -} from '../../../../menus-bonapp/types.js' +import {CafeInfoResponseSchema, CafeMenuResponseSchema, PauseMenuSchema} from '../../../../menus-bonapp/types.js' import {GH_PAGES} from '../../../../ccci-stolaf-college/v1/gh-pages.js' const pauseMenuUrl = GH_PAGES('pause-menu.json') diff --git a/source/feeds/wp-json.ts b/source/feeds/wp-json.ts index 174f0a11..a3ec01e1 100644 --- a/source/feeds/wp-json.ts +++ b/source/feeds/wp-json.ts @@ -38,10 +38,7 @@ const WpJsonFeedEntrySchema = z.object({ const WpJsonFeedResponseSchema = z.array(WpJsonFeedEntrySchema) -export async function fetchWpJson( - url: string | URL, - query: SearchParamsOption = {}, -): Promise { +export async function fetchWpJson(url: string | URL, query: SearchParamsOption = {}): Promise { const feed = WpJsonFeedResponseSchema.parse(await get(url, {searchParams: query}).json()) return feed.map(convertWpJsonItemToStory) } @@ -62,8 +59,7 @@ export function convertWpJsonItemToStory(item: WpJsonFeedEntryType) { if (featuredMediaInfo) { featuredImage = - featuredMediaInfo.media_details.sizes?.['medium_large']?.source_url ?? - featuredMediaInfo.source_url + featuredMediaInfo.media_details.sizes?.['medium_large']?.source_url ?? featuredMediaInfo.source_url } } @@ -72,9 +68,7 @@ export function convertWpJsonItemToStory(item: WpJsonFeedEntryType) { categories: categories, content: item.content.rendered, datePublished: moment( - item.date_gmt.endsWith('Z') || item.date_gmt.includes('+') - ? item.date_gmt - : `${item.date_gmt}Z`, + item.date_gmt.endsWith('Z') || item.date_gmt.includes('+') ? item.date_gmt : `${item.date_gmt}Z`, ).toISOString(), excerpt: JSDOM.fragment(item.excerpt.rendered).textContent?.trim() ?? '', featuredImage: featuredImage, diff --git a/source/menus-bonapp/helpers.ts b/source/menus-bonapp/helpers.ts index 33664f21..d8c1198f 100644 --- a/source/menus-bonapp/helpers.ts +++ b/source/menus-bonapp/helpers.ts @@ -1,9 +1,4 @@ -import { - CafeInfoResponseSchema, - CafeMenuDayPartSchema, - CafeMenuResponseSchema, - CafeMenuItemSchema, -} from './types.js' +import {CafeInfoResponseSchema, CafeMenuDayPartSchema, CafeMenuResponseSchema, CafeMenuItemSchema} from './types.js' export function CustomCafe(message: string) { let today = new Date() diff --git a/source/menus-bonapp/index.ts b/source/menus-bonapp/index.ts index 6baa7b00..627b73a2 100644 --- a/source/menus-bonapp/index.ts +++ b/source/menus-bonapp/index.ts @@ -51,15 +51,13 @@ export async function _cafe(cafeUrl: string | URL): Promise ({ - id, - label, - message, - starttime, - endtime, - }), - ), + dayparts: Object.values(bamco.dayparts).map(({id, label, message, starttime, endtime}) => ({ + id, + label, + message, + starttime, + endtime, + })), }, ], },