cp-cli-platform-codeforces 3.1.10

Codeforces HTTP client used by cp-cli
Documentation
use std::{future::Future, io, pin::Pin, time::Duration};

use sha2::Digest;
use tokio::{
    io::{AsyncReadExt, AsyncWriteExt},
    net::TcpListener,
    time::timeout,
};

use crate::{
    ApiCredentials, Client, Error,
    client::{MAX_RESPONSE_BYTES, http_builder},
};

type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
type RequestFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>;

async fn exchange<T>(
    response: String,
    expected_path: &'static str,
    operation: impl for<'a> FnOnce(&'a Client) -> RequestFuture<'a, T>,
) -> TestResult<Result<T, Error>> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let client = Client {
        http: http_builder()
            .https_only(false)
            .no_proxy()
            .timeout(Duration::from_secs(2))
            .build()?,
        endpoint: format!("http://{}/api", listener.local_addr()?).into(),
    };
    let server = async {
        let (mut stream, _) = listener.accept().await?;
        let mut request = Vec::new();
        let mut buffer = [0; 1024];
        loop {
            let read = stream.read(&mut buffer).await?;
            assert_ne!(read, 0, "client closed before sending its request");
            request.extend_from_slice(&buffer[..read]);
            if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
                break;
            }
        }
        assert!(request.starts_with(format!("GET {expected_path} HTTP/1.1\r\n").as_bytes()));
        stream.write_all(response.as_bytes()).await?;
        Ok::<(), io::Error>(())
    };
    let (result, _) = timeout(Duration::from_secs(3), async {
        tokio::join!(operation(&client), server)
    })
    .await?;
    Ok(result)
}

#[tokio::test]
async fn authorized_friends_contract() -> TestResult {
    let success = r#"{"status":"OK","result":["friend"]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let credentials = ApiCredentials::new("abc123", "secret456")?;
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let client = Client {
        http: http_builder()
            .https_only(false)
            .no_proxy()
            .timeout(Duration::from_secs(2))
            .build()?,
        endpoint: format!("http://{}/api", listener.local_addr()?).into(),
    };
    let server = async {
        let (mut stream, _) = listener.accept().await?;
        let mut request = Vec::new();
        let mut buffer = [0; 1024];
        loop {
            let read = stream.read(&mut buffer).await?;
            assert_ne!(read, 0, "client closed before sending its request");
            request.extend_from_slice(&buffer[..read]);
            if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
                break;
            }
        }
        let request = std::str::from_utf8(&request)?;
        let target = request
            .split_whitespace()
            .next()
            .and_then(|method| (method == "GET").then(|| request.split_whitespace().nth(1)))
            .flatten()
            .ok_or("missing GET target")?;
        let query = target
            .strip_prefix("/api/user.friends?")
            .ok_or("wrong endpoint")?;
        let mut values = std::collections::BTreeMap::new();
        for parameter in query.split('&') {
            let (name, value) = parameter.split_once('=').ok_or("bad parameter")?;
            values.insert(name, value);
        }
        assert_eq!(values.get("apiKey"), Some(&"abc123"));
        assert_eq!(values.get("onlyOnline"), Some(&"true"));
        let time = values.get("time").ok_or("missing time")?;
        assert!(time.parse::<u64>()? > 0);
        let signature = values.get("apiSig").ok_or("missing signature")?;
        let (prefix, digest) = signature.split_at(6);
        assert!(prefix.bytes().all(|byte| byte.is_ascii_hexdigit()));
        let expected = sha2::Sha512::digest(
            format!("{prefix}/user.friends?apiKey=abc123&onlyOnline=true&time={time}#secret456")
                .as_bytes(),
        );
        assert_eq!(digest, format!("{expected:x}"));
        stream.write_all(response.as_bytes()).await?;
        Ok::<(), Box<dyn std::error::Error>>(())
    };
    let (friends, _) = timeout(Duration::from_secs(3), async {
        tokio::join!(
            client.authenticated_friends(&credentials, |_, _| {}),
            server
        )
    })
    .await?;
    assert_eq!(friends?, vec!["friend".into()]);
    assert!(matches!(
        ApiCredentials::new("bad&key", "secret"),
        Err(Error::InvalidApiKey)
    ));
    Ok(())
}

