1use std::collections::HashMap;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use crate::channel::ControlChannel;
11use crate::code::Code;
12use crate::commands::{run_command, Commands, RunOptions};
13use crate::error::SolariError;
14use crate::files::Files;
15use crate::git::{Git, GitRunner};
16use crate::http::{encode_uri_component, new_idempotency_key, HttpOptions, HttpTransport};
17use crate::types::{
18 CommandResult, CreateSandboxRequest, CreateSandboxResponse, Lifecycle, ResumeResponse, StartResponse,
19 SandboxView, VolumeAttachment,
20};
21
22pub const DEFAULT_BASE_URL: &str = "https://api.getsolari.com";
24
25#[derive(Debug, Clone)]
27pub struct ClientOptions {
28 pub api_key: String,
29 pub base_url: String,
31 pub call_timeout_ms: Option<u64>,
33}
34
35impl ClientOptions {
36 pub fn with_api_key(api_key: impl Into<String>) -> Self {
38 Self::new(api_key, DEFAULT_BASE_URL)
39 }
40
41 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
42 ClientOptions {
43 api_key: api_key.into(),
44 base_url: base_url.into(),
45 call_timeout_ms: None,
46 }
47 }
48}
49
50#[derive(Default, Clone)]
52pub struct CreateOptions {
53 pub template: Option<String>,
54 pub cpu: Option<u32>,
55 pub mem_mb: Option<u32>,
56 pub disk_gb: Option<u32>,
57 pub envs: Option<HashMap<String, String>>,
58 pub metadata: Option<HashMap<String, String>>,
59 pub timeout_ms: Option<u64>,
60 pub ttl_seconds: Option<u64>,
63 pub from_snapshot: Option<String>,
64 pub isolation: Option<String>,
67 pub lifecycle: Option<Lifecycle>,
68 pub resolution: Option<String>,
71 pub record: Option<bool>,
74 pub volumes: Option<Vec<VolumeAttachment>>,
76}
77
78impl CreateOptions {
79 pub(crate) fn into_request(self, kind: &str) -> CreateSandboxRequest {
85 CreateSandboxRequest {
86 template: self.template,
87 kind: Some(kind.to_string()),
88 cpu: self.cpu,
89 mem_mb: self.mem_mb,
90 disk_gb: self.disk_gb,
91 envs: self.envs,
92 metadata: self.metadata,
93 timeout_ms: self.timeout_ms,
94 ttl_seconds: self.ttl_seconds,
95 from_snapshot: self.from_snapshot,
96 isolation: self.isolation,
97 lifecycle: self.lifecycle,
98 resolution: self.resolution,
99 record: self.record,
100 volumes: self.volumes,
101 }
102 }
103}
104
105impl From<CreateOptions> for CreateSandboxRequest {
106 fn from(o: CreateOptions) -> Self {
107 o.into_request("sandbox")
108 }
109}
110
111pub struct Client {
113 http: Arc<HttpTransport>,
114 call_timeout_ms: Option<u64>,
115}
116
117impl Client {
118 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
119 let base_url = if opts.base_url.is_empty() { DEFAULT_BASE_URL.to_string() } else { opts.base_url };
120 let http = HttpTransport::new(HttpOptions::new(opts.api_key, base_url))?;
121 Ok(Client {
122 http: Arc::new(http),
123 call_timeout_ms: opts.call_timeout_ms,
124 })
125 }
126
127 pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
129 self.create_with_kind(opts, "sandbox").await
130 }
131
132 pub async fn create_desktop(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
142 self.create_with_kind(opts, "desktop").await
143 }
144
145 async fn create_with_kind(
146 &self,
147 opts: CreateOptions,
148 kind: &str,
149 ) -> Result<Sandbox, SolariError> {
150 let req = opts.into_request(kind);
151 let body = serde_json::to_value(&req).unwrap();
152 let resp: CreateSandboxResponse = self
153 .http
154 .request("POST", "/sandboxes", Some(body), Some(new_idempotency_key()))
155 .await?;
156 Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
157 }
158
159 pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
161 if sandbox_id.trim().is_empty() {
164 return Err(SolariError::Validation {
165 message: "Solari: connect() requires a non-blank sandbox id.".to_string(),
166 });
167 }
168 let view = self.get(sandbox_id).await?;
169 let control_url = view.control_url.clone().unwrap_or_else(|| {
170 format!(
171 "{}/control/{}",
172 self.http.ws_origin(),
173 encode_uri_component(sandbox_id)
174 )
175 });
176 let stream_url = if view.kind == "desktop" {
181 Some(format!(
182 "{}/stream/{}",
183 self.http.ws_origin(),
184 encode_uri_component(sandbox_id)
185 ))
186 } else {
187 None
188 };
189 let resp = CreateSandboxResponse {
190 sandbox_id: view.sandbox_id,
191 kind: view.kind,
192 control_url,
193 expires_at: view.expires_at,
194 stream_url,
195 recording_url: None,
196 };
197 Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
198 }
199
200 pub async fn get(&self, sandbox_id: &str) -> Result<SandboxView, SolariError> {
202 self.http
203 .request(
204 "GET",
205 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
206 None,
207 None,
208 )
209 .await
210 }
211
212 pub async fn kill(&self, sandbox_id: &str) -> Result<(), SolariError> {
214 let _: serde_json::Value = self
215 .http
216 .request(
217 "DELETE",
218 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
219 None,
220 None,
221 )
222 .await?;
223 Ok(())
224 }
225
226 pub async fn pause(&self, sandbox_id: &str) -> Result<(), SolariError> {
230 let _: serde_json::Value = self
231 .http
232 .request(
233 "POST",
234 &format!("/sandboxes/{}/pause", encode_uri_component(sandbox_id)),
235 None,
236 None,
237 )
238 .await?;
239 Ok(())
240 }
241
242 pub async fn resume(&self, sandbox_id: &str) -> Result<String, SolariError> {
249 let r: ResumeResponse = self
250 .http
251 .request(
252 "POST",
253 &format!("/sandboxes/{}/resume", encode_uri_component(sandbox_id)),
254 None,
255 None,
256 )
257 .await?;
258 Ok(r.control_url.unwrap_or_else(|| {
259 format!(
260 "{}/control/{}",
261 self.http.ws_origin(),
262 encode_uri_component(sandbox_id)
263 )
264 }))
265 }
266
267 pub async fn start(&self, sandbox_id: &str) -> Result<String, SolariError> {
287 let r: StartResponse = self
288 .http
289 .request(
290 "POST",
291 &format!("/sandboxes/{}/start", encode_uri_component(sandbox_id)),
292 None,
293 None,
294 )
295 .await?;
296 Ok(r.control_url.unwrap_or_else(|| {
297 format!(
298 "{}/control/{}",
299 self.http.ws_origin(),
300 encode_uri_component(sandbox_id)
301 )
302 }))
303 }
304}
305
306pub struct Sandbox {
308 id: String,
309 kind: String,
310 control_url: String,
311 stream_url: Option<String>,
312 recording_url: Option<String>,
313 expires_at: String,
314 http: Arc<HttpTransport>,
315 channel: Arc<ControlChannel>,
316}
317
318impl Sandbox {
319 pub(crate) fn from_response(
320 http: Arc<HttpTransport>,
321 resp: CreateSandboxResponse,
322 call_timeout_ms: Option<u64>,
323 ) -> Result<Self, SolariError> {
324 if resp.sandbox_id.trim().is_empty() {
329 return Err(SolariError::Other(
330 "Solari: the session response carried a blank id - a blank id builds a malformed /sandboxes// request"
331 .to_string(),
332 ));
333 }
334 let channel = ControlChannel::new(
335 resp.control_url.clone(),
336 http.auth_headers(),
337 call_timeout_ms,
338 );
339 Ok(Sandbox {
340 id: resp.sandbox_id,
341 kind: resp.kind,
342 control_url: resp.control_url,
343 stream_url: resp.stream_url,
344 recording_url: resp.recording_url,
345 expires_at: resp.expires_at,
346 http,
347 channel: Arc::new(channel),
348 })
349 }
350
351 pub fn id(&self) -> &str {
353 &self.id
354 }
355 pub fn kind(&self) -> &str {
357 &self.kind
358 }
359 pub fn control_url(&self) -> &str {
361 &self.control_url
362 }
363 pub fn stream_url(&self) -> Option<&str> {
366 self.stream_url.as_deref()
367 }
368 pub fn recording_url(&self) -> Option<&str> {
370 self.recording_url.as_deref()
371 }
372 pub fn expires_at(&self) -> &str {
374 &self.expires_at
375 }
376 pub fn connected(&self) -> bool {
378 self.channel.connected()
379 }
380
381 pub async fn connect(&self) -> Result<(), SolariError> {
383 self.channel.connect().await
384 }
385
386 pub fn close(&self) {
388 self.channel.close();
389 }
390
391 pub async fn pause(&self) -> Result<(), SolariError> {
398 let _: serde_json::Value = self
399 .http
400 .request(
401 "POST",
402 &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
403 None,
404 None,
405 )
406 .await?;
407 self.close();
408 Ok(())
409 }
410
411 pub async fn resume(&mut self) -> Result<(), SolariError> {
423 let r: ResumeResponse = self
424 .http
425 .request(
426 "POST",
427 &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
428 None,
429 None,
430 )
431 .await?;
432 let control_url = r.control_url.unwrap_or_else(|| {
433 format!(
434 "{}/control/{}",
435 self.http.ws_origin(),
436 encode_uri_component(&self.id)
437 )
438 });
439 self.control_url = control_url.clone();
440 self.channel.set_control_url(control_url);
441 self.channel.reconnect().await
442 }
443
444 pub async fn start(&mut self) -> Result<(), SolariError> {
451 let r: StartResponse = self
452 .http
453 .request(
454 "POST",
455 &format!("/sandboxes/{}/start", encode_uri_component(&self.id)),
456 None,
457 None,
458 )
459 .await?;
460 let control_url = r.control_url.unwrap_or_else(|| {
461 format!(
462 "{}/control/{}",
463 self.http.ws_origin(),
464 encode_uri_component(&self.id)
465 )
466 });
467 self.control_url = control_url.clone();
468 self.channel.set_control_url(control_url);
469 self.channel.reconnect().await
470 }
471
472 pub async fn kill(&self) -> Result<(), SolariError> {
474 let _: serde_json::Value = self
475 .http
476 .request(
477 "DELETE",
478 &format!("/sandboxes/{}", encode_uri_component(&self.id)),
479 None,
480 None,
481 )
482 .await?;
483 self.close();
484 Ok(())
485 }
486
487 pub fn commands(&self) -> Commands<'_> {
490 Commands { sb: self }
491 }
492 pub fn files(&self) -> Files<'_> {
493 Files { sb: self }
494 }
495 pub fn code(&self) -> Code<'_> {
496 Code { sb: self }
497 }
498 pub fn git(&self) -> Git<'_> {
499 Git::new(self)
500 }
501
502 pub(crate) fn channel(&self) -> &ControlChannel {
505 &self.channel
506 }
507 pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
508 self.channel.clone()
509 }
510 pub(crate) fn http(&self) -> &HttpTransport {
511 &self.http
512 }
513}
514
515impl GitRunner for Sandbox {
517 fn run_git<'a>(
518 &'a self,
519 args: Vec<String>,
520 cwd: Option<String>,
521 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
522 self.run_git_env(args, cwd, None)
523 }
524
525 fn run_git_env<'a>(
526 &'a self,
527 args: Vec<String>,
528 cwd: Option<String>,
529 env: Option<std::collections::HashMap<String, String>>,
530 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
531 Box::pin(async move {
532 let opts = RunOptions {
533 args: Some(args),
534 cwd,
535 env,
536 ..Default::default()
537 };
538 run_command(self, "git", &opts).await
539 })
540 }
541}