generated from clechasseur/rust-template
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathv1.rs
423 lines (390 loc) · 15.9 KB
/
v1.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! 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,
}