#[tokio::test]
async fn problem_catalogue_contract() -> TestResult {
    let success = r#"{"status":"OK","result":{"problems":[{"contestId":1,"index":"A","name":"Theatre Square","rating":1000,"tags":["math"]}],"problemStatistics":[{"contestId":1,"index":"A","solvedCount":123}]}}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let problems = exchange(response, "/api/problemset.problems?lang=en", |client| {
        Box::pin(client.problems(|_, _| {}))
    })
    .await??;
    assert_eq!(problems.len(), 1);
    assert_eq!(
        problems[0].url.as_ref(),
        "https://codeforces.com/problemset/problem/1/A"
    );
    assert_eq!(problems[0].solved_count, Some(123));
    assert!(problems[0].statement.is_none());

    let failure = r#"{"status":"FAILED","comment":"Call limit exceeded"}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{failure}",
        failure.len()
    );
    let error = exchange(response, "/api/problemset.problems?lang=en", |client| {
        Box::pin(client.problems(|_, _| {}))
    })
    .await?;
    assert!(matches!(error, Err(Error::ApiFailure)));

    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
        MAX_RESPONSE_BYTES + 1
    );
    let error = exchange(response, "/api/problemset.problems?lang=en", |client| {
        Box::pin(client.problems(|_, _| {}))
    })
    .await?;
    assert!(matches!(error, Err(Error::ResponseTooLarge { .. })));

    let success = r#"{"status":"OK","result":[{"id":1,"name":"Round","type":"CF","phase":"BEFORE","startTimeSeconds":1,"durationSeconds":7200}]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let contests = exchange(response, "/api/contest.list?gym=false", |client| {
        Box::pin(client.contests(|_, _| {}))
    })
    .await??;
    assert_eq!(contests[0].phase.as_ref(), "BEFORE");

    let success = r#"{"status":"OK","result":[{"handle":"tourist","contribution":1,"friendOfCount":2,"registrationTimeSeconds":3,"lastOnlineTimeSeconds":4}]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let profile = exchange(response, "/api/user.info?handles=tourist", |client| {
        Box::pin(client.user_profile("tourist", |_, _| {}))
    })
    .await??;
    assert_eq!(profile.handle.as_ref(), "tourist");

    let success = r#"{"status":"OK","result":[{"contestId":1,"contestName":"Round","rank":2,"oldRating":1000,"newRating":1100,"ratingUpdateTimeSeconds":3}]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let ratings = exchange(response, "/api/user.rating?handle=tourist", |client| {
        Box::pin(client.rating_history("tourist", |_, _| {}))
    })
    .await??;
    assert_eq!(ratings[0].new_rating, 1100);

    let success = r#"{"status":"OK","result":[{"id":1,"creationTimeSeconds":2,"relativeTimeSeconds":3,"problem":{"index":"A","name":"Problem"},"programmingLanguage":"Rust","testset":"TESTS","passedTestCount":4,"timeConsumedMillis":5,"memoryConsumedBytes":6}]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let submissions = exchange(
        response,
        "/api/user.status?handle=tourist&from=1&count=1",
        |client| Box::pin(client.submissions("tourist", 1, |_, _| {})),
    )
    .await??;
    assert_eq!(submissions[0].problem_name.as_ref(), "Problem");

    let success = r#"{"status":"OK","result":[{"timeSeconds":10,"blogEntry":{"id":7,"creationTimeSeconds":8,"authorHandle":"tourist","title":"Notes","tags":["meta"],"rating":3},"comment":{"id":9,"creationTimeSeconds":10,"commentatorHandle":"reader","text":"Nice","rating":1}}]}"#;
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{success}",
        success.len()
    );
    let discussions = exchange(response, "/api/recentActions?maxCount=20", |client| {
        Box::pin(client.recent_discussions(|_, _| {}))
    })
    .await??;
    assert_eq!(
        discussions[0]
            .latest_comment
            .as_ref()
            .map(|comment| comment.id),
        Some(9)
    );
    Ok(())
}