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
18type JobStatus = Option<VerificationJob>;
20
21#[derive(Clone)]
22pub struct ApiClient {
23 base: Url,
24 client: Client,
25}
26
27impl ApiClient {
33 pub fn new(base: Url) -> Result<Self, ApiClientError> {
38 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 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 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 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 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 let license_value = if let Some(lic) = license {
123 if lic == "MIT" {
124 "MIT".to_string() } else {
126 lic
127 }
128 } else {
129 "NONE".to_string()
130 };
131
132 body = body.text("license", license_value);
133
134 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 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 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 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 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 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 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
301pub 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 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)) .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}