mlb_api/endpoints/draft/
mod.rs

1use crate::endpoints::person::{Person, PersonId};
2use crate::endpoints::teams::team::{Team, TeamId};
3use crate::endpoints::{Position, StatsAPIUrl};
4use crate::gen_params;
5use crate::types::{Copyright, Location};
6use derive_more::{Deref, Display};
7use serde::Deserialize;
8use serde_with::DisplayFromStr;
9use serde_with::serde_as;
10use std::fmt::{Display, Formatter};
11use thiserror::Error;
12
13#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
14#[serde(rename_all = "camelCase")]
15pub struct DraftResponse {
16	pub copyright: Copyright,
17	pub drafts: DraftYear,
18}
19
20#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
21#[serde(rename_all = "camelCase")]
22pub struct DraftYear {
23	#[serde(rename = "draftYear")]
24	pub year: u32,
25	pub rounds: Vec<DraftRound>,
26}
27
28#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
29#[serde(rename_all = "camelCase")]
30pub struct DraftRound {
31	pub round: String,
32	pub picks: Vec<DraftPick>,
33}
34
35#[repr(transparent)]
36#[derive(Debug, Deserialize, Deref, Display, PartialEq, Eq, Copy, Clone, Hash)]
37pub struct EBISPersonId(u32);
38
39impl EBISPersonId {
40	#[must_use]
41	pub const fn new(id: u32) -> Self {
42		Self(id)
43	}
44}
45
46#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
47#[serde(rename_all = "camelCase")]
48pub struct DraftProspectsResponse {
49	pub copyright: Copyright,
50	#[serde(rename = "totalSize")]
51	pub total_prospects: usize,
52	#[serde(rename = "returnedSize")]
53	pub returned_prospects: usize,
54	pub offset: usize,
55	pub prospects: Vec<DraftPick>,
56}
57
58#[serde_as]
59#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
60#[serde(rename_all = "camelCase")]
61pub struct DraftPick {
62	/// PlayerId on the EBIS System
63	#[serde(rename = "bisPlayerId")]
64	pub ebis_player_id: Option<EBISPersonId>,
65	#[serde(default, rename = "pickRound")]
66	pub round: String,
67	#[serde(default)]
68	pub pick_number: u32,
69	#[serde(rename = "displayPickNumber")]
70	pub displayed_pick_number: Option<u32>,
71	pub rank: Option<u32>,
72	#[serde(default, deserialize_with = "crate::types::try_from_str")]
73	pub signing_bonus: Option<u32>,
74	pub home: Location,
75	pub scouting_report_url: Option<String>,
76	pub school: School,
77	pub blurb: Option<String>,
78	#[serde(rename = "headshotLink", default = "get_default_headshot")]
79	pub headshot_url: String,
80	#[serde(default = "Person::unknown_person")]
81	pub person: Person,
82	#[serde(default = "Team::unknown_team")]
83	pub team: Team,
84	pub draft_type: DraftType,
85	pub is_drafted: bool,
86	pub is_pass: bool,
87	#[serde_as(as = "DisplayFromStr")]
88	pub year: u32,
89}
90
91#[must_use]
92pub fn get_default_headshot() -> String {
93	"https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:silo:current.png/w_120,q_auto:best/v1/people/0/headshot/draft/current".to_owned()
94}
95
96impl DraftPick {
97	#[must_use]
98	pub fn displayed_pick_number(&self) -> u32 {
99		self.displayed_pick_number.unwrap_or(self.pick_number)
100	}
101}
102
103#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
104#[serde(rename_all = "camelCase")]
105pub struct School {
106	pub name: Option<String>,
107	pub city: Option<String>,
108	pub class: Option<String>,
109	pub country: Option<String>,
110	pub state: Option<String>,
111}
112
113#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
114#[serde(try_from = "__DraftTypeStruct")]
115pub enum DraftType {
116	#[display("June Amateur Draft")]
117	JR,
118	/// Never appears
119	JS,
120	/// Never appears
121	NS,
122	/// Never appears
123	NR,
124	/// Never appears
125	AL,
126	/// Never appears
127	RA,
128	/// Never appears
129	RT,
130	/// Never appears
131	JD,
132	/// Never appears
133	AD,
134}
135
136#[derive(Deserialize)]
137struct __DraftTypeStruct {
138	code: String,
139}
140
141#[derive(Debug, Error)]
142enum DraftTypeParseError {
143	#[error("Invalid draft type code {0}")]
144	InvalidCode(String),
145}
146
147impl TryFrom<__DraftTypeStruct> for DraftType {
148	type Error = DraftTypeParseError;
149
150	fn try_from(value: __DraftTypeStruct) -> Result<Self, Self::Error> {
151		Ok(match &*value.code {
152			"JR" => DraftType::JR,
153			_ => return Err(DraftTypeParseError::InvalidCode(value.code)),
154		})
155	}
156}
157
158/// This endpoint sorts into rounds
159#[derive(Clone)]
160pub struct DraftEndpointUrl {
161	/// Year of the draft.
162	pub year: Option<u32>,
163	/// Kind of request to make.
164	pub kind: DraftEndpointUrlKind,
165}
166
167#[derive(Clone)]
168pub enum DraftEndpointUrlKind {
169	/// Gets the latest draft pick.\
170	/// During the draft, this is the most recent draft pick, however when the draft has ended, this is the last draft pick.
171	Latest,
172	/// A regular draft pick endpoint request.
173	Regular(DraftEndpointUrlData),
174}
175
176#[derive(Clone)]
177pub struct DraftEndpointUrlData {
178	/// Number of results to return.
179	pub limit: Option<u32>,
180	/// Offset in the results (used for pagination).
181	pub offset: Option<u32>,
182	/// Draft round.
183	pub round: Option<u32>,
184
185	/// Include only successfully drafted players
186	pub drafted_only: Option<bool>,
187	/// Filter players by the first character of their last name.
188	pub last_name: Option<char>,
189	/// Filter players by the first character of their school they were drafted from.
190	pub school: Option<char>,
191	/// Filter players by their position.
192	pub position: Option<Position>,
193	/// Filter players by the team they were drafted by.
194	pub team_id: Option<TeamId>,
195	/// Filter players by their home country.
196	pub home_country: Option<String>,
197	/// Filter for a specific player id.
198	pub player_id: Option<PersonId>,
199}
200
201impl Display for DraftEndpointUrl {
202	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
203		match self.kind.clone() {
204			DraftEndpointUrlKind::Latest => write!(f, "http://statsapi.mlb.com/api/v1/draft/{year}/latest", year = self.year.map_or(String::new(), |x| x.to_string())),
205			DraftEndpointUrlKind::Regular(DraftEndpointUrlData {
206				                              limit,
207				                              offset,
208				                              round,
209				                              drafted_only,
210				                              last_name,
211				                              school,
212				                              position,
213				                              team_id,
214				                              home_country,
215				                              player_id,
216			                              }) => write!(
217				f,
218				"http://statsapi.mlb.com/api/v1/draft/{year}{params}",
219				year = self.year.map_or(String::new(), |x| x.to_string()),
220				params = gen_params! {
221					"limit"?: limit,
222					"offset"?: offset,
223					"round"?: round,
224					"drafted"?: drafted_only,
225					"name"?: last_name,
226					"school"?: school,
227					"position"?: position.as_ref().map(|pos| &pos.code),
228					"teamId"?: team_id,
229					"homeCountry"?: home_country,
230					"playerId"?: player_id,
231				}
232			),
233		}
234	}
235}
236
237impl StatsAPIUrl for DraftEndpointUrl {
238	type Response = DraftResponse;
239}
240
241// todo: make type system allow for only the `Regular` variant here
242/// This endpoint gives a list of prospects.
243pub struct DraftProspectsEndpointUrl {
244	/// Year of the draft.
245	pub year: Option<u32>,
246	/// Kind of request to make.
247	pub kind: DraftEndpointUrlData,
248}
249
250impl Display for DraftProspectsEndpointUrl {
251	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
252		let DraftEndpointUrlData {
253			limit,
254			offset,
255			round,
256			drafted_only,
257			last_name,
258			school,
259			position,
260			team_id,
261			home_country,
262			player_id,
263		} = &self.kind;
264		write!(
265			f,
266			"http://statsapi.mlb.com/api/v1/draft/prospects/{year}{params}",
267			year = self.year.map_or(String::new(), |x| x.to_string()),
268			params = gen_params! {
269						"limit"?: limit,
270						"offset"?: offset,
271						"round"?: round,
272						"drafted"?: drafted_only,
273						"name"?: last_name,
274						"school"?: school,
275						"position"?: position.as_ref().map(|pos| &pos.code),
276						"teamId"?: team_id,
277						"homeCountry"?: home_country,
278						"playerId"?: player_id,
279				}
280		)
281	}
282}
283
284impl StatsAPIUrl for DraftProspectsEndpointUrl {
285	type Response = DraftProspectsResponse;
286}
287
288#[cfg(test)]
289mod tests {
290	use crate::endpoints::StatsAPIUrl;
291	use crate::endpoints::draft::{DraftEndpointUrl, DraftEndpointUrlKind, DraftProspectsEndpointUrl, DraftEndpointUrlData};
292	use chrono::{Datelike, Local};
293
294	#[tokio::test]
295	async fn draft_2025() {
296		let _ = DraftEndpointUrl {
297			year: Some(2025),
298			kind: DraftEndpointUrlKind::Regular(DraftEndpointUrlData {
299				limit: None,
300				offset: None,
301				round: None,
302				drafted_only: None,
303				last_name: None,
304				school: None,
305				position: None,
306				team_id: None,
307				home_country: None,
308				player_id: None,
309			}),
310		}
311			.get()
312			.await
313			.unwrap();
314
315		let _ = DraftProspectsEndpointUrl {
316			year: Some(2025),
317			kind: DraftEndpointUrlData {
318				limit: None,
319				offset: None,
320				round: None,
321				drafted_only: None,
322				last_name: None,
323				school: None,
324				position: None,
325				team_id: None,
326				home_country: None,
327				player_id: None,
328			},
329		}
330			.get()
331			.await
332			.unwrap();
333	}
334
335	#[tokio::test]
336	#[cfg_attr(not(feature = "_heavy_tests"), ignore)]
337	async fn draft_all_years() {
338		for year in 1965..=Local::now().year() as _ {
339			let json = reqwest::get(
340				DraftEndpointUrl {
341					year: Some(year),
342					kind: DraftEndpointUrlKind::Regular(DraftEndpointUrlData {
343						limit: None,
344						offset: None,
345						round: None,
346						drafted_only: None,
347						last_name: None,
348						school: None,
349						position: None,
350						team_id: None,
351						home_country: None,
352						player_id: None,
353					}),
354				}
355					.to_string(),
356			)
357				.await
358				.unwrap()
359				.bytes()
360				.await
361				.unwrap();
362			let mut de = serde_json::Deserializer::from_slice(&json);
363			let result: Result<<DraftEndpointUrl as StatsAPIUrl>::Response, serde_path_to_error::Error<_>> = serde_path_to_error::deserialize(&mut de);
364			match result {
365				Ok(_) => {}
366				Err(e) if format!("{:?}", e.inner()).contains("missing field `copyright`") => {}
367				Err(e) => panic!("Err: {:?} (yr: {year})", e),
368			}
369
370			let json = reqwest::get(
371				DraftProspectsEndpointUrl {
372					year: Some(year),
373					kind: DraftEndpointUrlData {
374						limit: None,
375						offset: None,
376						round: None,
377						drafted_only: None,
378						last_name: None,
379						school: None,
380						position: None,
381						team_id: None,
382						home_country: None,
383						player_id: None,
384					},
385				}
386					.to_string(),
387			)
388				.await
389				.unwrap()
390				.bytes()
391				.await
392				.unwrap();
393			let mut de = serde_json::Deserializer::from_slice(&json);
394			let result: Result<<DraftProspectsEndpointUrl as StatsAPIUrl>::Response, serde_path_to_error::Error<_>> = serde_path_to_error::deserialize(&mut de);
395			match result {
396				Ok(_) => {}
397				Err(e) if format!("{:?}", e.inner()).contains("missing field `copyright`") => {}
398				Err(e) => panic!("Err: {:?} (yr: {year})", e),
399			}
400		}
401	}
402}