Skip to main content

wc_data/backends/
api_football.rs

1//! API-Football (`api-sports.io`) backend.
2//!
3//! Auth: header `x-apisports-key: <key>`. Base `https://v3.football.api-sports.io`.
4//! Endpoints used: `/fixtures`, `/standings`, `/fixtures/events`,
5//! `/fixtures/lineups`, `/fixtures/statistics`.
6
7use serde::Deserialize;
8use time::{Date, Month, OffsetDateTime, Time};
9
10use crate::backends::common::{
11    api_status, day_bounds, f64_to_i16, f64_to_u16, group_from_text, parse_time,
12};
13use crate::domain::{
14    Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchEvent,
15    MatchEventKind, MatchStatus, Player, Score, Stage, StageWindow, Team, TeamStat,
16};
17use crate::error::{DataError, Result};
18use crate::provider::ScoreProvider;
19use crate::transport::Http;
20
21const BASE_URL: &str = "https://v3.football.api-sports.io";
22const WORLD_CUP_LEAGUE: u16 = 1;
23const WORLD_CUP_SEASON: u16 = 2026;
24
25/// API-Football-backed provider.
26#[derive(Debug, Clone)]
27pub struct ApiFootballProvider {
28    http: Http,
29    key: String,
30}
31
32impl ApiFootballProvider {
33    /// Build the provider over a shared HTTP client with the given API key.
34    #[must_use]
35    pub fn new(http: Http, key: String) -> Self {
36        Self { http, key }
37    }
38
39    fn headers(&self) -> [(&str, &str); 1] {
40        [("x-apisports-key", self.key.as_str())]
41    }
42
43    async fn fetch<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
44        self.http
45            .get_json(&format!("{BASE_URL}{path}"), &self.headers())
46            .await
47    }
48}
49
50impl ScoreProvider for ApiFootballProvider {
51    fn name(&self) -> &'static str {
52        "API-Football"
53    }
54
55    async fn calendar(&self) -> Result<Calendar> {
56        Ok(static_calendar())
57    }
58
59    async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
60        // NOTE: API-Football documents FIFA World Cup as league=1; the 2026 tournament uses season=2026.
61        let dto: ApiResponse<ApiFixture> = self
62            .fetch(&format!(
63                "/fixtures?league={WORLD_CUP_LEAGUE}&season={WORLD_CUP_SEASON}"
64            ))
65            .await?;
66        let calendar = static_calendar();
67        let mut matches = dto
68            .response
69            .into_iter()
70            .map(|fixture| fixture.into_match(&calendar))
71            .collect::<Result<Vec<_>>>()?;
72        if let Some(day) = day {
73            let (start, end) = day_bounds(day);
74            matches.retain(|m| m.kickoff >= start && m.kickoff < end);
75        }
76        Ok(matches)
77    }
78
79    async fn standings(&self) -> Result<Vec<Group>> {
80        let dto: ApiResponse<ApiStandingLeagueEnvelope> = self
81            .fetch(&format!(
82                "/standings?league={WORLD_CUP_LEAGUE}&season={WORLD_CUP_SEASON}"
83            ))
84            .await?;
85        Ok(dto
86            .response
87            .into_iter()
88            .flat_map(|r| {
89                r.league.standings.into_iter().map(|table| Group {
90                    name: table
91                        .first()
92                        .and_then(|row| group_from_text(row.group.as_deref()))
93                        .unwrap_or_default(),
94                    standings: table.into_iter().map(ApiStanding::into_domain).collect(),
95                })
96            })
97            .collect())
98    }
99
100    async fn bracket(&self) -> Result<Bracket> {
101        let matches = self.scoreboard(None).await?;
102        Ok(Bracket {
103            rounds: Stage::knockout_order()
104                .into_iter()
105                .map(|stage| BracketRound {
106                    stage,
107                    matches: matches
108                        .iter()
109                        .filter(|m| m.stage == stage)
110                        .cloned()
111                        .collect(),
112                })
113                .collect(),
114        })
115    }
116
117    async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
118        let fixtures: ApiResponse<ApiFixture> = self.fetch(&format!("/fixtures?id={id}")).await?;
119        let summary = fixtures
120            .response
121            .into_iter()
122            .next()
123            .ok_or_else(|| DataError::Decode(format!("API-Football fixture {id} not found")))?
124            .into_match(&static_calendar())?;
125        let events: ApiResponse<ApiEvent> = self
126            .fetch(&format!("/fixtures/events?fixture={id}"))
127            .await?;
128        let lineups: ApiResponse<ApiLineup> = self
129            .fetch(&format!("/fixtures/lineups?fixture={id}"))
130            .await?;
131        let stats: ApiResponse<ApiStatisticTeam> = self
132            .fetch(&format!("/fixtures/statistics?fixture={id}"))
133            .await?;
134        Ok(MatchDetail {
135            summary,
136            events: events
137                .response
138                .into_iter()
139                .map(ApiEvent::into_domain)
140                .collect(),
141            lineups: lineups
142                .response
143                .into_iter()
144                .map(ApiLineup::into_domain)
145                .collect(),
146            stats: api_team_stats(&stats.response),
147        })
148    }
149}
150
151fn static_calendar() -> Calendar {
152    Calendar {
153        stages: vec![
154            window(
155                Stage::GroupStage,
156                "Group Stage",
157                (2026, Month::June, 11),
158                (2026, Month::June, 28),
159            ),
160            window(
161                Stage::RoundOf32,
162                "Round of 32",
163                (2026, Month::June, 28),
164                (2026, Month::July, 4),
165            ),
166            window(
167                Stage::RoundOf16,
168                "Round of 16",
169                (2026, Month::July, 4),
170                (2026, Month::July, 9),
171            ),
172            window(
173                Stage::QuarterFinal,
174                "Quarter-final",
175                (2026, Month::July, 9),
176                (2026, Month::July, 14),
177            ),
178            window(
179                Stage::SemiFinal,
180                "Semi-final",
181                (2026, Month::July, 14),
182                (2026, Month::July, 18),
183            ),
184            window(
185                Stage::ThirdPlace,
186                "Third place",
187                (2026, Month::July, 18),
188                (2026, Month::July, 19),
189            ),
190            window(
191                Stage::Final,
192                "Final",
193                (2026, Month::July, 19),
194                (2026, Month::August, 1),
195            ),
196        ],
197    }
198}
199
200fn window(
201    stage: Stage,
202    label: &str,
203    start: (i32, Month, u8),
204    end: (i32, Month, u8),
205) -> StageWindow {
206    StageWindow {
207        stage,
208        label: label.to_owned(),
209        start: Date::from_calendar_date(start.0, start.1, start.2)
210            .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
211                d.with_time(Time::MIDNIGHT).assume_utc()
212            }),
213        end: Date::from_calendar_date(end.0, end.1, end.2)
214            .map_or(OffsetDateTime::UNIX_EPOCH, |d| {
215                d.with_time(Time::MIDNIGHT).assume_utc()
216            }),
217    }
218}
219
220#[derive(Debug, Deserialize)]
221#[serde(bound(deserialize = "T: Deserialize<'de>"))]
222struct ApiResponse<T> {
223    #[serde(default)]
224    response: Vec<T>,
225}
226
227#[derive(Debug, Deserialize)]
228struct ApiFixture {
229    fixture: FixtureInfo,
230    league: ApiLeague,
231    teams: ApiTeams,
232    goals: ApiGoals,
233    score: ApiScore,
234}
235
236impl ApiFixture {
237    fn into_match(self, calendar: &Calendar) -> Result<Match> {
238        let kickoff = parse_time(&self.fixture.date)?;
239        let status = api_status(
240            &self.fixture.status.short,
241            self.fixture.status.elapsed,
242            self.fixture.status.long.clone(),
243        );
244        let score = if matches!(status, MatchStatus::Scheduled) {
245            None
246        } else {
247            Some(Score {
248                home: self
249                    .goals
250                    .home
251                    .unwrap_or_default()
252                    .clamp(0, i64::from(u8::MAX)) as u8,
253                away: self
254                    .goals
255                    .away
256                    .unwrap_or_default()
257                    .clamp(0, i64::from(u8::MAX)) as u8,
258                home_pens: self
259                    .score
260                    .penalty
261                    .home
262                    .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
263                away_pens: self
264                    .score
265                    .penalty
266                    .away
267                    .map(|v| v.clamp(0, i64::from(u8::MAX)) as u8),
268            })
269        };
270        Ok(Match {
271            id: self.fixture.id.to_string(),
272            stage: crate::backends::common::stage_for_date(
273                calendar,
274                kickoff,
275                stage_from_round(self.league.round.as_deref()),
276            ),
277            group: group_from_text(self.league.round.as_deref()),
278            home: self.teams.home.to_domain(),
279            away: self.teams.away.to_domain(),
280            score,
281            status,
282            kickoff,
283            venue: self.fixture.venue.and_then(|v| v.name),
284        })
285    }
286}
287
288fn stage_from_round(round: Option<&str>) -> Stage {
289    let text = round.unwrap_or_default().to_ascii_lowercase();
290    if text.contains("round of 32") {
291        Stage::RoundOf32
292    } else if text.contains("round of 16") {
293        Stage::RoundOf16
294    } else if text.contains("quarter") {
295        Stage::QuarterFinal
296    } else if text.contains("semi") {
297        Stage::SemiFinal
298    } else if text.contains("third") {
299        Stage::ThirdPlace
300    } else if text.contains("final") {
301        Stage::Final
302    } else {
303        Stage::GroupStage
304    }
305}
306
307#[derive(Debug, Deserialize)]
308struct FixtureInfo {
309    id: i64,
310    date: String,
311    status: ApiStatus,
312    venue: Option<ApiVenue>,
313}
314#[derive(Debug, Deserialize)]
315struct ApiStatus {
316    long: Option<String>,
317    short: String,
318    elapsed: Option<u16>,
319}
320#[derive(Debug, Deserialize)]
321struct ApiVenue {
322    name: Option<String>,
323}
324#[derive(Debug, Deserialize)]
325struct ApiLeague {
326    round: Option<String>,
327}
328#[derive(Debug, Deserialize)]
329struct ApiTeams {
330    home: ApiTeam,
331    away: ApiTeam,
332}
333#[derive(Debug, Deserialize)]
334struct ApiTeam {
335    id: Option<i64>,
336    name: Option<String>,
337    code: Option<String>,
338    logo: Option<String>,
339}
340impl ApiTeam {
341    fn to_domain(&self) -> Team {
342        let name = self.name.clone().unwrap_or_else(|| "TBD".to_owned());
343        Team {
344            id: self.id.map_or_else(String::new, |id| id.to_string()),
345            abbreviation: self
346                .code
347                .clone()
348                .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase()),
349            country_code: self.code.clone(),
350            crest_url: self.logo.clone(),
351            name,
352        }
353    }
354}
355#[derive(Debug, Deserialize)]
356struct ApiGoals {
357    home: Option<i64>,
358    away: Option<i64>,
359}
360#[derive(Debug, Deserialize)]
361struct ApiScore {
362    penalty: ApiGoals,
363}
364
365#[derive(Debug, Deserialize)]
366struct ApiStandingLeagueEnvelope {
367    league: ApiStandingLeague,
368}
369#[derive(Debug, Deserialize)]
370struct ApiStandingLeague {
371    standings: Vec<Vec<ApiStanding>>,
372}
373#[derive(Debug, Deserialize)]
374struct ApiStanding {
375    rank: u8,
376    team: ApiTeam,
377    group: Option<String>,
378    all: ApiStandingAll,
379    goals_diff: i16,
380    points: Option<u16>,
381}
382impl ApiStanding {
383    fn into_domain(self) -> GroupStanding {
384        GroupStanding {
385            team: self.team.to_domain(),
386            rank: self.rank,
387            played: self.all.played,
388            won: self.all.win,
389            drawn: self.all.draw,
390            lost: self.all.lose,
391            goals_for: f64_to_u16(Some(f64::from(self.all.goals.for_))),
392            goals_against: f64_to_u16(Some(f64::from(self.all.goals.against))),
393            goal_diff: f64_to_i16(Some(f64::from(self.goals_diff))),
394            points: self.points.unwrap_or_default(),
395        }
396    }
397}
398#[derive(Debug, Deserialize)]
399struct ApiStandingAll {
400    played: u8,
401    win: u8,
402    draw: u8,
403    lose: u8,
404    goals: ApiStandingGoals,
405}
406#[derive(Debug, Deserialize)]
407struct ApiStandingGoals {
408    #[serde(rename = "for")]
409    for_: u16,
410    against: u16,
411}
412
413#[derive(Debug, Deserialize)]
414struct ApiEvent {
415    time: ApiEventTime,
416    team: ApiTeam,
417    player: Option<ApiPerson>,
418    assist: Option<ApiPerson>,
419    #[serde(rename = "type")]
420    type_: String,
421    detail: Option<String>,
422    comments: Option<String>,
423}
424impl ApiEvent {
425    fn into_domain(self) -> MatchEvent {
426        MatchEvent {
427            minute: self.time.elapsed,
428            stoppage: self.time.extra,
429            kind: api_event_kind(&self.type_, self.detail.as_deref()),
430            team_id: self.team.id.map(|id| id.to_string()),
431            player: self.player.and_then(|p| p.name),
432            detail: self
433                .comments
434                .or_else(|| self.assist.and_then(|p| p.name))
435                .or(self.detail),
436        }
437    }
438}
439#[derive(Debug, Deserialize)]
440struct ApiEventTime {
441    elapsed: Option<u16>,
442    extra: Option<u16>,
443}
444#[derive(Debug, Deserialize)]
445struct ApiPerson {
446    name: Option<String>,
447}
448fn api_event_kind(type_: &str, detail: Option<&str>) -> MatchEventKind {
449    let text = format!("{} {}", type_, detail.unwrap_or_default()).to_ascii_lowercase();
450    if text.contains("own") {
451        MatchEventKind::OwnGoal
452    } else if text.contains("missed") {
453        MatchEventKind::PenaltyMiss
454    } else if text.contains("penalty") {
455        MatchEventKind::PenaltyGoal
456    } else if text.contains("goal") {
457        MatchEventKind::Goal
458    } else if text.contains("yellow") {
459        MatchEventKind::YellowCard
460    } else if text.contains("red") {
461        MatchEventKind::RedCard
462    } else if text.contains("sub") {
463        MatchEventKind::Substitution
464    } else if text.contains("var") {
465        MatchEventKind::Var
466    } else {
467        MatchEventKind::Other
468    }
469}
470
471#[derive(Debug, Deserialize)]
472struct ApiLineup {
473    team: ApiTeam,
474    formation: Option<String>,
475    #[serde(default)]
476    start_xi: Vec<ApiLineupSlot>,
477    #[serde(default)]
478    substitutes: Vec<ApiLineupSlot>,
479}
480impl ApiLineup {
481    fn into_domain(self) -> Lineup {
482        Lineup {
483            team_id: self.team.id.map_or_else(String::new, |id| id.to_string()),
484            formation: self.formation,
485            starters: self
486                .start_xi
487                .into_iter()
488                .map(ApiLineupSlot::into_player)
489                .collect(),
490            substitutes: self
491                .substitutes
492                .into_iter()
493                .map(ApiLineupSlot::into_player)
494                .collect(),
495        }
496    }
497}
498#[derive(Debug, Deserialize)]
499struct ApiLineupSlot {
500    player: ApiLineupPlayer,
501}
502impl ApiLineupSlot {
503    fn into_player(self) -> Player {
504        Player {
505            name: self.player.name.unwrap_or_default(),
506            number: self
507                .player
508                .number
509                .map(|n| n.clamp(0, i64::from(u8::MAX)) as u8),
510            position: self.player.pos,
511        }
512    }
513}
514#[derive(Debug, Deserialize)]
515struct ApiLineupPlayer {
516    name: Option<String>,
517    number: Option<i64>,
518    pos: Option<String>,
519}
520
521#[derive(Debug, Deserialize)]
522struct ApiStatisticTeam {
523    #[serde(default)]
524    statistics: Vec<ApiStatistic>,
525}
526#[derive(Debug, Deserialize)]
527struct ApiStatistic {
528    #[serde(rename = "type")]
529    type_: String,
530    value: Option<serde_json::Value>,
531}
532fn api_team_stats(teams: &[ApiStatisticTeam]) -> Vec<TeamStat> {
533    if teams.len() < 2 {
534        return Vec::new();
535    }
536    teams[0]
537        .statistics
538        .iter()
539        .zip(&teams[1].statistics)
540        .map(|(home, away)| TeamStat {
541            label: home.type_.clone(),
542            home: stat_value(home.value.as_ref()),
543            away: stat_value(away.value.as_ref()),
544        })
545        .collect()
546}
547fn stat_value(value: Option<&serde_json::Value>) -> String {
548    value.map_or_else(String::new, |v| {
549        v.as_str().map_or_else(|| v.to_string(), ToOwned::to_owned)
550    })
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn maps_api_football_fixture() -> Result<()> {
559        let dto: ApiResponse<ApiFixture> = serde_json::from_str(include_str!(
560            "../../tests/fixtures/apifootball_scoreboard.json"
561        ))
562        .map_err(|e| DataError::Decode(e.to_string()))?;
563        let matches = dto
564            .response
565            .into_iter()
566            .map(|f| f.into_match(&static_calendar()))
567            .collect::<Result<Vec<_>>>()?;
568        assert_eq!(matches[0].home.name, "Canada");
569        assert_eq!(matches[0].status, MatchStatus::Scheduled);
570        assert_eq!(matches[1].stage, Stage::RoundOf32);
571        Ok(())
572    }
573
574    #[test]
575    fn maps_api_football_events() -> Result<()> {
576        let dto: ApiResponse<ApiEvent> =
577            serde_json::from_str(include_str!("../../tests/fixtures/apifootball_events.json"))
578                .map_err(|e| DataError::Decode(e.to_string()))?;
579        let event = dto
580            .response
581            .into_iter()
582            .next()
583            .ok_or_else(|| DataError::Decode("missing event".to_owned()))?
584            .into_domain();
585        assert_eq!(event.kind, MatchEventKind::PenaltyGoal);
586        assert_eq!(event.player.as_deref(), Some("Alphonso Davies"));
587        Ok(())
588    }
589}