1use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
9use reqwest::{Method, Response};
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12
13use crate::discovery::{env_var, runtime_candidates};
14use crate::error::{api_error, Result, WritError};
15use crate::models::WsTicket;
16use crate::resources::{
17 Agent, Automations, Crawl, Data, Datasets, Extractors, Files, Keys, Monitors, Personas, Runs,
18 Secrets, Selectors, Vault, Workflows,
19};
20
21pub(crate) const USER_AGENT: &str = concat!("writ-sdk-rust/", env!("CARGO_PKG_VERSION"));
23
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
26
27const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
29
30pub(crate) const SSE_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
33
34#[derive(Debug, Clone)]
41pub struct WritAgent {
42 inner: Arc<Inner>,
43}
44
45#[derive(Debug)]
47pub(crate) struct Inner {
48 pub(crate) http: reqwest::Client,
49 pub(crate) base_url: String,
50}
51
52#[derive(Debug, Default, Clone)]
57pub struct WritAgentBuilder {
58 base_url: Option<String>,
59 token: Option<String>,
60 timeout: Option<Duration>,
61 ca_pem_file: Option<PathBuf>,
62}
63
64impl WritAgentBuilder {
65 pub fn base_url(mut self, url: impl Into<String>) -> Self {
68 self.base_url = Some(url.into());
69 self
70 }
71
72 pub fn token(mut self, token: impl Into<String>) -> Self {
75 self.token = Some(token.into());
76 self
77 }
78
79 pub fn timeout(mut self, timeout: Duration) -> Self {
82 self.timeout = Some(timeout);
83 self
84 }
85
86 pub fn ca_pem_file(mut self, path: impl Into<PathBuf>) -> Self {
89 self.ca_pem_file = Some(path.into());
90 self
91 }
92
93 fn http_client(&self, timeout: Duration, token: Option<&str>) -> Result<reqwest::Client> {
95 let mut builder = reqwest::Client::builder()
96 .timeout(timeout)
97 .user_agent(USER_AGENT);
98 if let Some(token) = token {
99 let mut headers = HeaderMap::new();
100 let mut auth = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
101 WritError::Discovery("token contains characters invalid in an HTTP header".into())
102 })?;
103 auth.set_sensitive(true);
104 headers.insert(AUTHORIZATION, auth);
105 builder = builder.default_headers(headers);
106 }
107 if let Some(path) = &self.ca_pem_file {
108 let pem = std::fs::read(path).map_err(|e| {
109 WritError::Discovery(format!("cannot read ca_pem_file {}: {e}", path.display()))
110 })?;
111 let cert = reqwest::Certificate::from_pem(&pem).map_err(|e| {
112 WritError::Discovery(format!("invalid CA pem {}: {e}", path.display()))
113 })?;
114 builder = builder.add_root_certificate(cert);
115 }
116 builder
117 .build()
118 .map_err(|e| WritError::Discovery(format!("building http client: {e}")))
119 }
120
121 fn resolved(&self) -> (Option<String>, Option<String>) {
124 let url = self.base_url.clone().or_else(|| env_var("WRIT_API_URL"));
125 let token = self.token.clone().or_else(|| env_var("WRIT_TOKEN"));
126 (url, token)
127 }
128
129 fn assemble(&self, base_url: &str, token: &str) -> Result<WritAgent> {
130 let http = self.http_client(self.timeout.unwrap_or(DEFAULT_TIMEOUT), Some(token))?;
131 Ok(WritAgent {
132 inner: Arc::new(Inner {
133 http,
134 base_url: base_url.trim_end_matches('/').to_string(),
135 }),
136 })
137 }
138
139 pub fn build(self) -> Result<WritAgent> {
144 let (url, token) = self.resolved();
145 let token = token.ok_or_else(|| {
146 WritError::Discovery(
147 "no token configured — is the Writ agent running? pass .token(...) or set WRIT_TOKEN"
148 .into(),
149 )
150 })?;
151 let url = url.unwrap_or_else(|| "http://127.0.0.1:8131".to_string());
152 self.assemble(&url, &token)
153 }
154
155 pub async fn discover(self) -> Result<WritAgent> {
160 let (url_override, token_override) = self.resolved();
161
162 if let (Some(url), Some(token)) = (&url_override, &token_override) {
164 return self.assemble(url, token);
165 }
166
167 let candidates = runtime_candidates();
168 if candidates.is_empty() {
169 return Err(WritError::Discovery(
170 "no runtime.json found under $WRIT_HOME or ~/.writ — is the Writ agent running? \
171 pass base_url/token explicitly or set WRIT_API_URL/WRIT_TOKEN"
172 .into(),
173 ));
174 }
175
176 let probe = self.http_client(PROBE_TIMEOUT, None)?;
177 let mut tried: Vec<String> = Vec::new();
178 for candidate in candidates {
179 let url = url_override
180 .clone()
181 .unwrap_or_else(|| candidate.base_url.clone());
182 let url = url.trim_end_matches('/').to_string();
183 let token = token_override
184 .clone()
185 .unwrap_or_else(|| candidate.token.clone());
186 let live = probe
187 .get(format!("{url}/v1/agent"))
188 .bearer_auth(&token)
189 .send()
190 .await
191 .map(|r| r.status().is_success())
192 .unwrap_or(false);
193 if live {
194 return self.assemble(&url, &token);
195 }
196 tried.push(candidate.source.display().to_string());
197 }
198 Err(WritError::Discovery(format!(
199 "no live Writ agent answered the probe (stale runtime.json candidates: {}) — \
200 is the Writ agent running? pass token=... or set WRIT_TOKEN",
201 tried.join(", ")
202 )))
203 }
204}
205
206impl WritAgent {
207 pub fn builder() -> WritAgentBuilder {
209 WritAgentBuilder::default()
210 }
211
212 pub async fn discover() -> Result<WritAgent> {
215 WritAgentBuilder::default().discover().await
216 }
217
218 pub fn base_url(&self) -> &str {
220 &self.inner.base_url
221 }
222
223 pub fn agent(&self) -> Agent<'_> {
225 Agent { c: &self.inner }
226 }
227
228 pub fn workflows(&self) -> Workflows<'_> {
230 Workflows { c: &self.inner }
231 }
232
233 pub fn runs(&self) -> Runs<'_> {
235 Runs { c: &self.inner }
236 }
237
238 pub fn monitors(&self) -> Monitors<'_> {
240 Monitors { c: &self.inner }
241 }
242
243 pub fn selectors(&self) -> Selectors<'_> {
245 Selectors { c: &self.inner }
246 }
247
248 pub fn extractors(&self) -> Extractors<'_> {
250 Extractors { c: &self.inner }
251 }
252
253 pub fn automations(&self) -> Automations<'_> {
255 Automations { c: &self.inner }
256 }
257
258 pub fn personas(&self) -> Personas<'_> {
260 Personas { c: &self.inner }
261 }
262
263 pub fn secrets(&self) -> Secrets<'_> {
265 Secrets { c: &self.inner }
266 }
267
268 pub fn vault(&self) -> Vault<'_> {
270 Vault { c: &self.inner }
271 }
272
273 pub fn files(&self) -> Files<'_> {
275 Files { c: &self.inner }
276 }
277
278 pub fn data(&self) -> Data<'_> {
280 Data { c: &self.inner }
281 }
282
283 pub fn keys(&self) -> Keys<'_> {
285 Keys { c: &self.inner }
286 }
287
288 pub fn crawl(&self) -> Crawl<'_> {
290 Crawl { c: &self.inner }
291 }
292
293 pub fn datasets(&self) -> Datasets<'_> {
295 Datasets { c: &self.inner }
296 }
297
298 pub async fn ws_ticket(&self, route: &str, channel: Option<&str>) -> Result<WsTicket> {
302 let mut body = serde_json::json!({ "route": route });
303 if let Some(channel) = channel {
304 body["channel"] = Value::String(channel.to_string());
305 }
306 self.inner
307 .send_json(Method::POST, "/v1/ws-ticket", &[], Some(&body))
308 .await
309 }
310}
311
312impl Inner {
313 fn url(&self, path: &str) -> String {
314 format!("{}{}", self.base_url, path)
315 }
316
317 async fn execute(&self, rb: reqwest::RequestBuilder, extra_ok: &[u16]) -> Result<Response> {
319 let resp = rb.send().await.map_err(WritError::from)?;
320 let status = resp.status();
321 if status.is_success() || extra_ok.contains(&status.as_u16()) {
322 return Ok(resp);
323 }
324 let reason = status.canonical_reason().unwrap_or("error").to_string();
325 let text = resp.text().await.unwrap_or_default();
326 Err(api_error(status.as_u16(), &reason, &text))
327 }
328
329 async fn decode<T: DeserializeOwned>(resp: Response) -> Result<T> {
330 resp.json::<T>()
331 .await
332 .map_err(|e| WritError::Connection(format!("decoding response body: {e}")))
333 }
334
335 pub(crate) async fn get_json<T: DeserializeOwned>(
337 &self,
338 path: &str,
339 query: &[(&str, &str)],
340 ) -> Result<T> {
341 let rb = self.http.get(self.url(path)).query(query);
342 Self::decode(self.execute(rb, &[]).await?).await
343 }
344
345 pub(crate) async fn send_json<T: DeserializeOwned>(
347 &self,
348 method: Method,
349 path: &str,
350 query: &[(&str, &str)],
351 body: Option<&Value>,
352 ) -> Result<T> {
353 self.send_json_allowing(method, path, query, body, &[])
354 .await
355 }
356
357 pub(crate) async fn send_json_allowing<T: DeserializeOwned>(
360 &self,
361 method: Method,
362 path: &str,
363 query: &[(&str, &str)],
364 body: Option<&Value>,
365 extra_ok: &[u16],
366 ) -> Result<T> {
367 let mut rb = self.http.request(method, self.url(path)).query(query);
368 if let Some(body) = body {
369 rb = rb.json(body);
370 }
371 Self::decode(self.execute(rb, extra_ok).await?).await
372 }
373
374 pub(crate) async fn get_text(&self, path: &str, query: &[(&str, &str)]) -> Result<String> {
376 let rb = self.http.get(self.url(path)).query(query);
377 self.execute(rb, &[])
378 .await?
379 .text()
380 .await
381 .map_err(|e| WritError::Connection(format!("reading response body: {e}")))
382 }
383
384 pub(crate) async fn get_bytes(
386 &self,
387 path: &str,
388 query: &[(&str, &str)],
389 ) -> Result<bytes::Bytes> {
390 let rb = self.http.get(self.url(path)).query(query);
391 self.execute(rb, &[])
392 .await?
393 .bytes()
394 .await
395 .map_err(|e| WritError::Connection(format!("reading response body: {e}")))
396 }
397
398 pub(crate) async fn get_stream(&self, path: &str, timeout: Duration) -> Result<Response> {
401 let rb = self
402 .http
403 .get(self.url(path))
404 .header(reqwest::header::ACCEPT, "text/event-stream")
405 .timeout(timeout);
406 self.execute(rb, &[]).await
407 }
408
409 pub(crate) async fn post_multipart<T: DeserializeOwned>(
411 &self,
412 path: &str,
413 form: reqwest::multipart::Form,
414 ) -> Result<T> {
415 let rb = self.http.post(self.url(path)).multipart(form);
416 Self::decode(self.execute(rb, &[]).await?).await
417 }
418}