diff --git a/.config/mise/config.toml b/.config/mise/config.toml new file mode 100644 index 0000000..13af203 --- /dev/null +++ b/.config/mise/config.toml @@ -0,0 +1,29 @@ +[env] +_.file = '.env' + +[tasks.build] +run = "cargo build" + +[tasks.check] +depends = ["clippy", "format-check", "test"] + +[tasks.clippy] +run = "cargo clippy --workspace --all-features --all-targets" + +[tasks.diff] +run = "git diff --exit-code -- types/bindings/index.d.ts" + +[tasks.format] +run = "cargo fmt --all" + +[tasks.format-check] +run = "cargo fmt --all -- --check" + +[tasks.start] +run = "cargo run --bin ccc-server" + +[tasks.test] +run = "cargo test --workspace --all-features --all-targets --no-fail-fast" + +[tasks.e2e] +run = "cargo run --bin e2e" diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..0cb62f0 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +BON_APPETIT_AUTH= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ecb9cd6..738fbd7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,15 +18,16 @@ jobs: run: | rustup toolchain install ${{ matrix.rust }} rustup default ${{ matrix.rust }} - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2 - name: Run rustfmt run: | rustup component add rustfmt - cargo fmt --all -- --check + mise run format-check - name: Run clippy run: | rustup component add clippy - cargo clippy --workspace --all-features --all-targets + mise run clippy test: name: Test the code runs-on: ubuntu-latest @@ -39,10 +40,11 @@ jobs: run: | rustup toolchain install ${{ matrix.rust }} rustup default ${{ matrix.rust }} - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: jdx/mise-action@5083fe46898c414b2475087cc79da59e7da859e8 # v2 - name: Run cargo test run: | - cargo test --workspace --all-features --all-targets --no-fail-fast + mise run test - name: Ensure index.d.ts is up-to-date after tests were run run: | - git diff --exit-code -- types/bindings/index.d.ts + mise run diff diff --git a/.gitignore b/.gitignore index ea8c4bf..56c87b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +.env +.DS_STORE diff --git a/Cargo.lock b/Cargo.lock index bf6ee1f..795568f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -32,6 +32,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -227,6 +242,7 @@ dependencies = [ "axum-macros", "ccc-proxy", "ccc-types", + "chrono", "http", "phf", "reqwest", @@ -291,6 +307,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "clap" version = "4.5.26" @@ -643,6 +674,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -924,6 +978,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -1942,6 +2005,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-registry" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 4d8b258..ec57dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ ccc-handlers = { path = "./ccc-handlers" } ccc-proxy = { path = "./ccc-proxy" } ccc-routes = { path = "./ccc-routes" } ccc-types = { path = "./ccc-types" } +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5.3", features = ["derive"] } http = "1.1.0" phf = { version = "0.11.2", features = ["macros"] } diff --git a/README.md b/README.md index ad43fef..3cdc72f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,24 @@ # ccc-server-next next generation of cautious-computing-context +## Environment + +Config is managed with [mise](https://mise.jdx.dev) + +Install + +```sh +brew install mise +echo 'eval "$(mise activate zsh)"' >> ~/.zshrc +source ~/.zshrc +mise trust +``` + +Build and run +```sh +mise use -g rust +mise run build +mise run start +``` + +**`Bonappetit`**: You'll need to have authorization setup to get bonappetit requests working. Copy `.env.sample` to `.env` and set the token to get this working. diff --git a/ccc-handlers/Cargo.toml b/ccc-handlers/Cargo.toml index 040987d..6c66783 100644 --- a/ccc-handlers/Cargo.toml +++ b/ccc-handlers/Cargo.toml @@ -17,3 +17,4 @@ thiserror = { workspace = true } tracing = { workspace = true } serde_urlencoded = { workspace = true } phf = { workspace = true } +chrono = { workspace = true } diff --git a/ccc-handlers/src/bonapp.rs b/ccc-handlers/src/bonapp.rs index 53a834e..43f70b1 100644 --- a/ccc-handlers/src/bonapp.rs +++ b/ccc-handlers/src/bonapp.rs @@ -23,10 +23,15 @@ where let (base_url, entity) = get_query_base_url_and_entity(&query_type); + let bon_app_auth = + std::env::var("BON_APPETIT_AUTH").expect("BON_APPETIT_AUTH credential not set"); + let auth_header_value = format!("Basic {}", bon_app_auth); + let request = ccc_proxy::global_proxy() .client() .request(Method::GET, base_url) .query(&[(entity, entity_id)]) + .header("Authorization", auth_header_value) .build() .map_err(ProxyError::ProxiedRequest) .map_err(BonAppProxyError::GenericProxy)?; @@ -70,9 +75,18 @@ use QueryType::*; #[inline] const fn get_query_base_url_and_entity(query_type: &QueryType) -> (&str, &str) { match query_type { - Cafe => ("https://legacy.cafebonappetit.com/api/2/cafes", "cafe"), - Menu => ("https://legacy.cafebonappetit.com/api/2/menus", "cafe"), - ItemNutrition => ("https://legacy.cafebonappetit.com/api/2/items", "item"), + Cafe => ( + "https://cafemanager-api.cafebonappetit.com/api/2/cafes", + "cafe", + ), + Menu => ( + "https://cafemanager-api.cafebonappetit.com/api/2/menus", + "cafe", + ), + ItemNutrition => ( + "https://cafemanager-api.cafebonappetit.com/api/2/items", + "item", + ), } } diff --git a/ccc-handlers/src/lib.rs b/ccc-handlers/src/lib.rs index ba7a234..1d9b2bf 100644 --- a/ccc-handlers/src/lib.rs +++ b/ccc-handlers/src/lib.rs @@ -3,3 +3,4 @@ pub mod bonapp; pub mod github; +pub mod streams; diff --git a/ccc-handlers/src/streams.rs b/ccc-handlers/src/streams.rs new file mode 100644 index 0000000..76282e2 --- /dev/null +++ b/ccc-handlers/src/streams.rs @@ -0,0 +1,154 @@ +use axum::{ + extract::Query, + response::{IntoResponse, Json, Response}, +}; +use chrono::{Duration, Utc}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use tracing::instrument; + +#[derive(Debug, Deserialize)] +pub struct StreamParams { + #[serde(default)] + date_from: String, + #[serde(default)] + date_to: String, + #[serde(default = "default_sort")] + sort: String, +} + +fn default_sort() -> String { + "ascending".to_string() +} + +#[derive(Debug, Serialize)] +enum QueryClass { + Archived, + Upcoming, +} + +impl std::fmt::Display for QueryClass { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + QueryClass::Archived => write!(f, "archived"), + QueryClass::Upcoming => write!(f, "current"), + } + } +} + +#[inline] +const fn get_query_base_url_and_entity(query_class: &QueryClass) -> (&str, &str) { + match query_class { + QueryClass::Archived => ( + "https://www.stolaf.edu/multimedia/api/collection", + "archived", + ), + QueryClass::Upcoming => ( + "https://www.stolaf.edu/multimedia/api/collection", + "current", + ), + } +} + +#[instrument] +async fn send_proxied_query( + query_class: QueryClass, + date_from: &str, + date_to: &str, + sort: &str, +) -> Result, StreamProxyError> +where + T: serde::de::DeserializeOwned, +{ + tracing::debug!( + sort, + date_to, + date_from, + ?query_class, + "handling proxied Stream request" + ); + + let (base_url, _class) = get_query_base_url_and_entity(&query_class); + let query_type = query_class.to_string(); + + let request = ccc_proxy::global_proxy() + .client() + .get(base_url) + .query(&[ + ("class", query_type.as_str()), + ("date_from", date_from), + ("date_to", date_to), + ("sort", sort), + ]) + .build() + .map_err(ccc_proxy::ProxyError::ProxiedRequest) + .map_err(StreamProxyError::GenericProxy)?; + + ccc_proxy::global_proxy() + .send_request_parse_json::(request) + .await + .map(Json) + .map_err(StreamProxyError::GenericProxy) +} + +#[instrument(skip(date_from_fn, date_to_fn))] +async fn handle_stream_request( + params: StreamParams, + date_from_fn: impl Fn(chrono::DateTime) -> chrono::DateTime, + date_to_fn: impl Fn(chrono::DateTime) -> chrono::DateTime, + query_class: QueryClass, +) -> Result, StreamProxyError> { + let now = Utc::now(); + + let date_from = (!params.date_from.is_empty()) + .then(|| params.date_from.clone()) + .unwrap_or_else(|| date_from_fn(now).format("%Y-%m-%d").to_string()); + + let date_to = (!params.date_to.is_empty()) + .then(|| params.date_to.clone()) + .unwrap_or_else(|| date_to_fn(now).format("%Y-%m-%d").to_string()); + + send_proxied_query(query_class, &date_from, &date_to, ¶ms.sort).await +} + +#[instrument] +pub async fn upcoming_handler( + Query(params): Query, +) -> Result, StreamProxyError> { + handle_stream_request( + params, + |now| now, + |now| now + Duration::days(60), + QueryClass::Upcoming, + ) + .await +} + +#[instrument] +pub async fn archived_handler( + Query(params): Query, +) -> Result, StreamProxyError> { + handle_stream_request( + params, + |now| now - Duration::days(60), + |now| now, + QueryClass::Archived, + ) + .await +} + +#[derive(thiserror::Error, Debug)] +pub enum StreamProxyError { + #[error("error from generic proxy: {0}")] + GenericProxy(ccc_proxy::ProxyError), +} + +impl IntoResponse for StreamProxyError { + fn into_response(self) -> Response { + let text = self.to_string(); + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(text.into()) + .unwrap() + } +} diff --git a/ccc-routes/src/food.rs b/ccc-routes/src/food.rs index ec02c59..90e5127 100644 --- a/ccc-routes/src/food.rs +++ b/ccc-routes/src/food.rs @@ -8,10 +8,10 @@ use ccc_handlers::{ pub fn router() -> Router { Router::new() - .route("/menu/:cafe_id", get(cafe_menu_handler)) - .route("/cafe/:cafe_id", get(cafe_handler)) - .route("/item/:item_id", get(nutrition_handler)) - .route("/named/cafe/:name", get(named_cafe_handler)) - .route("/named/menu/:name", get(named_cafe_menu_handler)) + .route("/menu/{cafe_id}", get(cafe_menu_handler)) + .route("/cafe/{cafe_id}", get(cafe_handler)) + .route("/item/{item_id}", get(nutrition_handler)) + .route("/named/cafe/{name}", get(named_cafe_handler)) .route("/named/menu/the-pause", get(pause_menu_handler)) + .route("/named/menu/{name}", get(named_cafe_menu_handler)) } diff --git a/ccc-routes/src/lib.rs b/ccc-routes/src/lib.rs index f109002..42e8b3b 100644 --- a/ccc-routes/src/lib.rs +++ b/ccc-routes/src/lib.rs @@ -7,6 +7,7 @@ pub mod faqs; pub mod food; pub mod printing; pub mod spaces; +pub mod streams; pub mod tools; pub mod transit; pub mod webcams; diff --git a/ccc-routes/src/streams.rs b/ccc-routes/src/streams.rs new file mode 100644 index 0000000..057a615 --- /dev/null +++ b/ccc-routes/src/streams.rs @@ -0,0 +1,8 @@ +use axum::{routing::get, Router}; +use ccc_handlers::streams::{archived_handler, upcoming_handler}; + +pub fn router() -> Router { + Router::new() + .route("/archived", get(archived_handler)) + .route("/upcoming", get(upcoming_handler)) +} diff --git a/ccc-server/src/main.rs b/ccc-server/src/main.rs index 4767d56..9fe1505 100644 --- a/ccc-server/src/main.rs +++ b/ccc-server/src/main.rs @@ -47,12 +47,13 @@ fn init_router() -> Router { .nest("/food", ccc_routes::food::router()) .nest("/printing", ccc_routes::printing::router()) .nest("/spaces", ccc_routes::spaces::router()) + .nest("/streams", ccc_routes::streams::router()) .nest("/tools", ccc_routes::tools::router()) .nest("/transit", ccc_routes::transit::router()) .nest("/webcams", ccc_routes::webcams::router()); Router::new() - .nest("/", meta_routes) + .merge(meta_routes) .nest("/api", api_routes) .layer(middleware_stack) .fallback(fallback) diff --git a/ccc-types/bindings/index.d.ts b/ccc-types/bindings/index.d.ts index 3f60048..059a2fd 100644 --- a/ccc-types/bindings/index.d.ts +++ b/ccc-types/bindings/index.d.ts @@ -249,6 +249,26 @@ export interface Schedule { isPhysicallyOpen: boolean | null; } +export type Stream = { + starttime: string; + location: string; + eid: string; + performer: string; + subtitle: string; + poster: string; + player: string; + status: string; + category: string; + hptitle: string; + category_textcolor: string | null; + category_color: string | null; + thumb: string; + title: string; + iframesrc: string; +}; + +export type StreamResponse = { results: Array }; + export interface TransitColors { bar: string; dot: string; diff --git a/ccc-types/src/lib.rs b/ccc-types/src/lib.rs index f109002..42e8b3b 100644 --- a/ccc-types/src/lib.rs +++ b/ccc-types/src/lib.rs @@ -7,6 +7,7 @@ pub mod faqs; pub mod food; pub mod printing; pub mod spaces; +pub mod streams; pub mod tools; pub mod transit; pub mod webcams; diff --git a/ccc-types/src/streams.rs b/ccc-types/src/streams.rs new file mode 100644 index 0000000..dfbb7d9 --- /dev/null +++ b/ccc-types/src/streams.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, rename = "Stream")] +pub struct StreamEntry { + pub starttime: String, + pub location: String, + pub eid: String, + pub performer: String, + pub subtitle: String, + pub poster: String, + pub player: String, + pub status: String, + pub category: String, + pub hptitle: String, + // issues with (de)serde/renaming this field + #[serde(rename = "category_textcolor")] + pub category_textcolor: Option, + // issues with (de)serde/renaming this field + #[serde(rename = "category_color")] + pub category_color: Option, + pub thumb: String, + pub title: String, + pub iframesrc: String, +} + +#[derive(Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct StreamResponse { + pub results: Vec, +} diff --git a/renovate.json5 b/renovate.json5 index 0f06ff8..48090b9 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -1,12 +1,9 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base", - // Semantic commits add useless noise; turn them off explicitly. - ":semanticCommitsDisabled", - // Update lockfile versions weekly - ":maintainLockFilesWeekly", - // Pin github-actions with a digest to avoid surprises when they update - "helpers:pinGitHubActionDigests" - ] + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: [ + 'config:recommended', + ':semanticCommitsDisabled', + ':maintainLockFilesWeekly', + 'helpers:pinGitHubActionDigests', + ], }