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.clone())
118            .text("contract-name", project_metadata.contract_file)
119            .text("project_dir_path", project_metadata.project_dir_path);
120
121        // Add license using raw SPDX identifier
122        let license_value = if let Some(lic) = license {
123            if lic == "MIT" {
124                "MIT".to_string() // Ensure MIT is formatted correctly
125            } else {
126                lic
127            }
128        } else {
129            "NONE".to_string()
130        };
131
132        body = body.text("license", license_value);
133
134        // Send each file as a separate field with files[] prefix
135        for file in files {
136            let file_content = fs::read_to_string(file.path.as_path())?;
137            body = body.text(format!("files[{}]", file.name), file_content);
138        }
139
140        let url = self.verify_class_url(class_hash)?;
141
142        let response = self
143            .client
144            .post(url.clone())
145            .multipart(body)
146            .send()
147            .map_err(ApiClientError::Reqwest)?;
148
149        match response.status() {
150            StatusCode::OK => (),
151            StatusCode::BAD_REQUEST => {
152                return Err(ApiClientError::from(RequestFailure::new(
153                    url,
154                    StatusCode::BAD_REQUEST,
155                    response.json::<Error>()?.error,
156                )));
157            }
158            StatusCode::PAYLOAD_TOO_LARGE => {
159                return Err(ApiClientError::from(RequestFailure::new(
160                    url,
161                    StatusCode::PAYLOAD_TOO_LARGE,
162                    "Request payload too large. Maximum allowed size is 10MB.".to_string(),
163                )));
164            }
165            status_code => {
166                return Err(ApiClientError::from(RequestFailure::new(
167                    url,
168                    status_code,
169                    response.text()?,
170                )));
171            }
172        }
173
174        Ok(response.json::<VerificationJobDispatch>()?.job_id)
175    }
176
177    /// # Errors
178    ///
179    /// Will return `Err` if the URL cannot be a base.
180    pub fn get_job_status_url(&self, job_id: impl AsRef<str>) -> Result<Url, ApiClientError> {
181        let mut url = self.base.clone();
182        let url_clone = url.clone();
183        url.path_segments_mut()
184            .map_err(|_| ApiClientError::CannotBeBase(url_clone))?
185            .extend(&["class-verify", "job", job_id.as_ref()]);
186        Ok(url)
187    }
188
189    /// # Errors
190    ///
191    /// Will return `Err` on network error or if the verification has
192    /// failed.
193    pub fn get_job_status(
194        &self,
195        job_id: impl Into<String> + Clone,
196    ) -> Result<JobStatus, ApiClientError> {
197        let url = self.get_job_status_url(job_id.clone().into())?;
198        let response = self.client.get(url.clone()).send()?;
199
200        match response.status() {
201            StatusCode::OK => (),
202            StatusCode::NOT_FOUND => return Err(ApiClientError::JobNotFound(job_id.into())),
203            status_code => {
204                return Err(ApiClientError::from(RequestFailure::new(
205                    url,
206                    status_code,
207                    response.text()?,
208                )));
209            }
210        }
211
212        let response_text = response.text()?;
213        log::debug!("Raw API Response: {response_text}");
214
215        let data: VerificationJob = serde_json::from_str(&response_text).map_err(|e| {
216            log::error!("Failed to parse JSON response: {e}");
217            log::error!("Response text: {response_text}");
218            ApiClientError::from(RequestFailure::new(
219                url.clone(),
220                StatusCode::OK,
221                format!("Failed to parse JSON response: {e}"),
222            ))
223        })?;
224
225        // Debug logging to see the actual response
226        log::debug!("Parsed API Response: job_id={}, status={:?}, status_description={:?}, message={:?}, error_category={:?}", 
227                   data.job_id, data.status, data.status_description, data.message, data.error_category);
228
229        match data.status {
230            VerifyJobStatus::Success => Ok(Some(data)),
231            VerifyJobStatus::Fail => {
232                let error_message = data
233                    .message
234                    .or_else(|| data.status_description.clone())
235                    .unwrap_or_else(|| "unknown failure".to_owned());
236
237                // Parse specific error types from the server response
238                let parsed_error = if error_message.contains("Payload too large")
239                    || error_message.contains("payload too large")
240                {
241                    "Request payload too large. The project files exceed the maximum allowed size of 10MB. Try reducing file sizes or removing unnecessary files."
242                } else {
243                    &error_message
244                };
245
246                Err(ApiClientError::from(
247                    VerificationError::VerificationFailure(parsed_error.to_owned()),
248                ))
249            }
250            VerifyJobStatus::CompileFailed => {
251                let error_message = data
252                    .message
253                    .or_else(|| data.status_description.clone())
254                    .unwrap_or_else(|| "unknown failure".to_owned());
255
256                // Parse specific error types from the server response
257                let parsed_error = if error_message.contains("Payload too large")
258                    || error_message.contains("payload too large")
259                {
260                    "Request payload too large. The project files exceed the maximum allowed size of 10MB. Try reducing file sizes or removing unnecessary files."
261                } else if error_message.contains("Couldn't connect to cairo compilation service") {
262                    "Cairo compilation service is currently unavailable. Please try again later."
263                } else {
264                    &error_message
265                };
266
267                Err(ApiClientError::from(VerificationError::CompilationFailure(
268                    parsed_error.to_owned(),
269                )))
270            }
271            VerifyJobStatus::Submitted
272            | VerifyJobStatus::Compiled
273            | VerifyJobStatus::Processing
274            | VerifyJobStatus::Unknown => Ok(None),
275        }
276    }
277
278    /// # Errors
279    ///
280    /// Will return `Err` on network error or if the verification has failed.
281    pub fn get_verification_job(&self, job_id: &str) -> Result<VerificationJob, ApiClientError> {
282        match self.get_job_status(job_id)? {
283            Some(job) => Ok(job),
284            None => Err(ApiClientError::InProgress),
285        }
286    }
287}
288
289pub enum Status {
290    InProgress,
291    Finished(ApiClientError),
292}
293
294const fn is_is_progress(status: &Status) -> bool {
295    match status {
296        Status::InProgress => true,
297        Status::Finished(_) => false,
298    }
299}
300
301/// # Errors
302///
303/// Will return `Err` on network error or if the verification has
304/// failed.
305pub fn poll_verification_status(
306    api: &ApiClient,
307    job_id: &str,
308) -> Result<VerificationJob, ApiClientError> {
309    let fetch = || -> Result<VerificationJob, Status> {
310        let result: Option<VerificationJob> = api
311            .get_job_status(job_id.to_owned())
312            .map_err(Status::Finished)?;
313
314        result.ok_or(Status::InProgress)
315    };
316
317    // So verbose because it has problems with inference
318    fetch
319        .retry(
320            ExponentialBuilder::default()
321                .with_max_times(0)
322                .with_min_delay(Duration::from_secs(2))
323                .with_max_delay(Duration::from_secs(300)) // 5 mins
324                .with_max_times(20),
325        )
326        .when(is_is_progress)
327        .notify(|_, dur: Duration| {
328            println!("Job: {job_id} didn't finish, retrying in {dur:?}");
329        })
330        .call()
331        .map_err(|err| match err {
332            Status::InProgress => ApiClientError::InProgress,
333            Status::Finished(e) => e,
334        })
335}