Skip to main content

cp_cli_platform_hackerrank/
client.rs

1use std::{collections::BTreeMap, time::Duration};
2
3use reqwest::{Client as HttpClient, ClientBuilder, redirect::Policy};
4use serde::{Deserialize, de::DeserializeOwned};
5
6use crate::{
7    Difficulty, Error, Problem, ProblemList, ProblemSummary, Profile, Sample, Starter, Track,
8};
9
10pub(crate) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
11const MAX_STATEMENT_BYTES: usize = 512 * 1024;
12const MAX_TEXT_BYTES: usize = 128 * 1024;
13const MAX_LANGUAGES: usize = 64;
14const MAX_STARTERS: usize = 64;
15const MAX_SAMPLES: usize = 16;
16const MAX_TEMPLATE_BYTES: usize = 128 * 1024;
17const MAX_SAMPLE_BYTES: usize = 64 * 1024;
18const MAX_PROFILE_TEXT_BYTES: usize = 256;
19const MAX_PROFILE_LEVEL: u32 = 100_000;
20const MAX_PROFILE_EVENTS: u32 = 10_000_000;
21const MAX_LIST_LIMIT: u8 = 20;
22const MAX_OFFSET: u32 = 1_000_000;
23const MAX_TOTAL: u64 = 10_000_000;
24const MAX_PROBLEMS: usize = MAX_LIST_LIMIT as usize;
25
26pub struct Client {
27    pub(crate) http: HttpClient,
28    pub(crate) endpoint: Box<str>,
29}
30
31impl Client {
32    pub fn new() -> Result<Self, Error> {
33        Ok(Self {
34            http: http_builder().build()?,
35            endpoint: "https://www.hackerrank.com/".into(),
36        })
37    }
38
39    /// List a public HackerRank Community track
40    pub async fn list_track(
41        &self,
42        track: &str,
43        offset: u32,
44        limit: u8,
45        progress: impl FnMut(usize, Option<u64>),
46    ) -> Result<ProblemList, Error> {
47        if !valid_slug(track) {
48            return Err(Error::InvalidSlug);
49        }
50        if offset > MAX_OFFSET || limit == 0 || limit > MAX_LIST_LIMIT {
51            return Err(Error::InvalidPage);
52        }
53
54        let path = format!("rest/contests/master/tracks/{track}/challenges");
55        let response: ListResponse = self
56            .get_json(
57                &path,
58                &[
59                    ("offset", offset.to_string()),
60                    ("limit", limit.to_string()),
61                    ("track_login", "true".to_owned()),
62                ],
63                progress,
64            )
65            .await?;
66        list_from_response(response)
67    }
68
69    /// Fetch one public HackerRank Community problem from the master contest
70    pub async fn problem(
71        &self,
72        slug: &str,
73        progress: impl FnMut(usize, Option<u64>),
74    ) -> Result<Problem, Error> {
75        if !valid_slug(slug) {
76            return Err(Error::InvalidSlug);
77        }
78
79        let path = format!("rest/contests/master/challenges/{slug}");
80        let response: ProblemResponse = self.get_json(&path, &[], progress).await?;
81        if !response.status {
82            return Err(Error::NotFound);
83        }
84        let model = response.model.ok_or(Error::NotFound)?;
85        problem_from_model(model, Some(slug))
86    }
87
88    /// Fetch a public HackerRank profile without using browser credentials
89    pub async fn profile(
90        &self,
91        username: &str,
92        progress: impl FnMut(usize, Option<u64>),
93    ) -> Result<Profile, Error> {
94        if !valid_username(username) {
95            return Err(Error::InvalidUsername);
96        }
97        let path = format!("rest/contests/master/hackers/{username}/profile");
98        let response: ProfileResponse = self.get_json(&path, &[], progress).await?;
99        profile_from_model(response.model.ok_or(Error::NotFound)?)
100    }
101
102    async fn get_json<T: DeserializeOwned>(
103        &self,
104        path: &str,
105        query: &[(&'static str, String)],
106        progress: impl FnMut(usize, Option<u64>),
107    ) -> Result<T, Error> {
108        let mut url = self.endpoint_path(path)?;
109        if !query.is_empty() {
110            url.query_pairs_mut().extend_pairs(query);
111        }
112        let response = self.http.get(url).send().await?;
113        if response.status() == reqwest::StatusCode::NOT_FOUND {
114            return Err(Error::NotFound);
115        }
116        if !response.status().is_success() {
117            return Err(Error::Status(response.status()));
118        }
119        read_response(response, progress).await
120    }
121
122    fn endpoint_path(&self, path: &str) -> Result<reqwest::Url, Error> {
123        let mut url = reqwest::Url::parse(&self.endpoint).map_err(|_| Error::InvalidResponse)?;
124        url.set_path(path);
125        url.set_query(None);
126        Ok(url)
127    }
128}
129
130pub(crate) fn http_builder() -> ClientBuilder {
131    HttpClient::builder()
132        .https_only(true)
133        .connect_timeout(Duration::from_secs(10))
134        .read_timeout(Duration::from_secs(20))
135        .timeout(Duration::from_secs(30))
136        .redirect(Policy::none())
137        .retry(reqwest::retry::never())
138        .no_gzip()
139        .no_brotli()
140        .no_deflate()
141        .no_zstd()
142        .pool_max_idle_per_host(2)
143        .user_agent(concat!(
144            env!("CARGO_PKG_NAME"),
145            "/",
146            env!("CARGO_PKG_VERSION")
147        ))
148}
149
150async fn read_response<T: DeserializeOwned>(
151    mut response: reqwest::Response,
152    mut progress: impl FnMut(usize, Option<u64>),
153) -> Result<T, Error> {
154    if response
155        .content_length()
156        .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
157    {
158        return Err(Error::ResponseTooLarge {
159            limit: MAX_RESPONSE_BYTES,
160        });
161    }
162
163    let total = response.content_length();
164    let mut body = Vec::with_capacity(8 * 1024);
165    progress(0, total);
166    while let Some(chunk) = response.chunk().await? {
167        if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
168            return Err(Error::ResponseTooLarge {
169                limit: MAX_RESPONSE_BYTES,
170            });
171        }
172        body.extend_from_slice(&chunk);
173        progress(body.len(), total);
174    }
175    Ok(serde_json::from_slice(&body)?)
176}
177
178#[derive(Deserialize)]
179struct ListResponse {
180    models: Vec<ChallengeModel>,
181    total: u64,
182}
183
184#[derive(Deserialize)]
185struct ProblemResponse {
186    status: bool,
187    model: Option<ChallengeModel>,
188}
189
190#[derive(Deserialize)]
191struct ProfileResponse {
192    model: Option<ProfileModel>,
193}
194
195#[derive(Deserialize)]
196struct ProfileModel {
197    id: u64,
198    username: Box<str>,
199    name: Option<Box<str>>,
200    country: Option<Box<str>>,
201    level: Option<u32>,
202    event_count: Option<u32>,
203    created_at: Option<Box<str>>,
204}
205
206#[derive(Deserialize)]
207struct ChallengeModel {
208    id: u64,
209    slug: Box<str>,
210    name: Box<str>,
211    difficulty_name: Option<Box<str>>,
212    preview: Option<Box<str>>,
213    track: Option<TrackModel>,
214    problem_statement: Option<Box<str>>,
215    input_format: Option<Box<str>>,
216    output_format: Option<Box<str>>,
217    #[serde(default)]
218    languages: Vec<Box<str>>,
219    #[serde(default)]
220    onboarding: Option<BTreeMap<Box<str>, OnboardingEntry>>,
221    #[serde(default)]
222    sample_test_cases: Option<Vec<SampleModel>>,
223    #[serde(default)]
224    public_test_cases: bool,
225}
226
227#[derive(Deserialize)]
228#[serde(untagged)]
229enum OnboardingEntry {
230    Template { template: Option<Box<str>> },
231    Text(Box<str>),
232}
233
234#[derive(Deserialize)]
235struct SampleModel {
236    input: Box<str>,
237    output: Box<str>,
238}
239
240#[derive(Deserialize)]
241struct TrackModel {
242    slug: Box<str>,
243    name: Box<str>,
244    track_slug: Box<str>,
245    track_name: Box<str>,
246}
247
248fn list_from_response(response: ListResponse) -> Result<ProblemList, Error> {
249    if response.total > MAX_TOTAL || response.models.len() > MAX_PROBLEMS {
250        return Err(Error::InvalidResponse);
251    }
252    let problems = response
253        .models
254        .into_iter()
255        .map(|model| summary_from_model(model, None))
256        .collect::<Result<Vec<_>, _>>()?;
257    Ok(ProblemList {
258        total: response.total as u32,
259        problems,
260    })
261}
262
263fn problem_from_model(
264    model: ChallengeModel,
265    expected_slug: Option<&str>,
266) -> Result<Problem, Error> {
267    let ChallengeModel {
268        id,
269        slug,
270        name,
271        difficulty_name,
272        preview,
273        track,
274        problem_statement,
275        input_format,
276        output_format,
277        languages,
278        onboarding,
279        sample_test_cases,
280        public_test_cases,
281    } = model;
282    let statement = checked_text(problem_statement, MAX_STATEMENT_BYTES)?;
283    let input_format = checked_text(input_format, MAX_TEXT_BYTES)?;
284    let output_format = checked_text(output_format, MAX_TEXT_BYTES)?;
285    if languages.len() > MAX_LANGUAGES || languages.iter().any(|language| !valid_language(language))
286    {
287        return Err(Error::InvalidResponse);
288    }
289    let starters = starters_from_onboarding(onboarding)?;
290    let samples = samples_from_model(sample_test_cases)?;
291    let summary = summary_from_parts(
292        id,
293        slug,
294        name,
295        difficulty_name,
296        preview,
297        track,
298        expected_slug,
299    )?;
300    Ok(Problem {
301        summary,
302        statement,
303        input_format,
304        output_format,
305        languages,
306        starters,
307        samples,
308        has_public_test_cases: public_test_cases,
309    })
310}
311
312fn starters_from_onboarding(
313    onboarding: Option<BTreeMap<Box<str>, OnboardingEntry>>,
314) -> Result<Vec<Starter>, Error> {
315    let mut starters = Vec::new();
316    for (language, entry) in onboarding.unwrap_or_default() {
317        let template = match entry {
318            OnboardingEntry::Template {
319                template: Some(template),
320            } => template,
321            OnboardingEntry::Template { template: None } => continue,
322            OnboardingEntry::Text(text) => {
323                let _ = text.len();
324                continue;
325            }
326        };
327        if !valid_language(&language) || !valid_text(&template, MAX_TEMPLATE_BYTES) {
328            return Err(Error::InvalidResponse);
329        }
330        starters.push(Starter { language, template });
331        if starters.len() > MAX_STARTERS {
332            return Err(Error::InvalidResponse);
333        }
334    }
335    Ok(starters)
336}
337
338fn samples_from_model(samples: Option<Vec<SampleModel>>) -> Result<Vec<Sample>, Error> {
339    let samples = samples.unwrap_or_default();
340    if samples.len() > MAX_SAMPLES {
341        return Err(Error::InvalidResponse);
342    }
343    samples
344        .into_iter()
345        .map(|sample| {
346            if !valid_text(&sample.input, MAX_SAMPLE_BYTES)
347                || !valid_text(&sample.output, MAX_SAMPLE_BYTES)
348            {
349                return Err(Error::InvalidResponse);
350            }
351            Ok(Sample {
352                input: sample.input,
353                output: sample.output,
354            })
355        })
356        .collect()
357}
358
359fn profile_from_model(model: ProfileModel) -> Result<Profile, Error> {
360    if model.id == 0
361        || !valid_username(&model.username)
362        || model.level.is_some_and(|level| level > MAX_PROFILE_LEVEL)
363        || model
364            .event_count
365            .is_some_and(|events| events > MAX_PROFILE_EVENTS)
366    {
367        return Err(Error::InvalidResponse);
368    }
369    Ok(Profile {
370        id: model.id,
371        username: model.username,
372        name: profile_text(model.name)?,
373        country: profile_text(model.country)?,
374        level: model.level,
375        event_count: model.event_count,
376        created_at: profile_text(model.created_at)?,
377    })
378}
379
380fn profile_text(value: Option<Box<str>>) -> Result<Option<Box<str>>, Error> {
381    let Some(value) = value else {
382        return Ok(None);
383    };
384    let value = value.trim();
385    if value.is_empty() {
386        return Ok(None);
387    }
388    valid_text(value, MAX_PROFILE_TEXT_BYTES)
389        .then(|| Some(value.into()))
390        .ok_or(Error::InvalidResponse)
391}
392
393fn summary_from_model(
394    model: ChallengeModel,
395    expected_slug: Option<&str>,
396) -> Result<ProblemSummary, Error> {
397    summary_from_parts(
398        model.id,
399        model.slug,
400        model.name,
401        model.difficulty_name,
402        model.preview,
403        model.track,
404        expected_slug,
405    )
406}
407
408fn summary_from_parts(
409    id: u64,
410    slug: Box<str>,
411    title: Box<str>,
412    difficulty_name: Option<Box<str>>,
413    preview: Option<Box<str>>,
414    track: Option<TrackModel>,
415    expected_slug: Option<&str>,
416) -> Result<ProblemSummary, Error> {
417    if id == 0
418        || !valid_slug(&slug)
419        || expected_slug.is_some_and(|expected| expected != slug.as_ref())
420        || !valid_text(&title, 256)
421        || preview
422            .as_deref()
423            .is_some_and(|text| !valid_text(text, MAX_TEXT_BYTES))
424    {
425        return Err(Error::InvalidResponse);
426    }
427    let track = track.map(track_from_model).transpose()?;
428    Ok(ProblemSummary {
429        id,
430        slug,
431        title,
432        difficulty: difficulty(difficulty_name.as_deref()),
433        preview,
434        track,
435    })
436}
437
438fn track_from_model(track: TrackModel) -> Result<Track, Error> {
439    if !valid_slug(&track.slug)
440        || !valid_slug(&track.track_slug)
441        || !valid_text(&track.name, 128)
442        || !valid_text(&track.track_name, 128)
443    {
444        return Err(Error::InvalidResponse);
445    }
446    Ok(Track {
447        slug: track.slug,
448        name: track.name,
449        domain_slug: track.track_slug,
450        domain_name: track.track_name,
451    })
452}
453
454fn checked_text(text: Option<Box<str>>, limit: usize) -> Result<Box<str>, Error> {
455    let text = text.ok_or(Error::InvalidResponse)?;
456    valid_text(&text, limit)
457        .then_some(text)
458        .ok_or(Error::InvalidResponse)
459}
460
461fn difficulty(value: Option<&str>) -> Difficulty {
462    match value {
463        Some("Easy") => Difficulty::Easy,
464        Some("Medium") => Difficulty::Medium,
465        Some("Hard") => Difficulty::Hard,
466        _ => Difficulty::Other,
467    }
468}
469
470fn valid_slug(value: &str) -> bool {
471    !value.is_empty()
472        && value.len() <= 128
473        && !value.starts_with('-')
474        && !value.ends_with('-')
475        && value
476            .bytes()
477            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
478}
479
480fn valid_language(value: &str) -> bool {
481    !value.is_empty()
482        && value.len() <= 32
483        && value
484            .bytes()
485            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
486}
487
488fn valid_username(value: &str) -> bool {
489    !value.is_empty()
490        && value.len() <= 128
491        && value
492            .bytes()
493            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
494}
495
496fn valid_text(value: &str, limit: usize) -> bool {
497    !value.is_empty() && value.len() <= limit && !value.as_bytes().contains(&0)
498}