shopify_approver_opencode/
client.rs1use crate::config::OpenCodeConfig;
7use crate::error::{OpenCodeError, Result};
8use crate::models::*;
9use crate::packager::{PackageResult, Packager};
10use reqwest::{multipart, Client, StatusCode};
11use std::path::Path;
12use std::time::Instant;
13use tokio::fs::File;
14use tokio::io::AsyncReadExt;
15use tracing::{debug, info, warn};
16
17pub struct OpenCodeClient {
19 client: Client,
20 config: OpenCodeConfig,
21}
22
23impl OpenCodeClient {
24 pub fn new(config: OpenCodeConfig) -> Result<Self> {
26 config.validate()?;
27
28 let client = Client::builder()
29 .timeout(config.timeout)
30 .build()
31 .map_err(OpenCodeError::Http)?;
32
33 Ok(Self { client, config })
34 }
35
36 pub fn from_env() -> Result<Self> {
38 let config = OpenCodeConfig::from_env()?;
39 Self::new(config)
40 }
41
42 pub fn base_url(&self) -> &str {
44 &self.config.base_url
45 }
46
47 pub async fn index_codebase(&self, codebase_path: &Path) -> Result<IndexResponse> {
59 info!("Indexing codebase: {}", codebase_path.display());
60 let start = Instant::now();
61
62 let packager = Packager::new()
64 .with_max_file_size(self.config.max_file_size)
65 .with_max_total_size(self.config.max_upload_size);
66
67 let package = packager.package(codebase_path)?;
68 info!(
69 "Packaged {} files ({} bytes compressed)",
70 package.file_count, package.compressed_size
71 );
72
73 let index = self.upload_and_index(&package).await?;
75 info!("Created index: {}", index.id);
76
77 let result = self.wait_for_index(&index.id).await?;
79
80 let elapsed = start.elapsed();
81 info!("Indexing completed in {:?}", elapsed);
82
83 if let Err(e) = std::fs::remove_file(&package.archive_path) {
85 warn!("Failed to cleanup temp file: {}", e);
86 }
87
88 Ok(result)
89 }
90
91 async fn upload_and_index(&self, package: &PackageResult) -> Result<IndexResponse> {
93 let url = format!("{}/api/v1/index", self.config.base_url);
94
95 let mut file = File::open(&package.archive_path).await?;
97 let mut buffer = Vec::new();
98 file.read_to_end(&mut buffer).await?;
99
100 let part = multipart::Part::bytes(buffer)
102 .file_name("codebase.tar.gz")
103 .mime_str("application/gzip")
104 .map_err(|e| OpenCodeError::Packaging(e.to_string()))?;
105
106 let form = multipart::Form::new()
107 .part("archive", part)
108 .text("file_count", package.file_count.to_string())
109 .text("total_size", package.total_size.to_string());
110
111 debug!("Uploading to: {}", url);
112
113 let mut request = self.client.post(&url).multipart(form);
114
115 if let Some(ref api_key) = self.config.api_key {
116 request = request.header("Authorization", format!("Bearer {}", api_key));
117 }
118
119 let response = request.send().await?;
120 self.handle_response(response).await
121 }
122
123 pub async fn wait_for_index(&self, index_id: &str) -> Result<IndexResponse> {
125 let start = Instant::now();
126 let timeout = self.config.indexing_timeout;
127 let poll_interval = self.config.poll_interval;
128
129 loop {
130 let status = self.get_index_status(index_id).await?;
131
132 match status.status {
133 IndexStatus::Ready => return Ok(status),
134 IndexStatus::Failed => {
135 return Err(OpenCodeError::ServiceError {
136 status: 500,
137 message: status.error.unwrap_or_else(|| "Indexing failed".to_string()),
138 });
139 }
140 IndexStatus::Pending | IndexStatus::Processing => {
141 if start.elapsed() > timeout {
142 return Err(OpenCodeError::IndexingTimeout);
143 }
144 debug!("Index {} still processing, waiting...", index_id);
145 tokio::time::sleep(poll_interval).await;
146 }
147 IndexStatus::Deleted => {
148 return Err(OpenCodeError::IndexNotFound(index_id.to_string()));
149 }
150 }
151 }
152 }
153
154 pub async fn get_index_status(&self, index_id: &str) -> Result<IndexResponse> {
156 let url = format!("{}/api/v1/index/{}", self.config.base_url, index_id);
157
158 let mut request = self.client.get(&url);
159 if let Some(ref api_key) = self.config.api_key {
160 request = request.header("Authorization", format!("Bearer {}", api_key));
161 }
162
163 let response = request.send().await?;
164 self.handle_response(response).await
165 }
166
167 pub async fn delete_index(&self, index_id: &str) -> Result<()> {
169 let url = format!("{}/api/v1/index/{}", self.config.base_url, index_id);
170
171 let mut request = self.client.delete(&url);
172 if let Some(ref api_key) = self.config.api_key {
173 request = request.header("Authorization", format!("Bearer {}", api_key));
174 }
175
176 let response = request.send().await?;
177
178 if response.status().is_success() {
179 Ok(())
180 } else {
181 let status = response.status().as_u16();
182 let message = response.text().await.unwrap_or_default();
183 Err(OpenCodeError::service_error(status, message))
184 }
185 }
186
187 pub async fn get_index_stats(&self, index_id: &str) -> Result<IndexStats> {
189 let url = format!("{}/api/v1/index/{}/stats", self.config.base_url, index_id);
190
191 let mut request = self.client.get(&url);
192 if let Some(ref api_key) = self.config.api_key {
193 request = request.header("Authorization", format!("Bearer {}", api_key));
194 }
195
196 let response = request.send().await?;
197 self.handle_response(response).await
198 }
199
200 pub async fn search(&self, index_id: &str, request: SearchRequest) -> Result<SearchResponse> {
206 let url = format!("{}/api/v1/index/{}/search", self.config.base_url, index_id);
207
208 let mut http_request = self.client.post(&url).json(&request);
209 if let Some(ref api_key) = self.config.api_key {
210 http_request = http_request.header("Authorization", format!("Bearer {}", api_key));
211 }
212
213 let response = http_request.send().await?;
214 self.handle_response(response).await
215 }
216
217 pub async fn search_code(
219 &self,
220 index_id: &str,
221 query: &str,
222 limit: usize,
223 ) -> Result<Vec<CodeChunk>> {
224 let request = SearchRequest::new(query).with_limit(limit);
225 let response = self.search(index_id, request).await?;
226 Ok(response.results)
227 }
228
229 pub async fn search_pattern(
231 &self,
232 index_id: &str,
233 pattern: &str,
234 extensions: Vec<String>,
235 ) -> Result<Vec<CodeChunk>> {
236 let request = SearchRequest::new(pattern)
237 .with_limit(20)
238 .with_extensions(extensions)
239 .with_context_lines(5);
240
241 let response = self.search(index_id, request).await?;
242 Ok(response.results)
243 }
244
245 pub async fn get_file_context(
251 &self,
252 index_id: &str,
253 file_path: &str,
254 ) -> Result<ContextResponse> {
255 let request = ContextRequest::new(file_path);
256 self.get_context(index_id, request).await
257 }
258
259 pub async fn get_context(
261 &self,
262 index_id: &str,
263 request: ContextRequest,
264 ) -> Result<ContextResponse> {
265 let url = format!("{}/api/v1/index/{}/context", self.config.base_url, index_id);
266
267 let mut http_request = self.client.post(&url).json(&request);
268 if let Some(ref api_key) = self.config.api_key {
269 http_request = http_request.header("Authorization", format!("Bearer {}", api_key));
270 }
271
272 let response = http_request.send().await?;
273 self.handle_response(response).await
274 }
275
276 pub async fn get_file(&self, index_id: &str, file_path: &str) -> Result<FileInfo> {
278 let url = format!(
279 "{}/api/v1/index/{}/file/{}",
280 self.config.base_url, index_id, file_path
281 );
282
283 let mut request = self.client.get(&url);
284 if let Some(ref api_key) = self.config.api_key {
285 request = request.header("Authorization", format!("Bearer {}", api_key));
286 }
287
288 let response = request.send().await?;
289 self.handle_response(response).await
290 }
291
292 pub async fn list_files(&self, index_id: &str) -> Result<Vec<String>> {
294 let url = format!("{}/api/v1/index/{}/files", self.config.base_url, index_id);
295
296 let mut request = self.client.get(&url);
297 if let Some(ref api_key) = self.config.api_key {
298 request = request.header("Authorization", format!("Bearer {}", api_key));
299 }
300
301 let response = request.send().await?;
302 self.handle_response(response).await
303 }
304
305 pub async fn find_webhooks(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
311 self.search_pattern(
312 index_id,
313 "webhook handler GDPR customers data request redact",
314 vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
315 )
316 .await
317 }
318
319 pub async fn find_api_calls(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
321 self.search_pattern(
322 index_id,
323 "shopify api graphql rest admin",
324 vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
325 )
326 .await
327 }
328
329 pub async fn find_billing_code(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
331 self.search_pattern(
332 index_id,
333 "billing payment subscription charge stripe",
334 vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
335 )
336 .await
337 }
338
339 pub async fn find_auth_code(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
341 self.search_pattern(
342 index_id,
343 "oauth authentication access token session",
344 vec!["ts".into(), "js".into(), "rb".into(), "py".into(), "php".into()],
345 )
346 .await
347 }
348
349 async fn handle_response<T: serde::de::DeserializeOwned>(
355 &self,
356 response: reqwest::Response,
357 ) -> Result<T> {
358 let status = response.status();
359
360 if status.is_success() {
361 let body = response.json().await?;
362 Ok(body)
363 } else {
364 let status_code = status.as_u16();
365 let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
366
367 match status {
368 StatusCode::NOT_FOUND => Err(OpenCodeError::IndexNotFound(message)),
369 StatusCode::ACCEPTED => Err(OpenCodeError::IndexPending(message)),
370 _ => Err(OpenCodeError::service_error(status_code, message)),
371 }
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_client_creation() {
382 let config = OpenCodeConfig::new("http://localhost:3000");
383 let client = OpenCodeClient::new(config);
384 assert!(client.is_ok());
385 }
386
387 #[test]
388 fn test_invalid_config() {
389 let config = OpenCodeConfig::new("");
390 let client = OpenCodeClient::new(config);
391 assert!(client.is_err());
392 }
393}