1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3use reqwest::{Client as HttpClient, ClientBuilder, redirect::Policy};
4use serde::Deserialize;
5use sha2::{Digest, Sha512};
6
7use crate::{
8 ApiCredentials, Contest, Discussion, DiscussionComment, DiscussionPreview, Error, Problem,
9 RatingChange, Submission, UserProfile,
10};
11
12pub(crate) const MAX_RESPONSE_BYTES: usize = 3 * 1024 * 1024;
13const MAX_PROBLEMS: usize = 50_000;
14const MAX_CONTESTS: usize = 100;
15const MAX_DISCUSSIONS: usize = 20;
16const MAX_COMMENTS: usize = 1_000;
17
18pub struct Client {
19 pub(crate) http: HttpClient,
20 pub(crate) endpoint: Box<str>,
21}
22
23impl Client {
24 pub fn new() -> Result<Self, Error> {
25 Ok(Self {
26 http: http_builder().build()?,
27 endpoint: "https://codeforces.com/api".into(),
28 })
29 }
30
31 pub async fn problems(
33 &self,
34 progress: impl FnMut(usize, Option<u64>),
35 ) -> Result<Vec<Problem>, Error> {
36 let response = self
37 .http
38 .get(format!("{}/problemset.problems?lang=en", self.endpoint))
39 .send()
40 .await?;
41 if !response.status().is_success() {
42 return Err(Error::Status(response.status()));
43 }
44
45 let response: ApiResponse<ProblemSet> = read_response(response, progress).await?;
46 let result = match (response.status.as_ref(), response.result) {
47 ("OK", Some(result)) => result,
48 _ => return Err(Error::ApiFailure),
49 };
50 if result.problems.len() > MAX_PROBLEMS {
51 return Err(Error::ApiFailure);
52 }
53
54 let statistics = result
55 .problem_statistics
56 .into_iter()
57 .filter_map(|statistic| {
58 Some((
59 (statistic.contest_id?, statistic.index),
60 statistic.solved_count,
61 ))
62 })
63 .collect::<std::collections::HashMap<_, _>>();
64
65 Ok(result
66 .problems
67 .into_iter()
68 .filter_map(|problem| problem.into_problem(&statistics))
69 .collect())
70 }
71
72 pub async fn problem(
74 &self,
75 contest_id: u32,
76 index: &str,
77 progress: impl FnMut(usize, Option<u64>),
78 ) -> Result<Problem, Error> {
79 if contest_id == 0 {
80 return Err(Error::InvalidContestId);
81 }
82 if !valid_index(index) {
83 return Err(Error::InvalidIndex);
84 }
85
86 self.problems(progress)
87 .await?
88 .into_iter()
89 .find(|problem| {
90 problem.contest_id == contest_id && problem.index.eq_ignore_ascii_case(index)
91 })
92 .ok_or_else(|| Error::NotFound {
93 contest_id,
94 index: index.into(),
95 })
96 }
97
98 pub async fn contests(
100 &self,
101 progress: impl FnMut(usize, Option<u64>),
102 ) -> Result<Vec<Contest>, Error> {
103 let result: Vec<ContestData> = self.get("contest.list?gym=false", progress).await?;
104 if result.len() > MAX_PROBLEMS {
105 return Err(Error::ApiFailure);
106 }
107 Ok(result
108 .into_iter()
109 .take(MAX_CONTESTS)
110 .map(ContestData::into_contest)
111 .collect())
112 }
113
114 pub async fn user_profile(
116 &self,
117 handle: &str,
118 progress: impl FnMut(usize, Option<u64>),
119 ) -> Result<UserProfile, Error> {
120 validate_handle(handle)?;
121 let result: Vec<UserData> = self
122 .get(&format!("user.info?handles={handle}"), progress)
123 .await?;
124 result
125 .into_iter()
126 .next()
127 .map(UserData::into_profile)
128 .ok_or(Error::ApiFailure)
129 }
130
131 pub async fn rating_history(
133 &self,
134 handle: &str,
135 progress: impl FnMut(usize, Option<u64>),
136 ) -> Result<Vec<RatingChange>, Error> {
137 validate_handle(handle)?;
138 let result: Vec<RatingChangeData> = self
139 .get(&format!("user.rating?handle={handle}"), progress)
140 .await?;
141 if result.len() > MAX_PROBLEMS {
142 return Err(Error::ApiFailure);
143 }
144 Ok(result
145 .into_iter()
146 .map(RatingChangeData::into_rating_change)
147 .collect())
148 }
149
150 pub async fn submissions(
152 &self,
153 handle: &str,
154 count: u8,
155 progress: impl FnMut(usize, Option<u64>),
156 ) -> Result<Vec<Submission>, Error> {
157 validate_handle(handle)?;
158 if count == 0 || count > 100 {
159 return Err(Error::InvalidSubmissionCount);
160 }
161 let result: Vec<SubmissionData> = self
162 .get(
163 &format!("user.status?handle={handle}&from=1&count={count}"),
164 progress,
165 )
166 .await?;
167 if result.len() > usize::from(count) {
168 return Err(Error::ApiFailure);
169 }
170 Ok(result
171 .into_iter()
172 .map(SubmissionData::into_submission)
173 .collect())
174 }
175
176 pub async fn authenticated_friends(
178 &self,
179 credentials: &ApiCredentials,
180 progress: impl FnMut(usize, Option<u64>),
181 ) -> Result<Vec<Box<str>>, Error> {
182 let time = SystemTime::now()
183 .duration_since(UNIX_EPOCH)
184 .map_err(|_| Error::Clock)?
185 .as_secs();
186 let query = signed_friends_query(credentials, time, &signature_prefix()?);
187 self.get(&format!("user.friends?{query}"), progress).await
188 }
189
190 pub async fn recent_discussions(
192 &self,
193 progress: impl FnMut(usize, Option<u64>),
194 ) -> Result<Vec<DiscussionPreview>, Error> {
195 let result: Vec<RecentActionData> = self.get("recentActions?maxCount=20", progress).await?;
196 if result.len() > MAX_DISCUSSIONS {
197 return Err(Error::ApiFailure);
198 }
199
200 let mut seen = std::collections::HashSet::new();
201 Ok(result
202 .into_iter()
203 .filter_map(|action| {
204 let entry = action.blog_entry?;
205 seen.insert(entry.id).then(|| DiscussionPreview {
206 id: entry.id,
207 author: entry.author_handle,
208 title: entry.title,
209 created_time_seconds: entry.creation_time_seconds,
210 activity_time_seconds: action.time_seconds,
211 rating: entry.rating,
212 tags: entry.tags,
213 latest_comment: action.comment.map(CommentData::into_comment),
214 url: format!("https://codeforces.com/blog/entry/{}", entry.id).into(),
215 })
216 })
217 .collect())
218 }
219
220 pub async fn discussion(
222 &self,
223 id: u32,
224 mut progress: impl FnMut(usize, Option<u64>),
225 ) -> Result<Discussion, Error> {
226 if id == 0 {
227 return Err(Error::InvalidBlogEntryId);
228 }
229 let entry: BlogEntryData = self
230 .get(&format!("blogEntry.view?blogEntryId={id}"), &mut progress)
231 .await?;
232 tokio::time::sleep(Duration::from_secs(2)).await;
234 let comments: Vec<CommentData> = self
235 .get(
236 &format!("blogEntry.comments?blogEntryId={id}"),
237 &mut progress,
238 )
239 .await?;
240 if comments.len() > MAX_COMMENTS {
241 return Err(Error::ApiFailure);
242 }
243 let content_html = entry.content.ok_or(Error::ApiFailure)?;
244 Ok(Discussion {
245 id: entry.id,
246 author: entry.author_handle,
247 title: entry.title,
248 content_html,
249 created_time_seconds: entry.creation_time_seconds,
250 modified_time_seconds: entry.modification_time_seconds,
251 rating: entry.rating,
252 tags: entry.tags,
253 comments: comments
254 .into_iter()
255 .map(CommentData::into_comment)
256 .collect(),
257 url: format!("https://codeforces.com/blog/entry/{}", entry.id).into(),
258 })
259 }
260
261 async fn get<T: for<'de> Deserialize<'de>>(
262 &self,
263 path_and_query: &str,
264 progress: impl FnMut(usize, Option<u64>),
265 ) -> Result<T, Error> {
266 let response = self
267 .http
268 .get(format!("{}/{}", self.endpoint, path_and_query))
269 .send()
270 .await?;
271 if !response.status().is_success() {
272 return Err(Error::Status(response.status()));
273 }
274 let response: ApiResponse<T> = read_response(response, progress).await?;
275 match (response.status.as_ref(), response.result) {
276 ("OK", Some(result)) => Ok(result),
277 _ => Err(Error::ApiFailure),
278 }
279 }
280}
281
282fn signature_prefix() -> Result<String, Error> {
283 let mut bytes = [0_u8; 3];
284 getrandom::fill(&mut bytes).map_err(|_| Error::SignatureNonce)?;
285 Ok(format!("{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2]))
286}
287
288fn signed_friends_query(credentials: &ApiCredentials, time: u64, prefix: &str) -> String {
289 let query = format!("apiKey={}&onlyOnline=true&time={time}", credentials.key);
290 let signature = format!("{prefix}/user.friends?{query}#{}", credentials.secret);
291 let hash = Sha512::digest(signature.as_bytes());
292 format!("{query}&apiSig={prefix}{hash:x}")
293}
294
295pub(crate) fn http_builder() -> ClientBuilder {
296 HttpClient::builder()
297 .https_only(true)
298 .connect_timeout(Duration::from_secs(10))
299 .read_timeout(Duration::from_secs(20))
300 .timeout(Duration::from_secs(30))
301 .redirect(Policy::none())
302 .retry(reqwest::retry::never())
303 .no_brotli()
304 .no_deflate()
305 .no_zstd()
306 .pool_max_idle_per_host(2)
307 .user_agent(concat!(
308 env!("CARGO_PKG_NAME"),
309 "/",
310 env!("CARGO_PKG_VERSION")
311 ))
312}
313
314async fn read_response<T: for<'de> Deserialize<'de>>(
315 mut response: reqwest::Response,
316 mut progress: impl FnMut(usize, Option<u64>),
317) -> Result<T, Error> {
318 if response
319 .content_length()
320 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
321 {
322 return Err(Error::ResponseTooLarge {
323 limit: MAX_RESPONSE_BYTES,
324 });
325 }
326
327 let total = response.content_length();
328 let mut body = Vec::with_capacity(8 * 1024);
329 progress(0, total);
330 while let Some(chunk) = response.chunk().await? {
331 if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
332 return Err(Error::ResponseTooLarge {
333 limit: MAX_RESPONSE_BYTES,
334 });
335 }
336 body.extend_from_slice(&chunk);
337 progress(body.len(), total);
338 }
339 Ok(serde_json::from_slice(&body)?)
340}
341
342fn valid_index(index: &str) -> bool {
343 !index.is_empty() && index.len() <= 16 && index.bytes().all(|byte| byte.is_ascii_alphanumeric())
344}
345
346fn validate_handle(handle: &str) -> Result<(), Error> {
347 if handle.is_empty()
348 || handle.len() > 24
349 || !handle
350 .bytes()
351 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
352 {
353 return Err(Error::InvalidHandle);
354 }
355 Ok(())
356}
357
358#[derive(Deserialize)]
359struct ApiResponse<T> {
360 status: Box<str>,
361 result: Option<T>,
362}
363
364#[derive(Deserialize)]
365#[serde(rename_all = "camelCase")]
366struct ProblemSet {
367 problems: Vec<ProblemData>,
368 #[serde(default)]
369 problem_statistics: Vec<ProblemStatistics>,
370}
371
372#[derive(Deserialize)]
373#[serde(rename_all = "camelCase")]
374struct ProblemData {
375 contest_id: Option<u32>,
376 index: Box<str>,
377 name: Box<str>,
378 rating: Option<u16>,
379 #[serde(default)]
380 tags: Vec<Box<str>>,
381}
382
383impl ProblemData {
384 fn into_problem(
385 self,
386 statistics: &std::collections::HashMap<(u32, Box<str>), u32>,
387 ) -> Option<Problem> {
388 let contest_id = self.contest_id?;
389 if !valid_index(&self.index) {
390 return None;
391 }
392 let solved_count = statistics.get(&(contest_id, self.index.clone())).copied();
393 let url = format!(
394 "https://codeforces.com/problemset/problem/{contest_id}/{}",
395 self.index
396 );
397 Some(Problem {
398 contest_id,
399 index: self.index,
400 title: self.name,
401 rating: self.rating,
402 tags: self.tags,
403 solved_count,
404 url: url.into(),
405 statement: None,
406 })
407 }
408}
409
410#[derive(Deserialize)]
411#[serde(rename_all = "camelCase")]
412struct ProblemStatistics {
413 contest_id: Option<u32>,
414 index: Box<str>,
415 solved_count: u32,
416}
417
418#[derive(Deserialize)]
419#[serde(rename_all = "camelCase")]
420struct ContestData {
421 id: u32,
422 name: Box<str>,
423 #[serde(rename = "type")]
424 kind: Box<str>,
425 phase: Box<str>,
426 start_time_seconds: Option<i64>,
427 duration_seconds: Option<u32>,
428}
429
430impl ContestData {
431 fn into_contest(self) -> Contest {
432 Contest {
433 id: self.id,
434 name: self.name,
435 kind: self.kind,
436 phase: self.phase,
437 start_time_seconds: self.start_time_seconds,
438 duration_seconds: self.duration_seconds,
439 }
440 }
441}
442
443#[derive(Deserialize)]
444#[serde(rename_all = "camelCase")]
445struct UserData {
446 handle: Box<str>,
447 first_name: Option<Box<str>>,
448 last_name: Option<Box<str>>,
449 country: Option<Box<str>>,
450 city: Option<Box<str>>,
451 organization: Option<Box<str>>,
452 rank: Option<Box<str>>,
453 rating: Option<i32>,
454 max_rank: Option<Box<str>>,
455 max_rating: Option<i32>,
456 contribution: i32,
457 friend_of_count: u32,
458 registration_time_seconds: i64,
459 last_online_time_seconds: i64,
460}
461
462impl UserData {
463 fn into_profile(self) -> UserProfile {
464 UserProfile {
465 handle: self.handle,
466 first_name: self.first_name,
467 last_name: self.last_name,
468 country: self.country,
469 city: self.city,
470 organization: self.organization,
471 rank: self.rank,
472 rating: self.rating,
473 max_rank: self.max_rank,
474 max_rating: self.max_rating,
475 contribution: self.contribution,
476 friend_of_count: self.friend_of_count,
477 registration_time_seconds: self.registration_time_seconds,
478 last_online_time_seconds: self.last_online_time_seconds,
479 }
480 }
481}
482
483#[derive(Deserialize)]
484#[serde(rename_all = "camelCase")]
485struct RatingChangeData {
486 contest_id: u32,
487 contest_name: Box<str>,
488 rank: u32,
489 old_rating: i32,
490 new_rating: i32,
491 rating_update_time_seconds: i64,
492}
493
494impl RatingChangeData {
495 fn into_rating_change(self) -> RatingChange {
496 RatingChange {
497 contest_id: self.contest_id,
498 contest_name: self.contest_name,
499 rank: self.rank,
500 old_rating: self.old_rating,
501 new_rating: self.new_rating,
502 rating_update_time_seconds: self.rating_update_time_seconds,
503 }
504 }
505}
506
507#[derive(Deserialize)]
508#[serde(rename_all = "camelCase")]
509struct SubmissionData {
510 id: u64,
511 contest_id: Option<u32>,
512 creation_time_seconds: i64,
513 relative_time_seconds: i64,
514 problem: SubmissionProblemData,
515 programming_language: Box<str>,
516 verdict: Option<Box<str>>,
517 testset: Box<str>,
518 passed_test_count: u32,
519 time_consumed_millis: u32,
520 memory_consumed_bytes: u64,
521}
522
523#[derive(Deserialize)]
524struct SubmissionProblemData {
525 index: Box<str>,
526 name: Box<str>,
527}
528
529impl SubmissionData {
530 fn into_submission(self) -> Submission {
531 Submission {
532 id: self.id,
533 contest_id: self.contest_id,
534 creation_time_seconds: self.creation_time_seconds,
535 relative_time_seconds: self.relative_time_seconds,
536 problem_index: self.problem.index,
537 problem_name: self.problem.name,
538 programming_language: self.programming_language,
539 verdict: self.verdict,
540 testset: self.testset,
541 passed_test_count: self.passed_test_count,
542 time_consumed_millis: self.time_consumed_millis,
543 memory_consumed_bytes: self.memory_consumed_bytes,
544 }
545 }
546}
547
548#[derive(Deserialize)]
549#[serde(rename_all = "camelCase")]
550struct RecentActionData {
551 time_seconds: i64,
552 blog_entry: Option<BlogEntryData>,
553 comment: Option<CommentData>,
554}
555
556#[derive(Deserialize)]
557#[serde(rename_all = "camelCase")]
558struct BlogEntryData {
559 id: u32,
560 creation_time_seconds: i64,
561 author_handle: Box<str>,
562 title: Box<str>,
563 content: Option<Box<str>>,
564 modification_time_seconds: Option<i64>,
565 #[serde(default)]
566 tags: Vec<Box<str>>,
567 rating: i32,
568}
569
570#[derive(Deserialize)]
571#[serde(rename_all = "camelCase")]
572struct CommentData {
573 id: u32,
574 creation_time_seconds: i64,
575 commentator_handle: Box<str>,
576 text: Box<str>,
577 parent_comment_id: Option<u32>,
578 rating: i32,
579}
580
581impl CommentData {
582 fn into_comment(self) -> DiscussionComment {
583 DiscussionComment {
584 id: self.id,
585 author: self.commentator_handle,
586 content_html: self.text,
587 created_time_seconds: self.creation_time_seconds,
588 parent_comment_id: self.parent_comment_id,
589 rating: self.rating,
590 }
591 }
592}