1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::error::DataApiError;
5
6#[derive(Clone)]
8pub struct BuildersApi {
9 pub(crate) http_client: HttpClient,
10}
11
12impl BuildersApi {
13 pub fn leaderboard(&self) -> GetBuilderLeaderboard {
15 let request = Request::new(self.http_client.clone(), "/v1/builders/leaderboard");
16
17 GetBuilderLeaderboard { request }
18 }
19
20 pub fn volume(&self) -> GetBuilderVolume {
22 let request = Request::new(self.http_client.clone(), "/v1/builders/volume");
23
24 GetBuilderVolume { request }
25 }
26}
27
28pub struct GetBuilderLeaderboard {
30 request: Request<Vec<BuilderRanking>, DataApiError>,
31}
32
33impl GetBuilderLeaderboard {
34 pub fn time_period(mut self, period: TimePeriod) -> Self {
36 self.request = self.request.query("timePeriod", period);
37 self
38 }
39
40 pub fn limit(mut self, limit: u32) -> Self {
42 self.request = self.request.query("limit", limit);
43 self
44 }
45
46 pub fn offset(mut self, offset: u32) -> Self {
48 self.request = self.request.query("offset", offset);
49 self
50 }
51
52 pub async fn send(self) -> Result<Vec<BuilderRanking>, DataApiError> {
54 self.request.send().await
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
60#[serde(rename_all = "UPPERCASE")]
61pub enum TimePeriod {
62 #[default]
64 Day,
65 Week,
67 Month,
69 All,
71}
72
73impl std::fmt::Display for TimePeriod {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 Self::Day => write!(f, "DAY"),
77 Self::Week => write!(f, "WEEK"),
78 Self::Month => write!(f, "MONTH"),
79 Self::All => write!(f, "ALL"),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all(deserialize = "camelCase"))]
87pub struct BuilderRanking {
88 pub rank: String,
90 pub builder: String,
92 pub volume: f64,
94 pub active_users: u64,
96 pub verified: bool,
98 pub builder_logo: Option<String>,
100}
101
102pub struct GetBuilderVolume {
104 request: Request<Vec<BuilderVolume>, DataApiError>,
105}
106
107impl GetBuilderVolume {
108 pub fn time_period(mut self, period: TimePeriod) -> Self {
110 self.request = self.request.query("timePeriod", period);
111 self
112 }
113
114 pub async fn send(self) -> Result<Vec<BuilderVolume>, DataApiError> {
116 self.request.send().await
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all(deserialize = "camelCase"))]
123pub struct BuilderVolume {
124 pub dt: String,
126 pub builder: String,
128 pub builder_logo: Option<String>,
130 pub verified: bool,
132 pub volume: f64,
134 pub active_users: u64,
136 pub rank: String,
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn time_period_display_matches_serde() {
146 let variants = [
147 TimePeriod::Day,
148 TimePeriod::Week,
149 TimePeriod::Month,
150 TimePeriod::All,
151 ];
152 for variant in variants {
153 let serialized = serde_json::to_value(variant).unwrap();
154 let display = variant.to_string();
155 assert_eq!(
156 format!("\"{}\"", display),
157 serialized.to_string(),
158 "Display mismatch for {:?}",
159 variant
160 );
161 }
162 }
163
164 #[test]
165 fn time_period_serde_roundtrip() {
166 for variant in [
167 TimePeriod::Day,
168 TimePeriod::Week,
169 TimePeriod::Month,
170 TimePeriod::All,
171 ] {
172 let json = serde_json::to_string(&variant).unwrap();
173 let deserialized: TimePeriod = serde_json::from_str(&json).unwrap();
174 assert_eq!(variant, deserialized);
175 }
176 }
177
178 #[test]
179 fn time_period_default_is_day() {
180 assert_eq!(TimePeriod::default(), TimePeriod::Day);
181 }
182
183 #[test]
184 fn time_period_specific_values() {
185 assert_eq!(TimePeriod::Day.to_string(), "DAY");
186 assert_eq!(TimePeriod::Week.to_string(), "WEEK");
187 assert_eq!(TimePeriod::Month.to_string(), "MONTH");
188 assert_eq!(TimePeriod::All.to_string(), "ALL");
189 }
190
191 #[test]
192 fn deserialize_builder_ranking() {
193 let json = r#"{
194 "rank": "1",
195 "builder": "polymarket-app",
196 "volume": 1500000.50,
197 "activeUsers": 25000,
198 "verified": true,
199 "builderLogo": "https://example.com/logo.png"
200 }"#;
201
202 let ranking: BuilderRanking = serde_json::from_str(json).unwrap();
203 assert_eq!(ranking.rank, "1");
204 assert_eq!(ranking.builder, "polymarket-app");
205 assert!((ranking.volume - 1500000.50).abs() < f64::EPSILON);
206 assert_eq!(ranking.active_users, 25000);
207 assert!(ranking.verified);
208 assert_eq!(
209 ranking.builder_logo,
210 Some("https://example.com/logo.png".to_string())
211 );
212 }
213
214 #[test]
215 fn deserialize_builder_ranking_null_logo() {
216 let json = r#"{
217 "rank": "5",
218 "builder": "unknown-builder",
219 "volume": 100.0,
220 "activeUsers": 10,
221 "verified": false,
222 "builderLogo": null
223 }"#;
224
225 let ranking: BuilderRanking = serde_json::from_str(json).unwrap();
226 assert_eq!(ranking.rank, "5");
227 assert!(!ranking.verified);
228 assert!(ranking.builder_logo.is_none());
229 }
230
231 #[test]
232 fn deserialize_builder_volume() {
233 let json = r#"{
234 "dt": "2025-01-15T00:00:00Z",
235 "builder": "top-builder",
236 "builderLogo": null,
237 "verified": true,
238 "volume": 500000.0,
239 "activeUsers": 1200,
240 "rank": "3"
241 }"#;
242
243 let vol: BuilderVolume = serde_json::from_str(json).unwrap();
244 assert_eq!(vol.dt, "2025-01-15T00:00:00Z");
245 assert_eq!(vol.builder, "top-builder");
246 assert!(vol.verified);
247 assert!((vol.volume - 500000.0).abs() < f64::EPSILON);
248 assert_eq!(vol.active_users, 1200);
249 assert_eq!(vol.rank, "3");
250 assert!(vol.builder_logo.is_none());
251 }
252
253 #[test]
254 fn deserialize_builder_ranking_list() {
255 let json = r#"[
256 {"rank": "1", "builder": "a", "volume": 100.0, "activeUsers": 5, "verified": true, "builderLogo": null},
257 {"rank": "2", "builder": "b", "volume": 50.0, "activeUsers": 3, "verified": false, "builderLogo": null}
258 ]"#;
259
260 let rankings: Vec<BuilderRanking> = serde_json::from_str(json).unwrap();
261 assert_eq!(rankings.len(), 2);
262 assert_eq!(rankings[0].rank, "1");
263 assert_eq!(rankings[1].rank, "2");
264 }
265}