verifier/api/
client.rs

1use std::{fs, time::Duration};
2
3use backon::{BlockingRetryable, ExponentialBuilder};
4use reqwest::{
5    blocking::{self, multipart, Client},
6    StatusCode,
7};
8use url::Url;
9
10use crate::{class_hash::ClassHash, errors::RequestFailure};
11
12use super::errors::{ApiClientError, VerificationError};
13use super::models::{
14    Error, FileInfo, ProjectMetadataInfo, VerificationJob, VerificationJobDispatch,
15};
16use super::types::VerifyJobStatus;
17
18// TODO: Option blindness?
19type JobStatus = Option<VerificationJob>;
20
21#[derive(Clone)]
22pub struct ApiClient {
23    base: Url,
24    client: Client,
25}
26
27/**
28 * Currently only `GetJobStatus` and `VerifyClass` are public available apis.
29 * In the future, the get class api should be moved to using public apis too.
30 * TODO: Change get class api to use public apis.
31 */
32impl ApiClient {
33    /// # Errors
34    ///
35    /// Fails if provided `Url` cannot be a base. We rely on that
36    /// invariant in other methods.
37    pub fn new(base: Url) -> Result<Self, ApiClientError> {
38        // Test here so that we are sure path_segments_mut succeeds
39        if base.cannot_be_a_base() {
40            Err(ApiClientError::CannotBeBase(base))
41        } else {
42            Ok(Self {
43                base,
44                client: blocking::Client::new(),
45            })
46        }
47    }
48
49    /// # Errors
50    ///
51    /// Will return `Err` if the URL cannot be a base.
52    pub fn get_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
53        let mut url = self.base.clone();
54        let url_clone = url.clone();
55        url.path_segments_mut()
56            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
57            .extend(&["classes", class_hash.as_ref()]);
58        Ok(url)
59    }
60
61    /// # Errors
62    ///
63    /// Returns `Err` if the required `class_hash` is not found or on
64    /// network failure.
65    pub fn get_class(&self, class_hash: &ClassHash) -> Result<bool, ApiClientError> {
66        let url = self.get_class_url(class_hash)?;
67        let result = self
68            .client
69            .get(url.clone())
70            .send()
71            .map_err(ApiClientError::from)?;
72
73        match result.status() {
74            StatusCode::OK => Ok(true),
75            StatusCode::NOT_FOUND => Ok(false),
76            _ => Err(ApiClientError::from(RequestFailure::new(
77                url,
78                result.status(),
79                result.text()?,
80            ))),
81        }
82    }
83
84    /// # Errors
85    ///
86    /// Will return `Err` if the URL cannot be a base.
87    pub fn verify_class_url(&self, class_hash: &ClassHash) -> Result<Url, ApiClientError> {
88        let mut url = self.base.clone();
89        let url_clone = url.clone();
90        url.path_segments_mut()
91            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
92            .extend(&["class-verify", class_hash.as_ref()]);
93        Ok(url)
94    }
95
96    /// # Errors
97    ///
98    /// Will return `Err` on network request failure or if can't
99    /// gather file contents for submission.
100    pub fn verify_class(
101        &self,
102        class_hash: &ClassHash,
103        license: Option<String>,
104        name: &str,
105        project_metadata: ProjectMetadataInfo,
106        files: &[FileInfo],
107    ) -> Result<String, ApiClientError> {
108        let mut body = multipart::Form::new()
109            .percent_encode_noop()
110            .text(
111                "compiler_version",
112                project_metadata.cairo_version.to_string(),
113            )
114            .text("scarb_version", project_metadata.scarb_version.to_string())
115            .text("package_name", project_metadata.package_name)
116            .text("name", name.to_string())
117            .text("contract_file", project_metadata.contract_file)
118            .text("project_dir_path", project_metadata.project_dir_path);
119
120        // Add license using raw SPDX identifier
121        let license_value = if let Some(lic) = license {
122            if lic == "MIT" {
123                "MIT".to_string() // Ensure MIT is formatted correctly
124            } else {
125                lic
126            }
127        } else {
128            "NONE".to_string()
129        };
130
131        body = body.text("license", license_value);
132
133        // Send each file as a separate field with files[] prefix
134        for file in files {
135            let file_content = fs::read_to_string(file.path.as_path())?;
136            body = body.text(format!("files[{}]", file.name), file_content);
137        }
138
139        let url = self.verify_class_url(class_hash)?;
140
141        let response = self
142            .client
143            .post(url.clone())
144            .multipart(body)
145            .send()
146            .map_err(ApiClientError::Reqwest)?;
147
148        match response.status() {
149            StatusCode::OK => (),
150            StatusCode::BAD_REQUEST => {
151                return Err(ApiClientError::from(RequestFailure::new(
152                    url,
153                    StatusCode::BAD_REQUEST,
154                    response.json::<Error>()?.error,
155                )));
156            }
157            StatusCode::PAYLOAD_TOO_LARGE => {
158                return Err(ApiClientError::from(RequestFailure::new(
159                    url,
160                    StatusCode::PAYLOAD_TOO_LARGE,
161                    "Request payload too large. Maximum allowed size is 10MB.".to_string(),
162                )));
163            }
164            status_code => {
165                return Err(ApiClientError::from(RequestFailure::new(
166                    url,
167                    status_code,
168                    response.text()?,
169                )));
170            }
171        }
172
173        Ok(response.json::<VerificationJobDispatch>()?.job_id)
174    }
175
176    /// # Errors
177    ///
178    /// Will return `Err` if the URL cannot be a base.
179    pub fn get_job_status_url(&self, job_id: impl AsRef<str>) -> Result<Url, ApiClientError> {
180        let mut url = self.base.clone();
181        let url_clone = url.clone();
182        url.path_segments_mut()
183            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
184            .extend(&["class-verify", "job", job_id.as_ref()]);
185        Ok(url)
186    }
187
188    /// # Errors
189    ///
190    /// Will return `Err` on network error or if the verification has
191    /// failed.
192    pub fn get_job_status(
193        &self,
194        job_id: impl Into<String> + Clone,
195    ) -> Result<JobStatus, ApiClientError> {
196        let url = self.get_job_status_url(job_id.clone().into())?;
197        let response = self.client.get(url.clone()).send()?;
198
199        match response.status() {
200            StatusCode::OK => (),
201            StatusCode::NOT_FOUND => return Err(ApiClientError::JobNotFound(job_id.into())),
202            status_code => {
203                return Err(ApiClientError::from(RequestFailure::new(
204                    url,
205                    status_code,
206                    response.text()?,
207                )));
208            }
209        }
210
211        let response_text = response.text()?;
212        log::debug!("Raw API Response: {}", response_text);
213
214        let data: VerificationJob = serde_json::from_str(&response_text).map_err(|e| {
215            log::error!("Failed to parse JSON response: {}", e);
216            log::error!("Response text: {}", response_text);
217            ApiClientError::from(RequestFailure::new(
218                url.clone(),
219                StatusCode::OK,
220                format!("Failed to parse JSON response: {e}"),
221            ))
222        })?;
223
224        // Debug logging to see the actual response
225        log::debug!("Parsed API Response: job_id={}, status={:?}, status_description={:?}, message={:?}, error_category={:?}", 
226                   data.job_id, data.status, data.status_description, data.message, data.error_category);
227
228        match data.status {
229            VerifyJobStatus::Success => Ok(Some(data)),
230            VerifyJobStatus::Fail => {
231                let error_message = data
232                    .message
233                    .or_else(|| data.status_description.clone())
234                    .unwrap_or_else(|| "unknown failure".to_owned());
235
236                // Parse specific error types from the server response
237                let parsed_error = if error_message.contains("Payload too large")
238                    || error_message.contains("payload too large")
239                {
240                    "Request payload too large. The project files exceed the maximum allowed size of 10MB. Try reducing file sizes or removing unnecessary files."
241                } else {
242                    &error_message
243                };
244
245                Err(ApiClientError::from(
246                    VerificationError::VerificationFailure(parsed_error.to_owned()),
247                ))
248            }
249            VerifyJobStatus::CompileFailed => {
250                let error_message = data
251                    .message
252                    .or_else(|| data.status_description.clone())
253                    .unwrap_or_else(|| "unknown failure".to_owned());
254
255                // Parse specific error types from the server response
256                let parsed_error = if error_message.contains("Payload too large")
257                    || error_message.contains("payload too large")
258                {
259                    "Request payload too large. The project files exceed the maximum allowed size of 10MB. Try reducing file sizes or removing unnecessary files."
260                } else if error_message.contains("Couldn't connect to cairo compilation service") {
261                    "Cairo compilation service is currently unavailable. Please try again later."
262                } else {
263                    &error_message
264                };
265
266                Err(ApiClientError::from(VerificationError::CompilationFailure(
267                    parsed_error.to_owned(),
268                )))
269            }
270            VerifyJobStatus::Submitted
271            | VerifyJobStatus::Compiled
272            | VerifyJobStatus::Processing
273            | VerifyJobStatus::Unknown => Ok(None),
274        }
275    }
276
277    /// # Errors
278    ///
279    /// Will return `Err` on network error or if the verification has failed.
280    pub fn get_verification_job(&self, job_id: &str) -> Result<VerificationJob, ApiClientError> {
281        match self.get_job_status(job_id)? {
282            Some(job) => Ok(job),
283            None => Err(ApiClientError::InProgress),
284        }
285    }
286}
287
288pub enum Status {
289    InProgress,
290    Finished(ApiClientError),
291}
292
293const fn is_is_progress(status: &Status) -> bool {
294    match status {
295        Status::InProgress => true,
296        Status::Finished(_) => false,
297    }
298}
299
300/// # Errors
301///
302/// Will return `Err` on network error or if the verification has
303/// failed.
304pub fn poll_verification_status(
305    api: &ApiClient,
306    job_id: &str,
307) -> Result<VerificationJob, ApiClientError> {
308    let fetch = || -> Result<VerificationJob, Status> {
309        let result: Option<VerificationJob> = api
310            .get_job_status(job_id.to_owned())
311            .map_err(Status::Finished)?;
312
313        result.ok_or(Status::InProgress)
314    };
315
316    // So verbose because it has problems with inference
317    fetch
318        .retry(
319            ExponentialBuilder::default()
320                .with_max_times(0)
321                .with_min_delay(Duration::from_secs(2))
322                .with_max_delay(Duration::from_secs(300)) // 5 mins
323                .with_max_times(20),
324        )
325        .when(is_is_progress)
326        .notify(|_, dur: Duration| {
327            println!("Job: {job_id} didn't finish, retrying in {dur:?}");
328        })
329        .call()
330        .map_err(|err| match err {
331            Status::InProgress => ApiClientError::InProgress,
332            Status::Finished(e) => e,
333        })
334}