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
//! Types and functions to interact with the [Exercism website](https://exercism.org) v2 API.
pub mod exercise;
pub mod exercises;
pub mod iteration;
pub mod solution;
pub mod solutions;
pub mod submission;
pub mod tests;
pub mod track;
pub mod tracks;
pub mod user;
use crate::Result;
/// Default base URL for the [Exercism website](https://exercism.org) v2 API.
pub const DEFAULT_V2_API_BASE_URL: &str = "https://exercism.org/api/v2";
define_api_client! {
/// Client for the [Exercism website](https://exercism.org) v2 API.
///
/// This API is undocumented and is mostly used by the website itself to fetch information.
pub struct Client(DEFAULT_V2_API_BASE_URL);
}
impl Client {
/// Returns a list of [Exercism tracks](https://exercism.org/tracks).
///
/// - If the request is performed anonymously, will return a list of all tracks
/// supported on the website.
/// - If the request is performed with [`credentials`](ClientBuilder::credentials),
/// tracks that the user has joined will be identified by the
/// [`is_joined`](track::Track::is_joined) field.
///
/// The list of tracks can optionally be filtered using [`Filters`](tracks::Filters).
///
/// # Errors
///
/// - [`ApiError`]: Error while fetching track information from API
///
/// # Examples
///
/// ```no_run
/// use mini_exercism::api;
/// use mini_exercism::api::v2::tracks::Filters;
/// use mini_exercism::api::v2::tracks::StatusFilter::Joined;
/// use mini_exercism::core::Credentials;
///
/// async fn get_joined_tracks(api_token: &str) -> anyhow::Result<Vec<String>> {
/// let credentials = Credentials::from_api_token(api_token);
/// let client = api::v2::Client::builder()
/// .credentials(credentials)
/// .build()?;
///
/// let filters = Filters::builder().status(Joined).build();
/// let tracks = client.get_tracks(Some(filters)).await?.tracks;
///
/// Ok(tracks.into_iter().map(|track| track.name).collect())
/// }
/// ```
///
/// [`ApiError`]: crate::Error::ApiError
#[cfg_attr(not(coverage), tracing::instrument(skip(self), ret, err))]
pub async fn get_tracks(
&self,
filters: Option<tracks::Filters<'_>>,
) -> Result<tracks::Response> {
self.api_client
.get("/tracks")
.query(filters)
.execute()
.await
}
/// Returns a list of exercises for an [Exercism](https://exercism.org) `track`,
/// optionally loading the user's solutions.
///
/// - If the request is performed anonymously, returns a list of all exercises in
/// the track. Each exercise's [`is_external`](exercise::Exercise::is_external) field will
/// be set to `true`.
/// - If the request is performed with [`credentials`](ClientBuilder::credentials),
/// returns a list of all exercises in the track, with information about whether
/// each exercise has been [unlocked](exercise::Exercise::is_unlocked) by the user. Each
/// exercise's [`is_external`](exercise::Exercise::is_external) field will be set to `false`.
/// Additionally, if the `filters` parameter's [`include_solutions`](exercises::Filters::include_solutions)
/// is set to `true`, the response will contain a list of solutions the user has submitted
/// for the track's exercises.
///
/// The list of exercises can optionally be filtered using [`Filters`](exercises::Filters).
///
/// # Notes
///
/// If the `filters` parameter's [`include_solutions`](exercises::Filters::include_solutions) is
/// set to `true`, the returned [`solutions`](exercises::Response::solutions) will return all
/// solutions; the solutions are not filtered like exercises are.
///
/// # Errors
///
/// - [`ApiError`]: Error while fetching exercise information from API
///
/// # Examples
///
/// ```no_run
/// use mini_exercism::api;
/// use mini_exercism::api::v2::exercises::Filters;
/// use mini_exercism::core::Credentials;
///
/// async fn get_published_solution_uuids(
/// api_token: &str,
/// track: &str,
/// ) -> anyhow::Result<Vec<String>> {
/// let credentials = Credentials::from_api_token(api_token);
/// let client = api::v2::Client::builder()
/// .credentials(credentials)
/// .build()?;
///
/// let filters = Filters::builder().include_solutions(true).build();
/// let solutions = client.get_exercises(track, Some(filters)).await?.solutions;
///
/// Ok(solutions
/// .into_iter()
/// .filter(|solution| solution.published_at.is_some())
/// .map(|solution| solution.uuid)
/// .collect())
/// }
/// ```
///
/// [`ApiError`]: crate::Error::ApiError
#[cfg_attr(not(coverage), tracing::instrument(skip(self), ret, err))]
pub async fn get_exercises(
&self,
track: &str,
filters: Option<exercises::Filters<'_>>,
) -> Result<exercises::Response> {
self.api_client
.get(format!("/tracks/{track}/exercises"))
.query(filters)
.execute()
.await
}
/// Returns a list of [Exercism](https://exercism.org) solutions for the user.
///
/// This request cannot be performed anonymously; doing so will result in an [`ApiError`].
///
/// The list of solutions can optionally be filtered using [`Filters`](solutions::Filters).
///
/// The list is paginated. By default, the first page is returned. To iterate pages, pass in
/// [`paging`](solutions::Paging) information. It's also possible to control the [`sort_order`](solutions::SortOrder)
/// of the solutions; if not specified, the default sort order is to return solutions with the
/// [most stars first](solutions::SortOrder::MostStarred).
///
/// # Errors
///
/// - [`ApiError`]: Error while fetching solutions information from API
///
/// # Examples
///
/// ```no_run
/// use mini_exercism::api;
/// use mini_exercism::api::v2::solution::Solution;
/// use mini_exercism::api::v2::solutions::{Filters, Paging, SortOrder};
/// use mini_exercism::core::Credentials;
///
/// async fn get_user_solutions(
/// api_token: &str,
/// filters: Option<Filters<'_>>,
/// sort_order: Option<SortOrder>,
/// ) -> anyhow::Result<Vec<Solution>> {
/// let credentials = Credentials::from_api_token(api_token);
/// let client = api::v2::Client::builder()
/// .credentials(credentials)
/// .build()?;
///
/// let mut solutions = Vec::new();
/// let mut page = 1i64;
/// loop {
/// let paging = Paging::for_page(page);
/// let paged_solutions = client
/// .get_solutions(filters.clone(), Some(paging), sort_order)
/// .await?
/// .results;
/// if paged_solutions.is_empty() {
/// break;
/// }
///
/// solutions.extend(paged_solutions.into_iter());
/// page += 1;
/// }
///
/// Ok(solutions)
/// }
/// ```
///
/// [`ApiError`]: crate::Error::ApiError
#[cfg_attr(not(coverage), tracing::instrument(skip(self), ret, err))]
pub async fn get_solutions(
&self,
filters: Option<solutions::Filters<'_>>,
paging: Option<solutions::Paging>,
sort_order: Option<solutions::SortOrder>,
) -> Result<solutions::Response> {
self.api_client
.get("/solutions")
.query(filters)
.query(paging)
.query(("order", sort_order))
.execute()
.await
}
/// Returns information about a specific solution submitted by the user.
///
/// This request cannot be performed anonymously; doing so will result in an [`ApiError`].
///
/// It's possible to also sideload the solution's iterations.
///
/// # Errors
///
/// - [`ApiError`]: Error while fetching solution information from API
///
/// # Examples
///
/// ```no_run
/// use mini_exercism::api;
/// use mini_exercism::api::v2::iteration::Iteration;
/// use mini_exercism::core::Credentials;
///
/// async fn get_solution_iterations(
/// api_token: &str,
/// solution_uuid: &str,
/// ) -> anyhow::Result<Vec<Iteration>> {
/// let credentials = Credentials::from_api_token(api_token);
/// let client = api::v2::Client::builder()
/// .credentials(credentials)
/// .build()?;
///
/// Ok(client.get_solution(solution_uuid, true).await?.iterations)
/// }
/// ```
///
/// [`ApiError`]: crate::Error::ApiError
#[cfg_attr(not(coverage), tracing::instrument(skip(self), ret, err))]
pub async fn get_solution(
&self,
uuid: &str,
include_iterations: bool,
) -> Result<solution::Response> {
self.api_client
.get(format!("/solutions/{uuid}"))
.query(("sideload", include_iterations.then_some("iterations")))
.execute()
.await
}
/// Returns information about the files submitted for a solution iteration.
///
/// This request cannot be performed anonymously, unless the submission's iteration has been [published](crate::api::v2::iteration::Iteration::is_published)
/// (also see below).
///
/// # Notes
///
/// The [Exercism website](https://exercism.org) v2 API does not authenticate the user when
/// querying for submission files (see [here](https://github.com/exercism/website/blob/bf5e32c0bc2eef3a36573cc0405c610398d2a5ea/app/controllers/api/solutions/submission_files_controller.rb#L2)).
/// Because of this, performing a query for the files of a submission of which the iteration
/// is not published will fail unless the user is authenticated first through _another_ query.
/// Furthermore, in order for authentication information to be saved between requests, the
/// [cookie store](crate::http::ClientBuilder::cookie_store) needs to be enabled in the
/// [HTTP client](crate::http::Client) used by this API client.
///
/// The sample code below has an example of how to enable the cookie store so that the
/// query for submission files will work even if the iteration is private.
///
/// Note that enabling the cookie store requires the use of the `cookies` feature.
///
/// # Errors
///
/// - [`ApiError`]: Error while fetching submitted files information from API
///
/// # Examples
///
/// ```no_run
/// # #[cfg(feature = "cookies")]
/// use mini_exercism::api;
/// # #[cfg(feature = "cookies")]
/// use mini_exercism::api::v2::submission;
/// # #[cfg(feature = "cookies")]
/// use mini_exercism::core::Credentials;
/// # #[cfg(feature = "cookies")]
/// use mini_exercism::http;
///
/// # #[cfg(feature = "cookies")]
/// async fn get_solution_files(
/// api_token: &str,
/// solution_uuid: &str,
/// ) -> anyhow::Result<Vec<submission::files::File>> {
/// // Enable cookie store so that authentication persists for submission files query
/// let http_client = http::Client::builder().cookie_store(true).build()?;
///
/// let credentials = Credentials::from_api_token(api_token);
/// let client = api::v2::Client::builder()
/// .credentials(credentials)
/// .http_client(http_client)
/// .build()?;
///
/// let submission_uuid = client
/// .get_solution(solution_uuid, true)
/// .await?
/// .iterations
/// .into_iter()
/// .find(|iteration| iteration.is_latest)
/// .and_then(|iteration| iteration.submission_uuid)
/// .ok_or_else(|| anyhow::anyhow!("could not find submission uuid"))?;
///
/// Ok(client
/// .get_submission_files(solution_uuid, &submission_uuid)
/// .await?
/// .files)
/// }
/// ```
///
/// [`ApiError`]: crate::Error::ApiError
#[cfg_attr(not(coverage), tracing::instrument(skip(self), ret, err))]
pub async fn get_submission_files(
&self,
solution_uuid: &str,
submission_uuid: &str,
) -> Result<submission::files::Response> {
self.api_client
.get(format!("/solutions/{solution_uuid}/submissions/{submission_uuid}/files"))
.execute()
.await
}
}