Skip to main content

wc_data/backends/
football_data.rs

1//! football-data.org backend.
2//!
3//! Auth: header `X-Auth-Token: <key>`. Base `https://api.football-data.org/v4`.
4//! World Cup competition code `WC`. Endpoints: `/competitions/WC/matches`,
5//! `/competitions/WC/standings`. The free tier has a low rate limit and limited
6//! live-event granularity (no minute-by-minute timeline).
7
8use serde::Deserialize;
9use time::{Date, Month, OffsetDateTime, Time};
10
11use crate::backends::common::{day_bounds, f64_to_i16, parse_time};
12use crate::domain::{
13    Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchStatus,
14    Score, Stage, StageWindow, Team,
15};
16use crate::error::Result;
17use crate::provider::ScoreProvider;
18use crate::transport::Http;
19
20const BASE_URL: &str = "https://api.football-data.org/v4";
21
22/// football-data.org-backed provider.
23#[derive(Debug, Clone)]
24pub struct FootballDataProvider {
25    http: Http,
26    key: String,
27}
28
29impl FootballDataProvider {
30    /// Build the provider over a shared HTTP client with the given API token.
31    #[must_use]
32    pub fn new(http: Http, key: String) -> Self {
33        Self { http, key }
34    }
35
36    fn headers(&self) -> [(&str, &str); 1] {
37        [("X-Auth-Token", self.key.as_str())]
38    }
39
40    async fn fetch<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
41        self.http
42            .get_json(&format!("{BASE_URL}{path}"), &self.headers())
43            .await
44    }
45}
46
47impl ScoreProvider for FootballDataProvider {
48    fn name(&self) -> &'static str {
49        "football-data.org"
50    }
51
52    async fn calendar(&self) -> Result<Calendar> {
53        Ok(static_calendar())
54    }
55
56    async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
57        let dto: FdMatches = self.fetch("/competitions/WC/matches").await?;
58        let calendar = static_calendar();
59        let mut matches = dto
60            .matches
61            .into_iter()
62            .map(|m| m.to_match(&calendar))
63            .collect::<Result<Vec<_>>>()?;
64        if let Some(day) = day {
65            let (start, end) = day_bounds(day);
66            matches.retain(|m| m.kickoff >= start && m.kickoff < end);
67        }
68        Ok(matches)
69    }
70
71    async fn standings(&self) -> Result<Vec<Group>> {
72        let dto: FdStandingsResponse = self.fetch("/competitions/WC/standings").await?;
73        Ok(dto
74            .standings
75            .into_iter()
76            .map(FdStanding::into_group)
77            .collect())
78    }
79
80    async fn bracket(&self) -> Result<Bracket> {
81        let matches = self.scoreboard(None).await?;
82        Ok(Bracket {
83            rounds: Stage::knockout_order()
84                .into_iter()
85                .map(|stage| BracketRound {
86                    stage,
87                    matches: matches
88                        .iter()
89                        .filter(|m| m.stage == stage)
90                        .cloned()
91                        .collect(),
92                })
93                .collect(),
94        })
95    }
96
97    async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
98        let dto: FdMatchEnvelope = self.fetch(&format!("/matches/{id}")).await?;
99        // NOTE: football-data free tier lacks fine-grained events; timeline is empty.
100        Ok(MatchDetail {
101            summary: dto.match_.to_match(&static_calendar())?,
102            events: Vec::new(),
103            lineups: dto.match_.lineups(),
104            stats: Vec::new(),
105        })
106    }
107}
108
109fn static_calendar() -> Calendar {
110    Calendar {
111        stages: vec![
112            window(
113                Stage::GroupStage,
114                "Group Stage",
115                (2026, Month::June, 11),
116                (2026, Month::June, 28),
117            ),
118            window(
119                Stage::RoundOf32,
120                "Round of 32",
121                (2026, Month::June, 28),
122                (2026, Month::July, 4),
123            ),
124            window(
125                Stage::RoundOf16,
126                "Round of 16",
127                (2026, Month::July, 4),
128                (2026, Month::July, 9),
129            ),
130            window(
131                Stage::QuarterFinal,
132                "Quarter-final",
133                (2026, Month::July, 9),
134                (2026, Month::July, 14),
135            ),
136            window(
137                Stage::SemiFinal,
138                "Semi-final",
139                (2026, Month::July, 14),
140                (2026, Month::July, 18),
141            ),
142            window(
143                Stage::ThirdPlace,
144                "Third place",
145                (2026, Month::July, 18),
146                (2026, Month::July, 19),
147            ),
148            window(
149                Stage::Final,
150                "Final",
151                (2026, Month::July, 19),
152                (2026, Month::August, 1),
153            ),
154        ],
155    }
156}
157
158fn window(
159    stage: Stage,
160    label: &str,
161    start: (i32, Month, u8),
162    end: (i32, Month, u8),
163) -> StageWindow {
164    StageWindow {
165        stage,
166        label: label.to_owned(),
167        start: Date::from_calendar_date(start.0, start.1, start.2)
168            .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
169                d.with_time(Time::MIDNIGHT).assume_utc()
170            }),
171        end: Date::from_calendar_date(end.0, end.1, end.2)
172            .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
173                d.with_time(Time::MIDNIGHT).assume_utc()
174            }),
175    }
176}
177
178#[derive(Debug, Deserialize)]
179struct FdMatches {
180    matches: Vec<FdMatch>,
181}
182#[derive(Debug, Deserialize)]
183struct FdMatchEnvelope {
184    #[serde(rename = "match")]
185    match_: FdMatch,
186}
187
188#[derive(Debug, Deserialize)]
189struct FdMatch {
190    id: i64,
191    #[serde(rename = "utcDate")]
192    utc_date: String,
193    status: String,
194    stage: Option<String>,
195    group: Option<String>,
196    #[serde(rename = "homeTeam")]
197    home_team: FdTeam,
198    #[serde(rename = "awayTeam")]
199    away_team: FdTeam,
200    score: FdScore,
201    #[serde(default)]
202    lineups: Vec<FdLineup>,
203}
204
205impl FdMatch {
206    fn to_match(&self, calendar: &Calendar) -> Result<Match> {
207        let kickoff = parse_time(&self.utc_date)?;
208        let status = fd_status(&self.status);
209        Ok(Match {
210            id: self.id.to_string(),
211            stage: crate::backends::common::stage_for_date(
212                calendar,
213                kickoff,
214                self.stage.as_deref().map_or(Stage::GroupStage, fd_stage),
215            ),
216            group: self
217                .group
218                .as_ref()
219                .map(|g| g.trim_start_matches("GROUP_").to_owned()),
220            home: self.home_team.to_domain(),
221            away: self.away_team.to_domain(),
222            score: self.score.to_domain(&status),
223            status,
224            kickoff,
225            venue: None,
226        })
227    }
228
229    fn lineups(&self) -> Vec<Lineup> {
230        self.lineups.iter().map(FdLineup::to_domain).collect()
231    }
232}
233
234fn fd_status(status: &str) -> MatchStatus {
235    match status {
236        "SCHEDULED" | "TIMED" => MatchStatus::Scheduled,
237        "IN_PLAY" => MatchStatus::Live {
238            minute: None,
239            detail: None,
240        },
241        "PAUSED" => MatchStatus::HalfTime,
242        "FINISHED" => MatchStatus::FullTime,
243        "POSTPONED" => MatchStatus::Postponed,
244        "CANCELLED" | "CANCELED" => MatchStatus::Canceled,
245        _ => MatchStatus::Unknown,
246    }
247}
248
249fn fd_stage(stage: &str) -> Stage {
250    match stage {
251        "LAST_32" => Stage::RoundOf32,
252        "LAST_16" => Stage::RoundOf16,
253        "QUARTER_FINALS" => Stage::QuarterFinal,
254        "SEMI_FINALS" => Stage::SemiFinal,
255        "THIRD_PLACE" => Stage::ThirdPlace,
256        "FINAL" => Stage::Final,
257        _ => Stage::GroupStage,
258    }
259}
260
261#[derive(Debug, Deserialize)]
262struct FdTeam {
263    id: Option<i64>,
264    name: Option<String>,
265    tla: Option<String>,
266    crest: Option<String>,
267}
268impl FdTeam {
269    fn to_domain(&self) -> Team {
270        let name = self.name.clone().unwrap_or_else(|| "TBD".to_owned());
271        Team {
272            id: self.id.map_or_else(String::new, |id| id.to_string()),
273            abbreviation: self
274                .tla
275                .clone()
276                .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase()),
277            country_code: self.tla.clone(),
278            crest_url: self.crest.clone(),
279            name,
280        }
281    }
282}
283
284#[derive(Debug, Deserialize)]
285struct FdScore {
286    full_time: FdGoals,
287    regular_time: Option<FdGoals>,
288    penalties: Option<FdGoals>,
289}
290impl FdScore {
291    fn to_domain(&self, status: &MatchStatus) -> Option<Score> {
292        if matches!(status, MatchStatus::Scheduled) {
293            return None;
294        }
295        let goals = self.regular_time.as_ref().unwrap_or(&self.full_time);
296        Some(Score {
297            home: goals.home.unwrap_or_default().clamp(0, i64::from(u8::MAX)) as u8,
298            away: goals.away.unwrap_or_default().clamp(0, i64::from(u8::MAX)) as u8,
299            home_pens: self
300                .penalties
301                .as_ref()
302                .and_then(|p| p.home)
303                .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
304            away_pens: self
305                .penalties
306                .as_ref()
307                .and_then(|p| p.away)
308                .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
309        })
310    }
311}
312#[derive(Debug, Deserialize)]
313struct FdGoals {
314    home: Option<i64>,
315    away: Option<i64>,
316}
317
318#[derive(Debug, Deserialize)]
319struct FdStandingsResponse {
320    standings: Vec<FdStanding>,
321}
322#[derive(Debug, Deserialize)]
323struct FdStanding {
324    group: Option<String>,
325    table: Vec<FdStandingRow>,
326}
327impl FdStanding {
328    fn into_group(self) -> Group {
329        Group {
330            name: self
331                .group
332                .map(|g| g.trim_start_matches("GROUP_").to_owned())
333                .unwrap_or_default(),
334            standings: self
335                .table
336                .into_iter()
337                .map(FdStandingRow::into_domain)
338                .collect(),
339        }
340    }
341}
342#[derive(Debug, Deserialize)]
343struct FdStandingRow {
344    position: u8,
345    team: FdTeam,
346    played_games: u8,
347    won: u8,
348    draw: u8,
349    lost: u8,
350    goals_for: u16,
351    goals_against: u16,
352    goal_difference: i16,
353    points: u16,
354}
355impl FdStandingRow {
356    fn into_domain(self) -> GroupStanding {
357        GroupStanding {
358            team: self.team.to_domain(),
359            rank: self.position,
360            played: self.played_games,
361            won: self.won,
362            drawn: self.draw,
363            lost: self.lost,
364            goals_for: self.goals_for,
365            goals_against: self.goals_against,
366            goal_diff: f64_to_i16(Some(f64::from(self.goal_difference))),
367            points: self.points,
368        }
369    }
370}
371
372#[derive(Debug, Deserialize)]
373struct FdLineup {
374    team: FdTeam,
375    formation: Option<String>,
376    #[serde(default)]
377    start_xi: Vec<FdPlayerSlot>,
378    #[serde(default)]
379    substitutes: Vec<FdPlayerSlot>,
380}
381impl FdLineup {
382    fn to_domain(&self) -> Lineup {
383        Lineup {
384            team_id: self.team.id.map_or_else(String::new, |id| id.to_string()),
385            formation: self.formation.clone(),
386            starters: self.start_xi.iter().map(FdPlayerSlot::to_domain).collect(),
387            substitutes: self
388                .substitutes
389                .iter()
390                .map(FdPlayerSlot::to_domain)
391                .collect(),
392        }
393    }
394}
395#[derive(Debug, Deserialize)]
396struct FdPlayerSlot {
397    name: Option<String>,
398    shirt_number: Option<u8>,
399    position: Option<String>,
400}
401impl FdPlayerSlot {
402    fn to_domain(&self) -> crate::domain::Player {
403        crate::domain::Player {
404            name: self.name.clone().unwrap_or_default(),
405            number: self.shirt_number,
406            position: self.position.clone(),
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::error::DataError;
415
416    #[test]
417    fn maps_football_data_matches() -> Result<()> {
418        let dto: FdMatches = serde_json::from_str(include_str!(
419            "../../tests/fixtures/football_data_matches.json"
420        ))
421        .map_err(|e| DataError::Decode(e.to_string()))?;
422        let matches = dto
423            .matches
424            .iter()
425            .map(|m| m.to_match(&static_calendar()))
426            .collect::<Result<Vec<_>>>()?;
427        assert_eq!(matches[0].home.name, "Canada");
428        assert_eq!(matches[0].status, MatchStatus::Scheduled);
429        assert_eq!(matches[1].stage, Stage::RoundOf32);
430        Ok(())
431    }
432
433    #[test]
434    fn maps_football_data_standings() -> Result<()> {
435        let dto: FdStandingsResponse = serde_json::from_str(include_str!(
436            "../../tests/fixtures/football_data_standings.json"
437        ))
438        .map_err(|e| DataError::Decode(e.to_string()))?;
439        let groups: Vec<_> = dto
440            .standings
441            .into_iter()
442            .map(FdStanding::into_group)
443            .collect();
444        assert_eq!(groups[0].standings[0].team.name, "Mexico");
445        assert_eq!(groups[0].standings[0].points, 3);
446        Ok(())
447    }
448}