1use crate::endpoints::StatsAPIUrl;
2use crate::endpoints::person::{Person, PersonId};
3use crate::endpoints::sports::SportId;
4use crate::endpoints::teams::team::{Team, TeamId};
5use crate::gen_params;
6use crate::types::{Copyright, MLB_API_DATE_FORMAT, NaiveDateRange};
7use chrono::NaiveDate;
8use derive_more::{Deref, Display};
9use itertools::Itertools;
10use serde::Deserialize;
11use std::fmt::{Display, Formatter};
12use std::ops::{Deref, DerefMut};
13
14#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
15#[serde(rename_all = "camelCase")]
16pub struct TransactionsResponse {
17 pub copyright: Copyright,
18 pub transactions: Vec<Transaction>,
19}
20
21#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
22#[serde(rename_all = "camelCase")]
23pub struct TransactionCommon {
24 pub id: TransactionId,
25 #[serde(default)]
26 pub description: String,
27 #[serde(flatten)]
28 pub dates: TransactionDates,
29}
30
31#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
32#[serde(rename_all = "camelCase")]
33pub struct TransactionDates {
34 pub date: NaiveDate,
35 pub effective_date: Option<NaiveDate>,
36 pub resolution_date: Option<NaiveDate>,
37}
38
39#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
40#[serde(tag = "typeCode")]
41pub enum Transaction {
42 #[serde(rename = "ASG", rename_all = "camelCase")]
43 Assigned {
44 #[serde(flatten)]
45 common: TransactionCommon,
46 #[serde(default = "Person::unknown_person")]
47 person: Person,
48 #[serde(rename = "fromTeam")]
49 source_team: Option<Team>,
50 #[serde(default = "Team::unknown_team")]
51 #[serde(rename = "toTeam")]
52 destination_team: Team,
53 },
54 #[serde(rename = "SC", rename_all = "camelCase")]
55 StatusChange {
56 #[serde(flatten)]
57 common: TransactionCommon,
58 #[serde(default = "Person::unknown_person")]
59 person: Person,
60 #[serde(default = "Team::unknown_team")]
61 #[serde(rename = "toTeam")]
62 team: Team,
63 },
64 #[serde(rename = "SFA", rename_all = "camelCase")]
65 SignedAsFreeAgent {
66 #[serde(flatten)]
67 common: TransactionCommon,
68 #[serde(default = "Person::unknown_person")]
69 person: Person,
70 #[serde(default = "Team::unknown_team")]
71 #[serde(rename = "toTeam")]
72 team: Team,
73 },
74 #[serde(rename = "DES", rename_all = "camelCase")]
75 DesignatedForAssignment {
76 #[serde(flatten)]
77 common: TransactionCommon,
78 #[serde(default = "Person::unknown_person")]
79 person: Person,
80 #[serde(default = "Team::unknown_team")]
81 #[serde(rename = "toTeam")]
82 team: Team,
83 },
84 #[serde(rename = "TR", rename_all = "camelCase")]
85 Trade {
86 #[serde(flatten)]
87 common: TransactionCommon,
88 person: Option<Person>,
90 #[serde(default = "Team::unknown_team")]
91 #[serde(rename = "fromTeam")]
92 source_team: Team,
93 #[serde(default = "Team::unknown_team")]
94 #[serde(rename = "toTeam")]
95 destination_team: Team,
96 },
97 #[serde(rename = "NUM", rename_all = "camelCase")]
98 NumberChange {
99 #[serde(flatten)]
100 common: TransactionCommon,
101 #[serde(default = "Person::unknown_person")]
102 person: Person,
103 #[serde(default = "Team::unknown_team")]
104 #[serde(rename = "toTeam")]
105 team: Team,
106 },
107 #[serde(rename = "OUT", rename_all = "camelCase")]
108 Outrighted {
109 #[serde(flatten)]
110 common: TransactionCommon,
111 #[serde(default = "Person::unknown_person")]
112 person: Person,
113 #[serde(rename = "fromTeam")]
114 source_team: Team,
115 #[serde(default = "Team::unknown_team")]
116 #[serde(rename = "toTeam")]
117 destination_team: Team,
118 },
119 #[serde(rename = "CLW", rename_all = "camelCase")]
120 ClaimedOffWaivers {
121 #[serde(flatten)]
122 common: TransactionCommon,
123 #[serde(default = "Person::unknown_person")]
124 person: Person,
125 #[serde(default = "Team::unknown_team")]
126 #[serde(rename = "fromTeam")]
127 source_team: Team,
128 #[serde(default = "Team::unknown_team")]
129 #[serde(rename = "toTeam")]
130 destination_team: Team,
131 },
132 #[serde(rename = "SGN", rename_all = "camelCase")]
133 Signed {
134 #[serde(flatten)]
135 common: TransactionCommon,
136 #[serde(default = "Person::unknown_person")]
137 person: Person,
138 #[serde(rename = "toTeam")]
139 team: Team,
140 },
141 #[serde(rename = "REL", rename_all = "camelCase")]
142 Released {
143 #[serde(flatten)]
144 common: TransactionCommon,
145 #[serde(default = "Person::unknown_person")]
146 person: Person,
147 #[serde(default = "Team::unknown_team")]
148 #[serde(rename = "toTeam")]
149 team: Team,
150 },
151 #[serde(rename = "DFA", rename_all = "camelCase")]
152 DeclaredFreeAgency {
153 #[serde(flatten)]
154 common: TransactionCommon,
155 #[serde(default = "Person::unknown_person")]
156 person: Person,
157 #[serde(default = "Team::unknown_team")]
158 #[serde(rename = "toTeam")]
159 source_team: Team,
160 },
161 #[serde(rename = "OPT", rename_all = "camelCase")]
162 Optioned {
163 #[serde(flatten)]
164 common: TransactionCommon,
165 #[serde(default = "Person::unknown_person")]
166 person: Person,
167 #[serde(rename = "fromTeam")]
168 source_team: Team,
169 #[serde(default = "Team::unknown_team")]
170 #[serde(rename = "toTeam")]
171 destination_team: Team,
172 },
173 #[serde(rename = "RTN", rename_all = "camelCase")]
174 Returned {
175 #[serde(flatten)]
176 common: TransactionCommon,
177 #[serde(default = "Person::unknown_person")]
178 person: Person,
179 #[serde(default = "Team::unknown_team")]
180 #[serde(rename = "fromTeam")]
181 source_team: Team,
182 #[serde(default = "Team::unknown_team")]
183 #[serde(rename = "toTeam")]
184 destination_team: Team,
185 },
186 #[serde(rename = "SE", rename_all = "camelCase")]
187 Selected {
188 #[serde(flatten)]
189 common: TransactionCommon,
190 #[serde(default = "Person::unknown_person")]
191 person: Person,
192 #[serde(rename = "fromTeam")]
193 source_team: Option<Team>,
194 #[serde(default = "Team::unknown_team")]
195 #[serde(rename = "toTeam")]
196 destination_team: Team,
197 },
198 #[serde(rename = "CU", rename_all = "camelCase")]
199 Recalled {
200 #[serde(flatten)]
201 common: TransactionCommon,
202 #[serde(default = "Person::unknown_person")]
203 person: Person,
204 #[serde(rename = "fromTeam")]
205 source_team: Option<Team>,
206 #[serde(default = "Team::unknown_team")]
207 #[serde(rename = "toTeam")]
208 destination_team: Team,
209 },
210 #[serde(rename = "SU", rename_all = "camelCase")]
211 Suspension {
212 #[serde(flatten)]
213 common: TransactionCommon,
214 #[serde(default = "Person::unknown_person")]
215 person: Person,
216 #[serde(default = "Team::unknown_team")]
217 #[serde(rename = "toTeam")]
218 team: Team,
219 },
220 #[serde(rename = "RET", rename_all = "camelCase")]
221 Retired {
222 #[serde(flatten)]
223 common: TransactionCommon,
224 #[serde(default = "Person::unknown_person")]
225 person: Person,
226 #[serde(default = "Team::unknown_team")]
227 #[serde(rename = "toTeam")]
228 team: Team,
229 },
230 #[serde(rename = "PUR", rename_all = "camelCase")]
231 Purchase {
232 #[serde(flatten)]
233 common: TransactionCommon,
234 #[serde(default = "Person::unknown_person")]
235 person: Person,
236 #[serde(rename = "fromTeam")]
237 source_team: Option<Team>,
238 #[serde(default = "Team::unknown_team")]
239 #[serde(rename = "toTeam")]
240 destination_team: Team,
241 },
242 #[serde(rename = "R5", rename_all = "camelCase")]
243 RuleFiveDraft {
244 #[serde(flatten)]
245 common: TransactionCommon,
246 #[serde(default = "Person::unknown_person")]
247 person: Person,
248 #[serde(rename = "fromTeam")]
249 source_team: Team,
250 #[serde(default = "Team::unknown_team")]
251 #[serde(rename = "toTeam")]
252 destination_team: Team,
253 },
254 #[serde(rename = "RE", rename_all = "camelCase")]
255 Reinstated {
256 #[serde(flatten)]
257 common: TransactionCommon,
258 #[serde(default = "Person::unknown_person")]
259 person: Person,
260 #[serde(default = "Team::unknown_team")]
261 #[serde(rename = "toTeam")]
262 team: Team,
263 },
264 #[serde(rename = "LON", rename_all = "camelCase")]
265 Loan {
266 #[serde(flatten)]
267 common: TransactionCommon,
268 #[serde(default = "Person::unknown_person")]
269 person: Person,
270 #[serde(rename = "fromTeam")]
271 source_team: Team,
272 #[serde(default = "Team::unknown_team")]
273 #[serde(rename = "toTeam")]
274 destination_team: Team,
275 },
276 #[serde(rename = "CP", rename_all = "camelCase")]
277 ContractPurchased {
278 #[serde(flatten)]
279 common: TransactionCommon,
280 #[serde(default = "Person::unknown_person")]
281 person: Person,
282 #[serde(default = "Team::unknown_team")]
283 #[serde(rename = "toTeam")]
284 team: Team,
285 },
286 #[serde(rename = "DR", rename_all = "camelCase")]
287 Drafted {
288 #[serde(flatten)]
289 common: TransactionCommon,
290 #[serde(default = "Person::unknown_person")]
291 person: Person,
292 #[serde(default = "Team::unknown_team")]
293 #[serde(rename = "toTeam")]
294 team: Team,
295 },
296 #[serde(rename = "DEI", rename_all = "camelCase")]
297 DeclaredIneligible {
298 #[serde(flatten)]
299 common: TransactionCommon,
300 #[serde(default = "Person::unknown_person")]
301 person: Person,
302 #[serde(default = "Team::unknown_team")]
303 #[serde(rename = "toTeam")]
304 team: Team,
305 }
306}
307
308impl Deref for Transaction {
309 type Target = TransactionCommon;
310
311 fn deref(&self) -> &Self::Target {
312 match self {
313 Self::Assigned { common, .. } => common,
314 Self::StatusChange { common, .. } => common,
315 Self::SignedAsFreeAgent { common, .. } => common,
316 Self::DesignatedForAssignment { common, .. } => common,
317 Self::Trade { common, .. } => common,
318 Self::NumberChange { common, .. } => common,
319 Self::Outrighted { common, .. } => common,
320 Self::ClaimedOffWaivers { common, .. } => common,
321 Self::Signed { common, .. } => common,
322 Self::Released { common, .. } => common,
323 Self::DeclaredFreeAgency { common, .. } => common,
324 Self::Optioned { common, .. } => common,
325 Self::Returned { common, .. } => common,
326 Self::Selected { common, .. } => common,
327 Self::Recalled { common, .. } => common,
328 Self::Suspension { common, .. } => common,
329 Self::Retired { common, .. } => common,
330 Self::Purchase { common, .. } => common,
331 Self::RuleFiveDraft { common, .. } => common,
332 Self::Reinstated { common, .. } => common,
333 Self::Loan { common, .. } => common,
334 Self::ContractPurchased { common, .. } => common,
335 Self::Drafted { common, .. } => common,
336 Self::DeclaredIneligible { common, .. } => common,
337 }
338 }
339}
340
341impl DerefMut for Transaction {
342 fn deref_mut(&mut self) -> &mut Self::Target {
343 match self {
344 Self::Assigned { common, .. } => common,
345 Self::StatusChange { common, .. } => common,
346 Self::SignedAsFreeAgent { common, .. } => common,
347 Self::DesignatedForAssignment { common, .. } => common,
348 Self::Trade { common, .. } => common,
349 Self::NumberChange { common, .. } => common,
350 Self::Outrighted { common, .. } => common,
351 Self::ClaimedOffWaivers { common, .. } => common,
352 Self::Signed { common, .. } => common,
353 Self::Released { common, .. } => common,
354 Self::DeclaredFreeAgency { common, .. } => common,
355 Self::Optioned { common, .. } => common,
356 Self::Returned { common, .. } => common,
357 Self::Selected { common, .. } => common,
358 Self::Recalled { common, .. } => common,
359 Self::Suspension { common, .. } => common,
360 Self::Retired { common, .. } => common,
361 Self::Purchase { common, .. } => common,
362 Self::RuleFiveDraft { common, .. } => common,
363 Self::Reinstated { common, .. } => common,
364 Self::Loan { common, .. } => common,
365 Self::ContractPurchased { common, .. } => common,
366 Self::Drafted { common, .. } => common,
367 Self::DeclaredIneligible { common, .. } => common,
368 }
369 }
370}
371
372impl Display for Transaction {
373 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
374 write!(f, "{}", self.description)
375 }
376}
377
378#[repr(transparent)]
379#[derive(Debug, Deserialize, Deref, Display, PartialEq, Eq, Copy, Clone, Hash)]
380pub struct TransactionId(u32);
381
382impl TransactionId {
383 #[must_use]
384 pub const fn new(id: u32) -> Self {
385 Self(id)
386 }
387}
388
389pub enum TransactionsEndpointUrlKind {
390 Team(TeamId),
391 Player(PersonId),
392 Transactions(Vec<TransactionId>),
393 DateRange(NaiveDateRange),
394}
395
396impl Display for TransactionsEndpointUrlKind {
397 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
398 match self {
399 Self::Team(team_id) => write!(f, "teamId={team_id}"),
400 Self::Player(person_id) => write!(f, "playerId={person_id}"),
401 Self::Transactions(transactions) => write!(f, "transactionIds={}", transactions.iter().join(",")),
402 Self::DateRange(range) => write!(f, "startDate={}&endDate={}", range.start().format(MLB_API_DATE_FORMAT), range.end().format(MLB_API_DATE_FORMAT)),
403 }
404 }
405}
406
407pub struct TransactionsEndpointUrl {
412 pub kind: TransactionsEndpointUrlKind,
413 pub sport_id: Option<SportId>,
414}
415
416impl Display for TransactionsEndpointUrl {
417 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
418 write!(
419 f,
420 "http://statsapi.mlb.com/api/v1/transactions{}",
421 gen_params! {
422 self.kind,
423 "sportId"?: self.sport_id,
424 }
425 )
426 }
427}
428
429impl StatsAPIUrl for TransactionsEndpointUrl {
430 type Response = TransactionsResponse;
431}
432
433#[cfg(test)]
434mod tests {
435 use crate::endpoints::StatsAPIUrl;
436 use crate::endpoints::sports::SportId;
437 use crate::endpoints::sports::players::SportsPlayersEndpointUrl;
438 use crate::endpoints::teams::TeamsEndpointUrl;
439 use crate::endpoints::teams::team::Team;
440 use crate::endpoints::transactions::{TransactionsEndpointUrl, TransactionsEndpointUrlKind, TransactionsResponse};
441 use chrono::NaiveDate;
442 use crate::endpoints::person::Person;
443
444 #[tokio::test]
445 async fn parse_2025() {
446 let json = reqwest::get(
447 TransactionsEndpointUrl {
448 kind: TransactionsEndpointUrlKind::DateRange(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()..=NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()),
449 sport_id: Some(SportId::MLB),
450 }
451 .to_string(),
452 )
453 .await
454 .unwrap()
455 .bytes()
456 .await
457 .unwrap();
458 let mut de = serde_json::Deserializer::from_slice(&json);
459 let result: Result<TransactionsResponse, serde_path_to_error::Error<_>> = serde_path_to_error::deserialize(&mut de);
460 match result {
461 Ok(_) => {}
462 Err(e) if format!("{:?}", e.inner()).contains("missing field `copyright`") => {}
463 Err(e) => panic!("Err: {:?}", e),
464 }
465 }
466
467 #[tokio::test]
468 async fn parse_all_endpoints() {
469 let blue_jays = TeamsEndpointUrl {
470 sport_id: Some(SportId::MLB),
471 season: Some(2025),
472 }
473 .get()
474 .await
475 .unwrap()
476 .teams
477 .into_iter()
478 .filter_map(Team::try_as_named)
479 .find(|team| team.name.as_str() == "Toronto Blue Jays")
480 .unwrap();
481 let bo_bichette = SportsPlayersEndpointUrl { id: SportId::MLB, season: Some(2025) }
482 .get()
483 .await
484 .unwrap()
485 .people
486 .into_iter()
487 .filter_map(Person::try_as_named)
488 .find(|person| person.full_name == "Bo Bichette")
489 .unwrap();
490
491 let response = TransactionsEndpointUrl {
492 kind: TransactionsEndpointUrlKind::DateRange(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()..=NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()),
493 sport_id: Some(SportId::MLB),
494 }
495 .get()
496 .await
497 .unwrap();
498 let transaction_ids = response.transactions.into_iter().take(1).map(|transaction| transaction.id).collect::<Vec<_>>();
499 let _response = TransactionsEndpointUrl {
500 kind: TransactionsEndpointUrlKind::Team(blue_jays.id),
501 sport_id: Some(SportId::MLB),
502 }
503 .get()
504 .await
505 .unwrap();
506 let _response = TransactionsEndpointUrl {
507 kind: TransactionsEndpointUrlKind::Player(bo_bichette.id),
508 sport_id: Some(SportId::MLB),
509 }
510 .get()
511 .await
512 .unwrap();
513 let _response = TransactionsEndpointUrl {
514 kind: TransactionsEndpointUrlKind::Transactions(transaction_ids),
515 sport_id: Some(SportId::MLB),
516 }
517 .get()
518 .await
519 .unwrap();
520 }
521}