Skip to main content

cp_cli_platform_codeforces/
client.rs

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    /// Fetch bounded public problem metadata from Codeforces
32    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    /// Resolve one public problem from its canonical contest ID and index
73    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    /// Fetch the next and most recent public non-gym contests
99    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    /// Fetch one public Codeforces account profile
115    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    /// Fetch a public Codeforces rating history
132    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    /// Fetch up to 100 public submissions, newest first
151    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    /// Verify Codeforces API credentials with the authorized `user.friends` endpoint
177    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    /// Fetch up to 20 public blog entries from Codeforces' recent activity feed
191    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    /// Fetch a public blog entry and its comments
221    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        // Codeforces documents an anonymous API ceiling of one request every two seconds
233        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}