1use std::{fmt::Display, fs, path::PathBuf, time::Duration};
2
3use backon::{BlockingRetryable, ExponentialBuilder};
4use reqwest::{
5 blocking::{self, multipart, Client},
6 StatusCode,
7};
8use semver;
9use serde_repr::{Deserialize_repr, Serialize_repr};
10use thiserror::Error;
11use url::Url;
12
13use crate::{
14 class_hash::ClassHash,
15 errors::{self, RequestFailure},
16};
17
18#[derive(Clone, Debug, Deserialize_repr, Eq, PartialEq, Serialize_repr)]
19#[repr(u8)]
20pub enum VerifyJobStatus {
21 Submitted = 0,
22 Compiled = 1,
23 CompileFailed = 2,
24 Fail = 3,
25 Success = 4,
26 Processing = 5,
27 #[serde(other)]
28 Unknown,
29}
30
31#[derive(Debug, Error)]
32pub enum VerificationError {
33 #[error("Compilation failed: {0}")]
34 CompilationFailure(String),
35
36 #[error("Compilation failed: {0}")]
37 VerificationFailure(String),
38}
39
40type JobStatus = Option<VerificationJob>;
42
43impl Display for VerifyJobStatus {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::Submitted => write!(f, "Submitted"),
47 Self::Compiled => write!(f, "Compiled"),
48 Self::CompileFailed => write!(f, "CompileFailed"),
49 Self::Fail => write!(f, "Fail"),
50 Self::Success => write!(f, "Success"),
51 Self::Processing => write!(f, "Processing"),
52 Self::Unknown => write!(f, "Unknown"),
53 }
54 }
55}
56
57#[derive(Clone)]
58pub struct ApiClient {
59 base: Url,
60 client: Client,
61}
62
63#[derive(Error, Debug)]
64pub enum ApiClientError {
65 #[error("{0} cannot be base, provide valid URL")]
66 CannotBeBase(Url),
67
68 #[error(transparent)]
69 Reqwest(#[from] reqwest::Error),
70
71 #[error("Verification job is still in progress")]
72 InProgress,
73
74 #[error(transparent)]
75 Failure(#[from] errors::RequestFailure),
76
77 #[error("Job {0} not found")]
78 JobNotFound(String),
79
80 #[error(transparent)]
81 Verify(#[from] VerificationError),
82
83 #[error(transparent)]
84 IoError(#[from] std::io::Error),
85
86 #[error("URL cannot be a base: {0}")]
87 UrlCannotBeBase(#[from] url::ParseError),
88}
89
90impl ApiClient {
96 pub fn new(base: Url) -> Result<Self, ApiClientError> {
101 if base.cannot_be_a_base() {
103 Err(ApiClientError::CannotBeBase(base))
104 } else {
105 Ok(Self {
106 base,
107 client: blocking::Client::new(),
108 })
109 }
110 }
111
112 pub fn get_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
116 let mut url = self.base.clone();
117 let url_clone = url.clone();
118 url.path_segments_mut()
119 .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
120 .extend(&["classes", class_hash.as_ref()]);
121 Ok(url)
122 }
123
124 pub fn get_class(&self, class_hash: &ClassHash) -> Result<bool, ApiClientError> {
129 let url = self.get_class_url(class_hash)?;
130 let result = self
131 .client
132 .get(url.clone())
133 .send()
134 .map_err(ApiClientError::from)?;
135
136 match result.status() {
137 StatusCode::OK => Ok(true),
138 StatusCode::NOT_FOUND => Ok(false),
139 _ => Err(ApiClientError::from(RequestFailure::new(
140 url,
141 result.status(),
142 result.text()?,
143 ))),
144 }
145 }
146
147 pub fn verify_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
151 let mut url = self.base.clone();
152 let url_clone = url.clone();
153 url.path_segments_mut()
154 .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
155 .extend(&["class-verify", class_hash.as_ref()]);
156 Ok(url)
157 }
158
159 pub fn verify_class(
164 &self,
165 class_hash: &ClassHash,
166 license: Option<String>,
167 name: &str,
168 project_metadata: ProjectMetadataInfo,
169 files: &[FileInfo],
170 ) -> Result<String, ApiClientError> {
171 let mut body = multipart::Form::new()
172 .percent_encode_noop()
173 .text(
174 "compiler_version",
175 project_metadata.cairo_version.to_string(),
176 )
177 .text("scarb_version", project_metadata.scarb_version.to_string())
178 .text("package_name", project_metadata.package_name)
179 .text("name", name.to_string())
180 .text("contract_file", project_metadata.contract_file)
181 .text("project_dir_path", project_metadata.project_dir_path);
182
183 let license_value = if let Some(lic) = license {
185 if lic == "MIT" {
186 "MIT".to_string() } else {
188 lic
189 }
190 } else {
191 "NONE".to_string()
192 };
193
194 body = body.text("license", license_value);
195
196 for file in files {
198 let file_content = fs::read_to_string(file.path.as_path())?;
199 body = body.text(format!("files[{}]", file.name), file_content);
200 }
201
202 let url = self.verify_class_url(class_hash)?;
203
204 let response = self
205 .client
206 .post(url.clone())
207 .multipart(body)
208 .send()
209 .map_err(ApiClientError::Reqwest)?;
210
211 match response.status() {
212 StatusCode::OK => (),
213 StatusCode::BAD_REQUEST => {
214 return Err(ApiClientError::from(RequestFailure::new(
215 url,
216 StatusCode::BAD_REQUEST,
217 response.json::<Error>()?.error,
218 )));
219 }
220 status_code => {
221 return Err(ApiClientError::from(RequestFailure::new(
222 url,
223 status_code,
224 response.text()?,
225 )));
226 }
227 }
228
229 Ok(response.json::<VerificationJobDispatch>()?.job_id)
230 }
231
232 pub fn get_job_status_url(&self, job_id: impl AsRef<str>) -> Result<Url, ApiClientError> {
236 let mut url = self.base.clone();
237 let url_clone = url.clone();
238 url.path_segments_mut()
239 .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
240 .extend(&["class-verify", "job", job_id.as_ref()]);
241 Ok(url)
242 }
243
244 pub fn get_job_status(
249 &self,
250 job_id: impl Into<String> + Clone,
251 ) -> Result<JobStatus, ApiClientError> {
252 let url = self.get_job_status_url(job_id.clone().into())?;
253 let response = self.client.get(url.clone()).send()?;
254
255 match response.status() {
256 StatusCode::OK => (),
257 StatusCode::NOT_FOUND => return Err(ApiClientError::JobNotFound(job_id.into())),
258 status_code => {
259 return Err(ApiClientError::from(RequestFailure::new(
260 url,
261 status_code,
262 response.text()?,
263 )));
264 }
265 }
266
267 let data = response.json::<VerificationJob>()?;
268 match data.status {
269 VerifyJobStatus::Success => Ok(Some(data)),
270 VerifyJobStatus::Fail => Err(ApiClientError::from(
271 VerificationError::VerificationFailure(
272 data.status_description
273 .unwrap_or_else(|| "unknown failure".to_owned()),
274 ),
275 )),
276 VerifyJobStatus::CompileFailed => {
277 Err(ApiClientError::from(VerificationError::CompilationFailure(
278 data.status_description
279 .unwrap_or_else(|| "unknown failure".to_owned()),
280 )))
281 }
282 VerifyJobStatus::Submitted
283 | VerifyJobStatus::Compiled
284 | VerifyJobStatus::Processing
285 | VerifyJobStatus::Unknown => Ok(None),
286 }
287 }
288}
289
290#[derive(Debug, serde::Deserialize)]
291pub struct Error {
292 error: String,
293}
294
295#[derive(Debug, serde::Deserialize)]
296pub struct VerificationJobDispatch {
297 job_id: String,
298}
299
300#[derive(Debug, serde::Deserialize)]
301pub struct VerificationJob {
302 job_id: String,
303 status: VerifyJobStatus,
304 status_description: Option<String>,
305 class_hash: String,
306 created_timestamp: Option<f64>,
307 updated_timestamp: Option<f64>,
308 address: Option<String>,
309 contract_file: Option<String>,
310 name: Option<String>,
311 version: Option<String>,
312 license: Option<String>,
313}
314
315impl VerificationJob {
316 pub const fn status(&self) -> &VerifyJobStatus {
317 &self.status
318 }
319
320 pub fn class_hash(&self) -> &str {
321 &self.class_hash
322 }
323
324 pub fn job_id(&self) -> &str {
325 &self.job_id
326 }
327
328 pub fn name(&self) -> Option<&str> {
329 self.name.as_deref()
330 }
331
332 pub fn contract_file(&self) -> Option<&str> {
333 self.contract_file.as_deref()
334 }
335
336 pub fn status_description(&self) -> Option<&str> {
337 self.status_description.as_deref()
338 }
339
340 pub const fn created_timestamp(&self) -> Option<f64> {
341 self.created_timestamp
342 }
343
344 pub const fn updated_timestamp(&self) -> Option<f64> {
345 self.updated_timestamp
346 }
347
348 pub fn address(&self) -> Option<&str> {
349 self.address.as_deref()
350 }
351
352 pub fn version(&self) -> Option<&str> {
353 self.version.as_deref()
354 }
355
356 pub fn license(&self) -> Option<&str> {
357 self.license.as_deref()
358 }
359}
360
361#[derive(Debug, Eq, PartialEq)]
362pub struct FileInfo {
363 pub name: String,
364 pub path: PathBuf,
365}
366
367#[derive(Debug, Clone)]
368pub struct ProjectMetadataInfo {
369 pub cairo_version: semver::Version,
370 pub scarb_version: semver::Version,
371 pub project_dir_path: String,
372 pub contract_file: String,
373 pub package_name: String,
374}
375
376pub enum Status {
377 InProgress,
378 Finished(ApiClientError),
379}
380
381const fn is_is_progress(status: &Status) -> bool {
382 match status {
383 Status::InProgress => true,
384 Status::Finished(_) => false,
385 }
386}
387
388pub fn poll_verification_status(
393 api: &ApiClient,
394 job_id: &str,
395) -> Result<VerificationJob, ApiClientError> {
396 let fetch = || -> Result<VerificationJob, Status> {
397 let result: Option<VerificationJob> = api
398 .get_job_status(job_id.to_owned())
399 .map_err(Status::Finished)?;
400
401 result.ok_or(Status::InProgress)
402 };
403
404 fetch
406 .retry(
407 ExponentialBuilder::default()
408 .with_max_times(0)
409 .with_min_delay(Duration::from_secs(2))
410 .with_max_delay(Duration::from_secs(300)) .with_max_times(20),
412 )
413 .when(is_is_progress)
414 .notify(|_, dur: Duration| {
415 println!("Job: {job_id} didn't finish, retrying in {dur:?}");
416 })
417 .call()
418 .map_err(|err| match err {
419 Status::InProgress => ApiClientError::InProgress,
420 Status::Finished(e) => e,
421 })
422}