1use std::time::Duration;
4
5use acdp_primitives::error::AcdpError;
6use acdp_primitives::limits::{
7 CONNECT_TIMEOUT, MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS, REQUEST_TIMEOUT,
8};
9use acdp_safe_http::SsrfPolicy;
10use acdp_types::{
11 body::FullContext,
12 capabilities::CapabilitiesDocument,
13 primitives::{CtxId, LineageId},
14 publish::{PublishRequest, PublishResponse, WireError},
15 search::{SearchParams, SearchResponse},
16};
17use chrono::{DateTime, Utc};
18use reqwest::{redirect, Client};
19
20#[derive(Clone)]
27pub struct RegistryClient {
28 base: String,
29 http: Client,
30}
31
32#[derive(Debug, Clone, Default)]
38pub struct RetrievalMetadata {
39 pub etag: Option<String>,
41 pub cache_control: Option<String>,
43 pub last_modified: Option<DateTime<Utc>>,
45}
46
47impl RegistryClient {
48 pub fn authority(&self) -> Option<String> {
52 url::Url::parse(&self.base).ok().and_then(|u| {
53 let host = u.host_str()?.to_string();
54 Some(match u.port() {
55 Some(p) => format!("{host}:{p}"),
56 None => host,
57 })
58 })
59 }
60
61 pub fn new(base_url: &str) -> Result<Self, AcdpError> {
67 Self::build(base_url, None, None, SsrfPolicy::default())
68 }
69
70 #[cfg(feature = "test-transport")]
78 pub fn with_root_cert_pem(base_url: &str, pem: &[u8]) -> Result<Self, AcdpError> {
79 Self::build(base_url, Some(pem), None, SsrfPolicy::allow_test_loopback())
83 }
84
85 #[doc(hidden)]
94 #[cfg(feature = "test-transport")]
95 pub fn with_test_transport(base_url: &str) -> Result<Self, AcdpError> {
96 let policy = SsrfPolicy {
97 reject_ip_literals: false,
98 allow_http: true,
99 allow_loopback_resolved: true,
100 };
101 Self::build(base_url, None, None, policy)
102 }
103
104 #[doc(hidden)]
116 #[cfg(feature = "test-transport")]
117 pub fn with_test_endpoint(
118 base_url: &str,
119 target: std::net::SocketAddr,
120 pem: &[u8],
121 ) -> Result<Self, AcdpError> {
122 Self::build(
125 base_url,
126 Some(pem),
127 Some(target),
128 SsrfPolicy::allow_test_loopback(),
129 )
130 }
131
132 fn build(
133 base_url: &str,
134 extra_root_pem: Option<&[u8]>,
135 resolve_target: Option<std::net::SocketAddr>,
136 policy_ssrf: SsrfPolicy,
137 ) -> Result<Self, AcdpError> {
138 let base = base_url.trim_end_matches('/').to_string();
139 policy_ssrf.check_url(&base)?;
146 let original_authority = url::Url::parse(&base)
147 .ok()
148 .and_then(|u| u.host_str().map(str::to_string));
149
150 let policy = redirect::Policy::custom(move |attempt| {
151 if attempt.previous().len() >= MAX_REDIRECTS {
152 return attempt.error(format!(
153 "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
154 ));
155 }
156 let cross = attempt
159 .previous()
160 .first()
161 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
162 .map(|orig| (orig.to_string(), attempt.url().to_string()));
163 if let Some((from, to)) = cross {
164 return attempt.error(format!(
165 "cross-authority redirect rejected ({from} -> {to})"
166 ));
167 }
168 attempt.follow()
169 });
170
171 let mut builder = Client::builder()
172 .use_rustls_tls()
173 .connect_timeout(CONNECT_TIMEOUT)
174 .timeout(REQUEST_TIMEOUT)
175 .redirect(policy)
176 .dns_resolver(acdp_safe_http::SafeDnsResolver::arc(policy_ssrf));
180
181 if let Some(pem) = extra_root_pem {
182 let cert = reqwest::Certificate::from_pem(pem)
183 .map_err(|e| AcdpError::Http(format!("invalid root cert PEM: {e}")))?;
184 builder = builder.add_root_certificate(cert);
185 }
186
187 if let (Some(target), Some(host)) = (resolve_target, original_authority) {
188 builder = builder.resolve(&host, target);
189 }
190
191 let http = builder
192 .build()
193 .map_err(|e| AcdpError::Http(e.to_string()))?;
194
195 Ok(Self { base, http })
196 }
197
198 pub async fn new_pinned(base_url: &str, policy: &SsrfPolicy) -> Result<Self, AcdpError> {
212 let base = base_url.trim_end_matches('/').to_string();
213 let parsed = url::Url::parse(&base)
214 .map_err(|e| AcdpError::SchemaViolation(format!("invalid base URL: {e}")))?;
215 policy.check_url(&base)?;
217 let host = parsed
218 .host_str()
219 .ok_or_else(|| AcdpError::SchemaViolation(format!("base URL has no host: {base}")))?
220 .to_string();
221 let port = parsed
222 .port_or_known_default()
223 .unwrap_or(if parsed.scheme() == "http" { 80 } else { 443 });
224
225 let pinned = policy.pin_resolved_ip(&host, port).await?;
226
227 let policy_redirect = redirect::Policy::custom(move |attempt| {
228 if attempt.previous().len() >= MAX_REDIRECTS {
229 return attempt.error(format!(
230 "exceeded {MAX_REDIRECTS} redirects per RFC-ACDP-0006 §7.5"
231 ));
232 }
233 let cross = attempt
236 .previous()
237 .first()
238 .filter(|orig| !acdp_safe_http::same_fetch_authority(orig, attempt.url()))
239 .map(|orig| (orig.to_string(), attempt.url().to_string()));
240 if let Some((from, to)) = cross {
241 return attempt.error(format!(
242 "cross-authority redirect rejected ({from} -> {to})"
243 ));
244 }
245 attempt.follow()
246 });
247
248 let http = Client::builder()
249 .use_rustls_tls()
250 .connect_timeout(CONNECT_TIMEOUT)
251 .timeout(REQUEST_TIMEOUT)
252 .redirect(policy_redirect)
253 .resolve(&host, pinned)
254 .build()
255 .map_err(|e| AcdpError::Http(e.to_string()))?;
256
257 Ok(Self { base, http })
258 }
259
260 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
268 pub async fn capabilities(&self) -> Result<CapabilitiesDocument, AcdpError> {
269 Ok(self.capabilities_with_ttl().await?.0)
270 }
271
272 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
281 pub async fn capabilities_with_ttl(
282 &self,
283 ) -> Result<(CapabilitiesDocument, std::time::Duration), AcdpError> {
284 let url = format!("{}/.well-known/acdp.json", self.base);
285 let resp = self.http.get(&url).send().await?;
286 let ttl = cache_ttl_from_response(&resp);
287 let caps: CapabilitiesDocument = self.parse_success(resp, MAX_METADATA_BYTES).await?;
288 acdp_validation::validate_capabilities(&caps)?;
289 Ok((caps, ttl))
290 }
291
292 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, req)))]
296 pub async fn publish(&self, req: &PublishRequest) -> Result<PublishResponse, AcdpError> {
297 let url = format!("{}/contexts", self.base);
298 let resp = self
299 .http
300 .post(&url)
301 .header("Content-Type", "application/acdp+json")
302 .json(req)
303 .send()
304 .await?;
305 self.parse_success(resp, MAX_METADATA_BYTES).await
306 }
307
308 pub async fn publish_idempotent(
310 &self,
311 req: &PublishRequest,
312 idempotency_key: &str,
313 ) -> Result<PublishResponse, AcdpError> {
314 let url = format!("{}/contexts", self.base);
315 let resp = self
316 .http
317 .post(&url)
318 .header("Content-Type", "application/acdp+json")
319 .header("Idempotency-Key", idempotency_key)
320 .json(req)
321 .send()
322 .await?;
323 self.parse_success(resp, MAX_METADATA_BYTES).await
324 }
325
326 pub async fn publish_with_retry(
333 &self,
334 req: &PublishRequest,
335 idempotency_key: &str,
336 max_attempts: u32,
337 ) -> Result<PublishResponse, AcdpError> {
338 let attempts = max_attempts.max(1);
339 let mut last_err: Option<AcdpError> = None;
340 for attempt in 0..attempts {
341 match self.publish_idempotent(req, idempotency_key).await {
342 Ok(resp) => return Ok(resp),
343 Err(e) if e.is_transient() && attempt + 1 < attempts => {
344 let backoff_ms = 250u64 * (1 << attempt.min(3));
345 last_err = Some(e);
346 #[cfg(feature = "tracing")]
347 tracing::debug!(
348 attempt = attempt + 1,
349 backoff_ms,
350 "publish transient failure; retrying"
351 );
352 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
353 }
354 Err(e) => return Err(e),
355 }
356 }
357 Err(last_err
358 .unwrap_or_else(|| AcdpError::Http("publish_with_retry exhausted attempts".into())))
359 }
360
361 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(ctx_id = %ctx_id)))]
367 pub async fn retrieve(&self, ctx_id: &CtxId) -> Result<FullContext, AcdpError> {
368 let encoded = urlencoding::encode(ctx_id.as_str());
369 let url = format!("{}/contexts/{}", self.base, encoded);
370 let resp = self.http.get(&url).send().await?;
371 self.parse_success(resp, MAX_CONTEXT_BYTES).await
372 }
373
374 pub async fn retrieve_with_metadata(
376 &self,
377 ctx_id: &CtxId,
378 ) -> Result<(FullContext, RetrievalMetadata), AcdpError> {
379 let encoded = urlencoding::encode(ctx_id.as_str());
380 let url = format!("{}/contexts/{}", self.base, encoded);
381 let resp = self.http.get(&url).send().await?;
382 let metadata = parse_retrieval_metadata(&resp);
383 let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
384 Ok((body, metadata))
385 }
386
387 pub async fn retrieve_if_none_match(
392 &self,
393 ctx_id: &CtxId,
394 etag: &str,
395 ) -> Result<Option<(FullContext, RetrievalMetadata)>, AcdpError> {
396 let encoded = urlencoding::encode(ctx_id.as_str());
397 let url = format!("{}/contexts/{}", self.base, encoded);
398 let resp = self
399 .http
400 .get(&url)
401 .header("If-None-Match", etag)
402 .send()
403 .await?;
404 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
405 return Ok(None);
406 }
407 let metadata = parse_retrieval_metadata(&resp);
408 let body = self.parse_success(resp, MAX_CONTEXT_BYTES).await?;
409 Ok(Some((body, metadata)))
410 }
411
412 pub async fn retrieve_body(&self, ctx_id: &CtxId) -> Result<acdp_types::body::Body, AcdpError> {
414 let encoded = urlencoding::encode(ctx_id.as_str());
415 let url = format!("{}/contexts/{}/body", self.base, encoded);
416 let resp = self.http.get(&url).send().await?;
417 self.parse_success(resp, MAX_CONTEXT_BYTES).await
418 }
419
420 pub async fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
424 let encoded = urlencoding::encode(lineage_id.as_str());
425 let url = format!("{}/lineages/{}", self.base, encoded);
426 let resp = self.http.get(&url).send().await?;
427 self.parse_success::<serde_json::Value>(resp, MAX_CONTEXT_BYTES)
428 .await
429 .and_then(|v| {
430 serde_json::from_value(v).map_err(|e| AcdpError::Serialization(e.to_string()))
431 })
432 }
433
434 pub async fn current(&self, lineage_id: &LineageId) -> Result<FullContext, AcdpError> {
436 let encoded = urlencoding::encode(lineage_id.as_str());
437 let url = format!("{}/lineages/{}/current", self.base, encoded);
438 let resp = self.http.get(&url).send().await?;
439 self.parse_success(resp, MAX_CONTEXT_BYTES).await
440 }
441
442 pub async fn search(&self, params: &SearchParams) -> Result<SearchResponse, AcdpError> {
449 let url = format!("{}/contexts/search", self.base);
450 let resp = self.http.get(&url).query(params).send().await?;
451 self.parse_success(resp, MAX_METADATA_BYTES).await
452 }
453
454 pub fn search_builder(&self) -> RegistrySearch<'_> {
470 RegistrySearch::new(self)
471 }
472}
473
474pub struct RegistrySearch<'a> {
477 client: &'a RegistryClient,
478 inner: acdp_types::search::SearchParamsBuilder,
479}
480
481impl<'a> RegistrySearch<'a> {
482 fn new(client: &'a RegistryClient) -> Self {
483 Self {
484 client,
485 inner: acdp_types::search::SearchParamsBuilder::new(),
486 }
487 }
488
489 pub async fn send(self) -> Result<SearchResponse, AcdpError> {
491 let params = self.inner.build();
492 self.client.search(¶ms).await
493 }
494 pub fn q(mut self, q: impl Into<String>) -> Self {
496 self.inner = self.inner.q(q);
497 self
498 }
499 pub fn context_type(mut self, t: impl Into<String>) -> Self {
501 self.inner = self.inner.context_type(t);
502 self
503 }
504 pub fn domain(mut self, d: impl Into<String>) -> Self {
506 self.inner = self.inner.domain(d);
507 self
508 }
509 pub fn tag(mut self, t: impl Into<String>) -> Self {
511 self.inner = self.inner.tag(t);
512 self
513 }
514 pub fn agent_id(mut self, a: impl Into<String>) -> Self {
516 self.inner = self.inner.agent_id(a);
517 self
518 }
519 pub fn derived_from(mut self, c: &acdp_types::CtxId) -> Self {
521 self.inner = self.inner.derived_from_ctx_id(c);
522 self
523 }
524 pub fn created_after(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
526 self.inner = self.inner.created_after(dt);
527 self
528 }
529 pub fn created_before(mut self, dt: chrono::DateTime<chrono::Utc>) -> Self {
531 self.inner = self.inner.created_before(dt);
532 self
533 }
534 pub fn status(mut self, s: impl Into<String>) -> Self {
536 self.inner = self.inner.status(s);
537 self
538 }
539 pub fn limit(mut self, l: u32) -> Self {
541 self.inner = self.inner.limit(l);
542 self
543 }
544 pub fn cursor(mut self, c: impl Into<String>) -> Self {
546 self.inner = self.inner.cursor(c);
547 self
548 }
549}
550
551impl RegistryClient {
554 async fn parse_success<T: serde::de::DeserializeOwned>(
555 &self,
556 resp: reqwest::Response,
557 max_bytes: usize,
558 ) -> Result<T, AcdpError> {
559 if resp.status().is_success() {
560 let bytes = read_body_capped(resp, max_bytes).await?;
561 serde_json::from_slice(&bytes).map_err(|e| AcdpError::Serialization(e.to_string()))
562 } else {
563 let bytes = match read_body_capped(resp, MAX_METADATA_BYTES).await {
566 Ok(b) => b,
567 Err(_) => {
568 return Err(AcdpError::from_wire_error(WireError {
569 error: acdp_types::publish::WireErrorBody {
570 code: "unknown".into(),
571 message: "could not read registry error response".into(),
572 details: None,
573 },
574 }));
575 }
576 };
577 let wire: WireError = serde_json::from_slice(&bytes).unwrap_or_else(|_| WireError {
578 error: acdp_types::publish::WireErrorBody {
579 code: "unknown".into(),
580 message: "could not parse registry error response".into(),
581 details: None,
582 },
583 });
584 Err(AcdpError::from_wire_error(wire))
585 }
586 }
587}
588
589fn cache_ttl_from_response(resp: &reqwest::Response) -> std::time::Duration {
597 const MAX_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
598 const DEFAULT_CAPS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
599
600 let Some(cc) = resp
601 .headers()
602 .get(reqwest::header::CACHE_CONTROL)
603 .and_then(|v| v.to_str().ok())
604 else {
605 return DEFAULT_CAPS_CACHE_TTL;
606 };
607 for directive in cc.split(',') {
608 let directive = directive.trim();
609 if let Some(value) = directive
610 .strip_prefix("max-age=")
611 .or_else(|| directive.strip_prefix("s-maxage="))
612 {
613 if let Ok(secs) = value.parse::<u64>() {
614 return std::time::Duration::from_secs(secs).min(MAX_CAPS_CACHE_TTL);
615 }
616 }
617 }
618 DEFAULT_CAPS_CACHE_TTL
619}
620
621fn parse_retrieval_metadata(resp: &reqwest::Response) -> RetrievalMetadata {
622 let headers = resp.headers();
623 let etag = headers
624 .get(reqwest::header::ETAG)
625 .and_then(|v| v.to_str().ok())
626 .map(|s| s.to_string());
627 let cache_control = headers
628 .get(reqwest::header::CACHE_CONTROL)
629 .and_then(|v| v.to_str().ok())
630 .map(|s| s.to_string());
631 let last_modified = headers
632 .get(reqwest::header::LAST_MODIFIED)
633 .and_then(|v| v.to_str().ok())
634 .and_then(|s| {
635 DateTime::parse_from_rfc2822(s)
636 .ok()
637 .map(|dt| dt.with_timezone(&Utc))
638 });
639 RetrievalMetadata {
640 etag,
641 cache_control,
642 last_modified,
643 }
644}
645
646async fn read_body_capped(
649 mut resp: reqwest::Response,
650 max_bytes: usize,
651) -> Result<Vec<u8>, AcdpError> {
652 if let Some(len) = resp.content_length() {
653 if len as usize > max_bytes {
654 return Err(AcdpError::PayloadTooLarge(format!(
655 "response Content-Length {len} exceeds cap {max_bytes}"
656 )));
657 }
658 }
659 let mut buf = Vec::with_capacity(8 * 1024);
660 while let Some(chunk) = resp
661 .chunk()
662 .await
663 .map_err(|e| AcdpError::Http(e.to_string()))?
664 {
665 if buf.len() + chunk.len() > max_bytes {
666 return Err(AcdpError::PayloadTooLarge(format!(
667 "response body exceeded {max_bytes} bytes"
668 )));
669 }
670 buf.extend_from_slice(&chunk);
671 }
672 Ok(buf)
673}