From d45202770e9225af9610416cea4cb120000f61b7 Mon Sep 17 00:00:00 2001 From: Love Billenius Date: Thu, 9 Apr 2026 15:09:43 +0200 Subject: [PATCH] add market and odds endpoints --- backend/src/app_state.rs | 13 +++ backend/src/lib.rs | 1 + backend/src/markets/mod.rs | 150 ++++++++++++++++++++++++++++++++++ backend/src/routes/markets.rs | 24 ++++++ backend/src/routes/mod.rs | 3 + backend/tests/api_smoke.rs | 53 ++++++++++++ 6 files changed, 244 insertions(+) create mode 100644 backend/src/markets/mod.rs create mode 100644 backend/src/routes/markets.rs diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 0afb043..85adf43 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -5,12 +5,15 @@ use sqlx::PgPool; use uuid::Uuid; pub use crate::events::{EventManifestSnapshot, EventSnapshot}; +pub use crate::events::{MarketSnapshot, OutcomeSnapshot}; +pub use crate::markets::{OddsVersionSnapshot, OutcomeOddsSnapshot}; pub use crate::sessions::{SessionSnapshot, SessionStartRequest}; use crate::{ config::AppConfig, error::AppError, events::{EventsStore}, + markets::MarketsStore, sessions::{SessionDefaults, SessionsStore}, users::{UserCreateRequest, UsersStore}, }; @@ -22,6 +25,7 @@ pub struct AppState { pub database_pool: Option, pub redis_client: Option, events: EventsStore, + markets: MarketsStore, users: UsersStore, sessions: SessionsStore, } @@ -38,6 +42,7 @@ impl AppState { database_pool, redis_client, events: EventsStore::new(), + markets: MarketsStore::new(), users: UsersStore::new(), sessions: SessionsStore::new(), } @@ -96,6 +101,14 @@ impl AppState { self.events.get_manifest(event_id).await } + pub async fn markets_for_event(&self, event_id: Uuid) -> Option> { + self.markets.markets_for_event(event_id).await + } + + pub async fn current_odds(&self, market_id: Uuid) -> Option { + self.markets.current_odds(market_id).await + } + pub fn database_ready(&self) -> bool { self.database_pool.is_some() } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 1699c08..a157cfb 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod db; pub mod events; pub mod error; +pub mod markets; pub mod sessions; pub mod routes; pub mod users; diff --git a/backend/src/markets/mod.rs b/backend/src/markets/mod.rs new file mode 100644 index 0000000..c1794bd --- /dev/null +++ b/backend/src/markets/mod.rs @@ -0,0 +1,150 @@ +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::events::{MarketSnapshot, OutcomeSnapshot}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OutcomeOddsSnapshot { + pub id: Uuid, + pub odds_version_id: Uuid, + pub outcome_id: Uuid, + pub decimal_odds: f64, + pub fractional_num: i32, + pub fractional_den: i32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OddsVersionSnapshot { + pub id: Uuid, + pub market_id: Uuid, + pub version_no: i32, + pub created_at: DateTime, + pub is_current: bool, + pub odds: Vec, +} + +#[derive(Clone, Default)] +pub struct MarketsStore { + inner: Arc>, +} + +#[derive(Default)] +struct MarketsState { + markets_by_event_id: HashMap>, + markets_by_id: HashMap, + current_odds_by_market_id: HashMap, +} + +impl MarketsStore { + pub fn new() -> Self { + Self::with_sample_data() + } + + pub fn with_sample_data() -> Self { + let event_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("valid uuid"); + let market_id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("valid uuid"); + let odds_version_id = Uuid::parse_str("66666666-6666-6666-6666-666666666666").expect("valid uuid"); + let preview_lock_at = Utc::now() + ChronoDuration::minutes(10); + let created_at = Utc::now(); + + let home_outcome_id = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("valid uuid"); + let away_outcome_id = Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("valid uuid"); + + let market = MarketSnapshot { + id: market_id, + event_id, + question_key: "market.sample.winner".to_string(), + market_type: "winner".to_string(), + status: "open".to_string(), + lock_at: preview_lock_at, + settlement_rule_key: "settle_on_match_winner".to_string(), + outcomes: vec![ + OutcomeSnapshot { + id: home_outcome_id, + market_id, + outcome_code: "home".to_string(), + label_key: "outcome.home".to_string(), + sort_order: 1, + }, + OutcomeSnapshot { + id: away_outcome_id, + market_id, + outcome_code: "away".to_string(), + label_key: "outcome.away".to_string(), + sort_order: 2, + }, + ], + }; + + let odds_version = OddsVersionSnapshot { + id: odds_version_id, + market_id, + version_no: 1, + created_at, + is_current: true, + odds: vec![ + OutcomeOddsSnapshot { + id: Uuid::parse_str("77777777-7777-7777-7777-777777777777").expect("valid uuid"), + odds_version_id, + outcome_id: home_outcome_id, + decimal_odds: 1.85, + fractional_num: 17, + fractional_den: 20, + }, + OutcomeOddsSnapshot { + id: Uuid::parse_str("88888888-8888-8888-8888-888888888888").expect("valid uuid"), + odds_version_id, + outcome_id: away_outcome_id, + decimal_odds: 2.05, + fractional_num: 21, + fractional_den: 20, + }, + ], + }; + + let mut markets_by_event_id = HashMap::new(); + markets_by_event_id.insert(event_id, vec![market.clone()]); + + let mut markets_by_id = HashMap::new(); + markets_by_id.insert(market_id, market); + + let mut current_odds_by_market_id = HashMap::new(); + current_odds_by_market_id.insert(market_id, odds_version); + + Self { + inner: Arc::new(RwLock::new(MarketsState { + markets_by_event_id, + markets_by_id, + current_odds_by_market_id, + })), + } + } + + pub async fn markets_for_event(&self, event_id: Uuid) -> Option> { + let state = self.inner.read().await; + let Some(markets) = state.markets_by_event_id.get(&event_id) else { + return None; + }; + Some(markets.clone()) + } + + pub async fn market(&self, market_id: Uuid) -> Option { + let state = self.inner.read().await; + let Some(market) = state.markets_by_id.get(&market_id) else { + return None; + }; + Some(market.clone()) + } + + pub async fn current_odds(&self, market_id: Uuid) -> Option { + let state = self.inner.read().await; + let Some(odds) = state.current_odds_by_market_id.get(&market_id) else { + return None; + }; + Some(odds.clone()) + } +} diff --git a/backend/src/routes/markets.rs b/backend/src/routes/markets.rs new file mode 100644 index 0000000..4cffdad --- /dev/null +++ b/backend/src/routes/markets.rs @@ -0,0 +1,24 @@ +use axum::{extract::{Extension, Path}, Json}; +use uuid::Uuid; + +use crate::{app_state::{AppState, MarketSnapshot, OddsVersionSnapshot}, error::AppError}; + +pub async fn list_for_event( + Path(event_id): Path, + Extension(state): Extension, +) -> Result>, AppError> { + let Some(markets) = state.markets_for_event(event_id).await else { + return Err(AppError::not_found("Markets not found")); + }; + Ok(Json(markets)) +} + +pub async fn current_odds( + Path(market_id): Path, + Extension(state): Extension, +) -> Result, AppError> { + let Some(odds) = state.current_odds(market_id).await else { + return Err(AppError::not_found("Current odds not found")); + }; + Ok(Json(odds)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 1213595..c499e69 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod health; pub mod events; +pub mod markets; pub mod session; use axum::{routing::{get, post}, Router}; @@ -10,6 +11,8 @@ pub fn router() -> Router { .route("/api/v1/feed/next", get(events::next)) .route("/api/v1/events/{event_id}", get(events::show)) .route("/api/v1/events/{event_id}/manifest", get(events::manifest)) + .route("/api/v1/events/{event_id}/markets", get(markets::list_for_event)) + .route("/api/v1/markets/{market_id}/odds/current", get(markets::current_odds)) .route("/api/v1/session/start", post(session::start)) .route("/api/v1/session/end", post(session::end)) .route("/api/v1/session/me", get(session::me)) diff --git a/backend/tests/api_smoke.rs b/backend/tests/api_smoke.rs index 2646989..eef8d07 100644 --- a/backend/tests/api_smoke.rs +++ b/backend/tests/api_smoke.rs @@ -129,3 +129,56 @@ async fn feed_next_returns_a_manifestable_event() { assert!(manifest["media"].as_array().unwrap().len() >= 1); assert!(manifest["markets"].as_array().unwrap().len() >= 1); } + +#[tokio::test] +async fn event_markets_and_current_odds_work() { + let app = build_router(AppState::new(AppConfig::default(), None, None)); + + let event_response = app + .clone() + .oneshot(Request::builder().uri("/api/v1/feed/next").body(Body::empty()).unwrap()) + .await + .unwrap(); + + let event_body = to_bytes(event_response.into_body(), usize::MAX).await.unwrap(); + let event: json::Value = json::from_slice(&event_body).unwrap(); + let event_id = event["id"].as_str().unwrap().to_string(); + + let markets_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/v1/events/{event_id}/markets")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(markets_response.status(), StatusCode::OK); + + let markets_body = to_bytes(markets_response.into_body(), usize::MAX).await.unwrap(); + let markets: json::Value = json::from_slice(&markets_body).unwrap(); + let market_id = markets[0]["id"].as_str().unwrap().to_string(); + + assert_eq!(markets[0]["outcomes"].as_array().unwrap().len(), 2); + + let odds_response = app + .oneshot( + Request::builder() + .uri(format!("/api/v1/markets/{market_id}/odds/current")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(odds_response.status(), StatusCode::OK); + + let odds_body = to_bytes(odds_response.into_body(), usize::MAX).await.unwrap(); + let odds: json::Value = json::from_slice(&odds_body).unwrap(); + + assert_eq!(odds["market_id"], market_id); + assert_eq!(odds["is_current"], true); + assert_eq!(odds["odds"].as_array().unwrap().len(), 2); +}