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/package-lock.json b/package-lock.json index faf9bd87..b31b3dae 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", @@ -17,16 +18,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,7 +34,10 @@ "normalize-url": "8.0.1", "p-map": "7.0.2", "turndown": "7.1.3", - "zod": "^3.23.8" + "type-fest": "^4.18.2", + "zod": "^3.23.8", + "zod-geojson": "^0.0.1", + "zod-validation-error": "^3.3.0" }, "devDependencies": { "@ava/typescript": "^5.0.0", @@ -53,17 +55,31 @@ "@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" } }, + "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", @@ -205,6 +221,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", @@ -1434,6 +1488,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", @@ -1507,6 +1567,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 +1634,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": "*" } @@ -1659,6 +1726,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", @@ -1738,6 +1811,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", @@ -2183,6 +2277,20 @@ "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-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", @@ -2689,6 +2797,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", @@ -2770,6 +2887,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", @@ -2934,6 +3057,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 +3649,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 +3998,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 +4570,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 +4678,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": { @@ -4779,6 +4905,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", @@ -5121,6 +5259,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", @@ -5765,6 +5911,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", @@ -6018,6 +6176,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", @@ -6082,6 +6274,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", @@ -6259,12 +6464,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" @@ -6675,6 +6879,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", @@ -6758,6 +6973,22 @@ "funding": { "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", + "integrity": "sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } } } diff --git a/package.json b/package.json index 30e5bb9a..84e5186e 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", @@ -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", @@ -47,16 +48,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,7 +64,10 @@ "normalize-url": "8.0.1", "p-map": "7.0.2", "turndown": "7.1.3", - "zod": "^3.23.8" + "type-fest": "^4.18.2", + "zod": "^3.23.8", + "zod-geojson": "^0.0.1", + "zod-validation-error": "^3.3.0" }, "devDependencies": { "@ava/typescript": "^5.0.0", @@ -83,12 +85,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/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/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 56% rename from source/ccci-carleton-college/v1/news/nnb.ts rename to source/carleton-edu/nnb.ts index 4b85720c..2f053300 100644 --- a/source/ccci-carleton-college/v1/news/nnb.ts +++ b/source/carleton-edu/nnb.ts @@ -1,8 +1,22 @@ -import {get} from '../../../ccc-lib/http.js' +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/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/ccc-frog-pond/building-hours.ts b/source/ccc-frog-pond/building-hours.ts new file mode 100644 index 00000000..115b4f78 --- /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.array(), + 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().optional(), +}) + +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/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/day-of-week.ts b/source/ccc-frog-pond/day-of-week.ts new file mode 100644 index 00000000..f2c34492 --- /dev/null +++ b/source/ccc-frog-pond/day-of-week.ts @@ -0,0 +1,3 @@ +import {z} from 'zod' + +export const DayOfWeekSchema = z.enum(['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']) 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/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..3e288e46 --- /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.array(), + 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/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 b8858680..5e1e61f6 100644 --- a/source/ccc-server/server.ts +++ b/source/ccc-server/server.ts @@ -3,13 +3,15 @@ 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 {extendZodWithOpenApi} from '@asteasolutions/zod-to-openapi' import {z} from 'zod' -import type {ContextState, RouterState} from './context.js' +import {errorMap} from 'zod-validation-error' + +extendZodWithOpenApi(z) +z.setErrorMap(errorMap) const InstitutionSchema = z.enum(['stolaf-college', 'carleton-college']) @@ -18,37 +20,59 @@ 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 - let v1: Router - 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() // // set up the routes // - const router = new Router() - router.use(v1.routes()) + const router = zodRouter({ + zodRouter: {exposeRequestErrors: true, exposeResponseErrors: true}, + }) - router.get('/', (ctx) => { - ctx.body = 'Hello world!' + switch (institution) { + case 'carleton-college': { + 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 + } + } + + 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' + }, }) // @@ -61,15 +85,11 @@ async function main() { app.use(conditional()) app.use(etag()) // support adding cache-control headers - cacheControl(app) - // parse request bodies - app.use(bodyParser()) + // cacheControl(app) // 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) // diff --git a/source/ccci-carleton-college/index.ts b/source/ccci-carleton-college/index.ts index 2356bcc9..5096f77e 100644 --- a/source/ccci-carleton-college/index.ts +++ b/source/ccci-carleton-college/index.ts @@ -1 +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..d26a6e66 100644 --- a/source/ccci-carleton-college/v1/calendar.ts +++ b/source/ccci-carleton-college/v1/calendar.ts @@ -1,81 +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 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 * from '../../ccci-shared/us/minnesota/northfield/calendar.js' diff --git a/source/ccci-carleton-college/v1/contacts.ts b/source/ccci-carleton-college/v1/contacts.ts index d5e99399..471c34e5 100644 --- a/source/ccci-carleton-college/v1/contacts.ts +++ b/source/ccci-carleton-college/v1/contacts.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 {ContactResponseSchema} from '../../ccc-frog-pond/contact.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -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_HOUR) - - 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-carleton-college/v1/convos.ts b/source/ccci-carleton-college/v1/convos.ts index 2db5d961..27afbf00 100644 --- a/source/ccci-carleton-college/v1/convos.ts +++ b/source/ccci-carleton-college/v1/convos.ts @@ -1,90 +1,40 @@ -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' - -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) - - 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 fetchUpcoming(eventId: string) { - 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 { - 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) -} - -export const getArchived = mem(fetchArchived, {maxAge: ONE_HOUR * 6}) - -export async function archived(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - ctx.body = await getArchived() -} +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' +import { + ConvocationEpisodeSchema, + fetchArchived, + fetchUpcomingDetail, + UpcomingConvocationEventSchema, +} from '../../carleton-edu/convocation.js' + +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: ConvocationEpisodeSchema.array()}, + 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/dictionary.ts b/source/ccci-carleton-college/v1/dictionary.ts index ffef3bad..5973570e 100644 --- a/source/ccci-carleton-college/v1/dictionary.ts +++ b/source/ccci-carleton-college/v1/dictionary.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 {DictionaryResponseSchema} from '../../ccc-frog-pond/dictionary.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -let url = GH_PAGES('dictionary-carls.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_HOUR) - - 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-carleton-college/v1/faqs.ts b/source/ccci-carleton-college/v1/faqs.ts index 4214c06d..377d0329 100644 --- a/source/ccci-carleton-college/v1/faqs.ts +++ b/source/ccci-carleton-college/v1/faqs.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 {FaqsSchema} from '../../ccc-frog-pond/faqs.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -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 c0386d6a..9c0f91c2 100644 --- a/source/ccci-carleton-college/v1/help.ts +++ b/source/ccci-carleton-college/v1/help.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 {HelpResponseSchema} from '../../ccc-frog-pond/help.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -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_HOUR) - - 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-carleton-college/v1/hours.ts b/source/ccci-carleton-college/v1/hours.ts index 9f723d23..2976de9b 100644 --- a/source/ccci-carleton-college/v1/hours.ts +++ b/source/ccci-carleton-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-carleton-college/v1/index.ts b/source/ccci-carleton-college/v1/index.ts index 3f871f11..de17c411 100644 --- a/source/ccci-carleton-college/v1/index.ts +++ b/source/ccci-carleton-college/v1/index.ts @@ -1,8 +1,9 @@ -import Router from 'koa-router' +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 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' @@ -14,115 +15,78 @@ 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' -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/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) +api.register(dictionary.getDictionaryRoute) // convos -api.get('/convos/upcoming', calendar.convos) -api.get('/convos/upcoming/:id', convos.upcomingDetail) -api.get('/convos/archived', convos.archived) - -// important contacts -api.get('/contacts', contacts.contacts) +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) +api.register(faqs.getFaqsRoute) // webcams -api.get('/webcams', webcams.webcams) +api.register(webcams.getWebcamsRoute) // jobs -api.get('/jobs', jobs.jobs) +api.register(jobs.getJobsRoute) // 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) +api.register(orgs.getStudentOrgsRoute) // 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) -api.get('/transit/modes', transit.modes) +api.register(transit.getBusTimesRoute) +api.register(transit.getTransitModesRoute) // utilities -api.get('/util/html-to-md', util.htmlToMarkdown) +api.register(util.htmlToMarkdownRoute) // sitemap 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-carleton-college/v1/jobs.ts b/source/ccci-carleton-college/v1/jobs.ts index c0dda468..56d951fc 100644 --- a/source/ccci-carleton-college/v1/jobs.ts +++ b/source/ccci-carleton-college/v1/jobs.ts @@ -1,79 +1,13 @@ -import {get} from '../../ccc-lib/http.js' -import {ONE_DAY, ONE_HOUR} 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' - -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) { - let id = link.searchParams.get('job_id') - assert(id) - - if (link.protocol === 'http:') { - link.protocol = 'https:' - } - - const body = await GET_TWO_DAYS(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: PARAGRAPHICAL_KEYS, boolean: BOOLEAN_KEYS}) - - const description = detailMap.get('Description') ?? '' - const links = Array.from(getUrls(description === true ? '' : description)) - - return { - id: id, - title: titleText, - offCampus: offCampus, - department: detailMap.get('Department or Office'), - dateOpen: detailMap.get('Date Open') ?? 'Unknown', - duringTerm: Boolean(detailMap.get('Position available during term')), - duringBreak: Boolean(detailMap.get('Position available during break')), - description: detailMap.get('Description') ?? '', - links: links, - } -} - -async function _getAllJobs() { - 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}) -} - -export const getJobs = mem(_getAllJobs, {maxAge: ONE_HOUR}) - -export async function jobs(ctx: Context) { - ctx.cacheControl(ONE_HOUR) - - ctx.body = await getJobs() -} +import {createRouteSpec} from 'koa-zod-router' +import {getAllJobs, StudentWorkResponseSchema} from '../../carleton-edu/student-work.js' + +export const getJobsRoute = createRouteSpec({ + method: 'get', + path: '/jobs', + validate: { + response: StudentWorkResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getAllJobs() + }, +}) 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/menu.test.ts b/source/ccci-carleton-college/v1/menu.test.ts deleted file mode 100644 index 1eb7c8c4..00000000 --- a/source/ccci-carleton-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, - kingsRoom: 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, - kingsRoom: 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-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-carleton-college/v1/news.ts b/source/ccci-carleton-college/v1/news.ts index f156f51f..9340242a 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 '../../carleton-edu/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/orgs.ts b/source/ccci-carleton-college/v1/orgs.ts index 38fbb2b5..810f9196 100644 --- a/source/ccci-carleton-college/v1/orgs.ts +++ b/source/ccci-carleton-college/v1/orgs.ts @@ -1,124 +1,13 @@ -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' - -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(), +import {createRouteSpec} from 'koa-zod-router' +import {getAllOrgs, SortableCarletonStudentOrgSchema} from '../../carleton-edu/student-org.js' + +export const getStudentOrgsRoute = createRouteSpec({ + method: 'get', + path: '/orgs', + validate: { + response: SortableCarletonStudentOrgSchema.array(), + }, + handler: async (ctx) => { + ctx.body = await getAllOrgs() + }, }) - -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 _getOrgs(): 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') -} - -export const getOrgs = mem(_getOrgs, {maxAge: ONE_HOUR * 6}) - -export async function orgs(ctx: Context) { - ctx.cacheControl(ONE_HOUR * 6) - - ctx.body = await getOrgs() -} 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/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) + }, +}) diff --git a/source/ccci-carleton-college/v1/webcams.ts b/source/ccci-carleton-college/v1/webcams.ts index 0215505e..536b7b71 100644 --- a/source/ccci-carleton-college/v1/webcams.ts +++ b/source/ccci-carleton-college/v1/webcams.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 {WebcamResponseSchema} from '../../ccc-frog-pond/webcams.js' -const GET = mem(get, {maxAge: ONE_HOUR}) - -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_HOUR) - - ctx.body = await getWebcams() -} +export const getWebcamsRoute = createRouteSpec({ + method: 'get', + path: '/webcams', + validate: {response: WebcamResponseSchema}, + handler: async (ctx) => { + ctx.body = await getWebcams() + }, +}) 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-shared/us/minnesota/northfield/menu.test.ts b/source/ccci-shared/us/minnesota/northfield/menu.test.ts new file mode 100644 index 00000000..65bfa0a2 --- /dev/null +++ b/source/ccci-shared/us/minnesota/northfield/menu.test.ts @@ -0,0 +1,101 @@ +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 {keysOf} from '../../../../ccc-lib/keysOf.js' + +import * as menu from './menu.js' +import zodRouter from 'koa-zod-router' +import * as http from 'node:http' +import ky from 'ky' + +const test = baseTest as TestFn<{server: http.Server; prefixUrl: URL}> + +test.before(async (t) => { + let app = 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) + + app.use(router.routes()) + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + 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 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 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 new file mode 100644 index 00000000..e0ba5e60 --- /dev/null +++ b/source/ccci-shared/us/minnesota/northfield/menu.ts @@ -0,0 +1,133 @@ +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, 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 () => 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 BamcoCafeSlugs = z.infer +export const BamcoCafeSlugs = 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 AllKnownCafeSlugs = BamcoCafeSlugs.or(z.literal('the-pause')) + +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 "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 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 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: AllKnownCafeSlugs}), + response: CafeMenuResponseSchema.or(PauseMenuSchema), + }, + handler: async (ctx) => { + if (ctx.request.params.cafeName === 'the-pause') { + ctx.body = await getPauseMenu() + } else { + ctx.body = await getMenu(BamcoSlugToUrl[ctx.request.params.cafeName]) + } + }, +}) + +export const getNamedCafeRoute = createRouteSpec({ + method: 'get', + path: '/food/named/cafe/:cafeName', + validate: { + params: z.object({cafeName: BamcoCafeSlugs}), + response: CafeInfoResponseSchema, + }, + handler: async (ctx) => { + ctx.body = await getInfo(BamcoSlugToUrl[ctx.request.params.cafeName]) + }, +}) + +export const getBonAppMenuRoute = createRouteSpec({ + method: 'get', + path: '/food/menu/:cafeId', + validate: { + params: z.object({cafeId: KnownCafeIdEnum}), + }, + handler: async (ctx) => { + let cafeName = BamcoIdToSlug[ctx.request.params.cafeId] + ctx.body = await getMenu(BamcoSlugToUrl[cafeName]) + }, +}) + +export const getBonAppCafeRoute = createRouteSpec({ + method: 'get', + path: '/food/cafe/:cafeId', + validate: { + params: z.object({cafeId: KnownCafeIdEnum}), + }, + handler: async (ctx) => { + let cafeName = BamcoIdToSlug[ctx.request.params.cafeId] + ctx.body = await getInfo(BamcoSlugToUrl[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) + }, +}) diff --git a/source/ccci-stolaf-college/index.ts b/source/ccci-stolaf-college/index.ts index 2356bcc9..5096f77e 100644 --- a/source/ccci-stolaf-college/index.ts +++ b/source/ccci-stolaf-college/index.ts @@ -1 +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-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/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/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/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/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/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..42906c74 100644 --- a/source/ccci-stolaf-college/v1/index.ts +++ b/source/ccci-stolaf-college/v1/index.ts @@ -1,14 +1,14 @@ -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' -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' @@ -18,121 +18,85 @@ 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) +api.register(atoz.getAToZRoute) // dictionary -api.get('/dictionary', dictionary.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.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) +api.register(jobs.getJobsRoute) // orgs -api.get('/orgs', orgs.orgs) +api.register(orgs.getStudentOrgsRoute) // 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) -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) +api.register(reports.getStavMealtimeReportRoute) // 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/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/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/menu.test.ts b/source/ccci-stolaf-college/v1/menu.test.ts deleted file mode 100644 index 1eb7c8c4..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, - kingsRoom: 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, - kingsRoom: 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 db817f28..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/', - 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-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/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/reports.ts b/source/ccci-stolaf-college/v1/reports.ts index 000f7b3d..11226031 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().regex(/^[0-2]\d:[0-5]\d$/), 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() + }, +}) 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/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() + }, +}) 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, + })), }, ], }, 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), + }), +}) diff --git a/source/stolaf-edu/a-to-z.ts b/source/stolaf-edu/a-to-z.ts new file mode 100644 index 00000000..d609a1e5 --- /dev/null +++ b/source/stolaf-edu/a-to-z.ts @@ -0,0 +1,60 @@ +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({ + url: z.string().url(), + label: z.string(), + }) + .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..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(), @@ -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()) 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) -}