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)
118 .text("project_dir_path", project_metadata.project_dir_path);
119
120 let license_value = if let Some(lic) = license {
122 if lic == "MIT" {
123 "MIT".to_string() } else {
125 lic
126 }
127 } else {
128 "NONE".to_string()
129 };
130
131 body = body.text("license", license_value);
132
133 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 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 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 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 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 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 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
300pub 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 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)) .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}