//! Types and functions to interact with the [Exercism website](https://exercism.org) v1 API. use std::fmt::Display; use bytes::Bytes; use futures::future::Either; use futures::{stream, Stream, StreamExt, TryStreamExt}; use reqwest::StatusCode; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use crate::core::Result; /// Default base URL for the [Exercism website](https://exercism.org) v1 API. pub const DEFAULT_V1_API_BASE_URL: &str = "https://api.exercism.io/v1"; define_api_client! { /// Client for the [Exercism website](https://exercism.org) v1 API. This API is undocumented /// and is mostly used by the [Exercism CLI](https://exercism.org/docs/using/solving-exercises/working-locally) /// to download solution files. #[derive(Debug)] pub struct Client(DEFAULT_V1_API_BASE_URL); } impl Client { /// Returns information about a specific solution submitted by the user. /// /// # Arguments /// /// - `uuid` - UUID of the solution to fetch. This can be provided by the mentoring /// interface, or returned by another API, like /// [`api::v2::Client::get_exercises`](crate::api::v2::Client::get_exercises) /// (see [`Solution::uuid`](crate::api::v2::Solution::uuid)). /// /// # Notes /// /// Performing this request requires [`credentials`](ClientBuilder::credentials), /// otherwise a `401 Unauthorized` error will be returned. /// /// # Errors /// /// - [`ApiError`]: Error while fetching solution information from API /// /// # Examples /// /// ```no_run /// use mini_exercism::api; /// use mini_exercism::core::Credentials; /// /// async fn get_solution_url(api_token: &str, solution_uuid: &str) -> anyhow::Result<String> { /// let credentials = Credentials::from_api_token(api_token); /// let client = api::v1::Client::builder().credentials(credentials).build(); /// /// anyhow::Ok(client.get_solution(solution_uuid).await?.solution.url) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn get_solution(&self, uuid: &str) -> Result<SolutionResponse> { self.get(format!("/solutions/{}", uuid), None).await } /// Returns information about the latest solution submitted by the user for /// a given exercise. /// /// # Notes /// /// Performing this request requires [`credentials`](ClientBuilder::credentials), /// otherwise a `401 Unauthorized` error will be returned. /// /// # Errors /// /// - [`ApiError`]: Error while fetching solution information from API /// /// # Examples /// /// ```no_run /// use mini_exercism::api; /// use mini_exercism::core::Credentials; /// /// async fn get_latest_solution_url( /// api_token: &str, /// track: &str, /// exercise: &str, /// ) -> anyhow::Result<String> { /// let credentials = Credentials::from_api_token(api_token); /// let client = api::v1::Client::builder().credentials(credentials).build(); /// /// anyhow::Ok( /// client /// .get_latest_solution(track, exercise) /// .await? /// .solution /// .url, /// ) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn get_latest_solution( &self, track: &str, exercise: &str, ) -> Result<SolutionResponse> { let query = [("track_id", track), ("exercise_id", exercise)]; self.get("/solutions/latest", Some(&query)).await } /// Returns the contents of a specific file that is part of a solution. /// /// # Arguments /// /// - `solution_uuid` - [UUID](Solution::uuid) of the solution containing the file. /// - `file_path` - Path to the file, as returned in [`Solution::files`]. /// /// # Notes /// /// - Performing this request requires [`credentials`](ClientBuilder::credentials), /// otherwise a `401 Unauthorized` error will be returned. /// - If the API call to fetch file content fails, this method will return a [`Stream`] /// containing a single [`ApiError`]. /// /// # Examples /// /// ```no_run /// use std::io::Write; /// /// use futures::StreamExt; /// use mini_exercism::api; /// use mini_exercism::core::Credentials; /// /// async fn get_file_content( /// api_token: &str, /// track: &str, /// exercise: &str, /// file: &str, /// ) -> anyhow::Result<String> { /// let credentials = Credentials::from_api_token(api_token); /// let client = api::v1::Client::builder().credentials(credentials).build(); /// /// let solution = client.get_latest_solution(track, exercise).await?.solution; /// let mut file_response = client.get_file(&solution.uuid, file).await; /// let mut file_content: Vec<u8> = Vec::new(); /// while let Some(bytes) = file_response.next().await { /// file_content.write_all(&bytes?)?; /// } /// /// anyhow::Ok(String::from_utf8(file_content).expect("File should be valid UTF-8")) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn get_file( &self, solution_uuid: &str, file_path: &str, ) -> impl Stream<Item = Result<Bytes>> { let result = self .api_client .get(format!("/solutions/{}/files/{}", solution_uuid, file_path)) .send() .await .and_then(|response| response.error_for_status()); // The result of `stream::once` is not `Unpin`, so calling `boxed()` will make sure it's // possible for callers to use `next()` on the returned `Stream` without pinning it first. match result { Ok(response) => Either::Left(response.bytes_stream().map_err(|err| err.into())), Err(error) => Either::Right(stream::once(async { Err(error.into()) }).boxed()), } } /// Returns information about a language track. /// /// # Notes /// /// Perhaps strangely, performing this request requires [`credentials`](ClientBuilder::credentials), /// otherwise a `401 Unauthorized` error will be returned. /// /// # Errors /// /// - [`ApiError`]: Error while fetching track information from API /// /// # Examples /// /// ```no_run /// use mini_exercism::api; /// use mini_exercism::api::v1::SolutionTrack; /// use mini_exercism::core::Credentials; /// /// async fn get_language_track_details( /// api_token: &str, /// track: &str, /// ) -> anyhow::Result<SolutionTrack> { /// let credentials = Credentials::from_api_token(api_token); /// let client = api::v1::Client::builder().credentials(credentials).build(); /// /// anyhow::Ok(client.get_track(track).await?.track) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn get_track(&self, track: &str) -> Result<TrackResponse> { self.get(format!("/tracks/{}", track), None).await } /// Validates the API token used to perform API requests. If the API token is invalid or /// if the query is performed without [`credentials`](ClientBuilder::credentials), the /// API will return `401 Unauthorized` and this method will return `false`. If another HTTP /// error is returned by the API, this method will return an [`ApiError`]. /// /// # Errors /// /// - [`ApiError`]: Error while validating API token (other than `401 Unauthorized`) /// /// # Examples /// /// ```no_run /// use mini_exercism::api; /// use mini_exercism::core::Credentials; /// /// async fn is_api_token_valid(api_token: &str) -> bool { /// let credentials = Credentials::from_api_token(api_token); /// let client = api::v1::Client::builder().credentials(credentials).build(); /// /// client.validate_token().await.unwrap_or(false) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn validate_token(&self) -> Result<bool> { // This API call returns a payload, but it doesn't really contain useful information: // if the token is invalid, 401 will be returned. let response = self .api_client .get("/validate_token") .send() .await .and_then(|r| r.error_for_status()); match response { Ok(_) => Ok(true), Err(error) if error.status() == Some(StatusCode::UNAUTHORIZED) => Ok(false), Err(error) => Err(error.into()), } } /// Sends a "ping" to the server to determine if service is up and available. The call /// returns information about the website and database. /// /// # Notes /// /// - This call does not require [`credentials`](ClientBuilder::credentials), but works /// anyway if they are provided. /// - As of this writing, the [current implementation](https://github.com/exercism/website/blob/2580b8fa2b13cad7aa7e8a877551bbd8552bee8b/app/controllers/api/v1/ping_controller.rb) /// of this endpoint always return `true` as status for all components. It makes sense /// if you think about it: if the database or the Rails server misbehave, then the API /// would be inaccessible anyway 😅 It also means that if the service is actually down, /// this method will simply return an [`ApiError`]. /// /// # Errors /// /// - [`ApiError`]: Error while pinging API /// /// # Examples /// /// ```no_run /// use mini_exercism::api; /// use mini_exercism::core::Credentials; /// /// async fn report_service_status() -> anyhow::Result<()> { /// let client = api::v1::Client::new(); /// /// let service_status = client.ping().await?.status; /// println!( /// "Status: website: {}, database: {}", /// service_status.website, service_status.database, /// ); /// /// anyhow::Ok(()) /// } /// ``` /// /// [`ApiError`]: crate::core::Error#variant.ApiError pub async fn ping(&self) -> Result<PingResponse> { self.get("/ping", None).await } async fn get<U, R>(&self, url: U, query: Option<&[(&str, &str)]>) -> Result<R> where U: Display, R: DeserializeOwned, { let mut request = self.api_client.get(url); if let Some(query) = query { request = request.query(query); } Ok(request.send().await?.error_for_status()?.json().await?) } } /// Struct representing a response to a query for a solution on the /// [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SolutionResponse { /// Solution information. pub solution: Solution, } /// Struct representing information about a solution returned by the /// [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Solution { /// Solution unique ID. #[serde(rename = "id")] pub uuid: String, /// Solution URL. This URL's value depends on who performs the query /// versus who submitted the solution: /// /// | Condition | URL value used | /// |-----------------------------------------------------------------|---------------------------------| /// | Query user submitted the solution | Public exercise URL[^1] | /// | Query user is mentoring the solution's submitter | URL of the mentoring discussion | /// | Query user is a mentor and solution was submitted for mentoring | URL of the mentoring request | /// | Solution has been published and is accessible to query user | Public URL of the solution | /// /// [^1]: This is not a typo: the URL returned is indeed the public URL of the exercise, /// not the private URL of the solution for the user. pub url: String, /// Information about the user that submitted the solution. pub user: SolutionUser, /// Information about the solution's exercise. pub exercise: SolutionExercise, /// Base URL that can be used to download solution files. To fetch a specific file, /// use `{{file_download_base_url}}/{{file path}}` (with `{{file path}}` replaced by /// the path of a file returned in [`files`](Self::files). pub file_download_base_url: String, /// List of files that are part of the solution. This includes files submitted by /// the user as well as files that are provided by the exercise project. Files can /// be fetched by pre-pending their path with [`file_download_base_url`](Self::file_download_base_url). pub files: Vec<String>, /// Information about the submission of the solution. Only present if /// the solution has been submitted by the user. #[serde(default)] pub submission: Option<SolutionSubmission>, } /// Struct representing information about the user who created a solution, /// as returned by the [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SolutionUser { /// [Exercism](https://exercism.org) user handle. pub handle: String, /// Whether the user performing the query is the one that created the solution. pub is_requester: bool, } /// Struct representing information about the exercise for a solution, as returned by /// the [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SolutionExercise { /// Exercise name. This is an internal name, like `forth`. Also called `slug`. #[serde(rename = "id")] pub name: String, /// URL of the exercise's instructions (e.g., the public exercise URL). pub instructions_url: String, /// Information about the track containing the exercise. pub track: SolutionTrack, } /// Struct representing information about a language track returned by the /// [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SolutionTrack { /// Name of the language track. This is an internal name, like `common-lisp`. Also called `slug`. #[serde(rename = "id")] pub name: String, /// Language track title. /// This is a textual representation of the track name, like `Common Lisp`. #[serde(rename = "language")] pub title: String, } /// Struct representing information about the submission of a solution, as returned by the /// [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SolutionSubmission { /// Date/time when the solution has been submitted, in ISO-8601 format. pub submitted_at: String, } /// Struct representing a response to a track query on the [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TrackResponse { /// Information about the language track. pub track: SolutionTrack, } /// Struct representing a response to a ping request to the [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PingResponse { /// Information about the status of the [Exercism](https://exercism.org) services. pub status: ServiceStatus, } /// Struct representing the status of services, as returned by the [Exercism website](https://exercism.org) v1 API. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ServiceStatus { /// Whether the [Exercism website](https://exercism.org) is up and running. pub website: bool, /// Whether the database backing the [Exercism website](https://exercism.org) is working. pub database: bool, }