mlb_api/endpoints/transactions/
mod.rs

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		/// No person here indicates a trade occured that gave the team cash.
89		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
407/// This API endpoint is rather unreliable. For an example of what I mean: http://statsapi.mlb.com/api/v1/transactions?transactionIds=477955 \
408/// Vladimir Guerrero Jr.'s `.` in his name causes the API to be super confused and generate 5 players, four of which don't exist.\
409/// Of course putting `[Option<Person>]` for the `person` field is needlessly overkill since mostly all situations will not cause this, but the transactions shouldn't be discarded.\
410/// Instead, these values (no team, no date, no player) are given default values such that they are valid, but any further API requests with them return an error, such as a person with ID 0.
411pub 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}