cow_sdk_app_data/fetch.rs
1use serde_json::Value;
2
3use crate::{AppDataDoc, AppDataError, DEFAULT_IPFS_READ_URI, IpfsConfig, app_data_hex_to_cid};
4
5/// Read transport seam for fetching app-data JSON from IPFS.
6///
7/// # Implementing
8///
9/// The seam is consumed generically (`&impl IpfsFetchTransport`), never as a
10/// trait object, so an implementor writes `get` as a native `async fn` with no
11/// attribute macro:
12///
13/// ```
14/// use cow_sdk_app_data::{AppDataError, IpfsFetchTransport};
15///
16/// struct MyIpfsReads;
17///
18/// impl IpfsFetchTransport for MyIpfsReads {
19/// async fn get(&self, uri: &str) -> Result<String, AppDataError> {
20/// todo!("fetch `uri` and return the response body")
21/// }
22/// }
23/// ```
24#[expect(
25 async_fn_in_trait,
26 reason = "the trait surface adopts native async fn in trait per ADR 0010 runtime-neutral posture; the resulting non-Send futures are covered by the workspace future_not_send allow so wasm callbacks can satisfy the same trait without an explicit Send bound"
27)]
28pub trait IpfsFetchTransport {
29 /// Performs a GET request against `uri`.
30 ///
31 /// # Errors
32 ///
33 /// Returns the transport-specific error when the read request fails.
34 async fn get(&self, uri: &str) -> Result<String, AppDataError>;
35}
36
37/// Fetch policy for IPFS reads.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct IpfsFetchPolicy {
40 read_base_uri: String,
41}
42
43impl Default for IpfsFetchPolicy {
44 fn default() -> Self {
45 Self {
46 read_base_uri: DEFAULT_IPFS_READ_URI.to_owned(),
47 }
48 }
49}
50
51impl IpfsFetchPolicy {
52 /// Creates a fetch policy with an explicit read base URI.
53 ///
54 /// # Errors
55 ///
56 /// Returns [`AppDataError::Transport`] if the URI is empty after trimming.
57 pub fn new(read_base_uri: impl Into<String>) -> Result<Self, AppDataError> {
58 let read_base_uri = read_base_uri.into();
59 Ok(Self {
60 read_base_uri: normalize_read_base_uri(&read_base_uri)?,
61 })
62 }
63
64 /// Creates a fetch policy from [`IpfsConfig`].
65 ///
66 /// `read_uri` takes precedence over the general `uri` field.
67 ///
68 /// # Errors
69 ///
70 /// Returns [`AppDataError::Transport`] if the resolved URI is empty after trimming.
71 pub fn from_config(config: &IpfsConfig) -> Result<Self, AppDataError> {
72 let read_base_uri = config
73 .read_uri
74 .as_ref()
75 .map(|uri| uri.as_inner().as_str())
76 .or_else(|| config.uri.as_ref().map(|uri| uri.as_inner().as_str()))
77 .unwrap_or(DEFAULT_IPFS_READ_URI);
78
79 Self::new(read_base_uri)
80 }
81
82 /// Returns the normalized IPFS read base URI.
83 #[must_use]
84 pub fn read_base_uri(&self) -> &str {
85 &self.read_base_uri
86 }
87
88 /// Returns a copy of this policy with a new read base URI.
89 ///
90 /// # Errors
91 ///
92 /// Returns [`AppDataError::Transport`] if the URI is empty after trimming.
93 pub fn with_read_base_uri(
94 mut self,
95 read_base_uri: impl Into<String>,
96 ) -> Result<Self, AppDataError> {
97 let read_base_uri = read_base_uri.into();
98 self.read_base_uri = normalize_read_base_uri(&read_base_uri)?;
99 Ok(self)
100 }
101}
102
103/// Fetches an app-data document by CID using an optional base URI override.
104///
105/// # Errors
106///
107/// Returns [`AppDataError`] if the policy is invalid, the transport fails, or
108/// the fetched payload is not valid JSON.
109pub async fn fetch_doc_from_cid(
110 cid: &str,
111 transport: &impl IpfsFetchTransport,
112 ipfs_uri: Option<&str>,
113) -> Result<AppDataDoc, AppDataError> {
114 fetch_doc_from_cid_with_policy(cid, transport, &policy_from_optional_uri(ipfs_uri)?).await
115}
116
117/// Fetches an app-data document by CID using an explicit fetch policy.
118///
119/// This is the shared IPFS read leaf: every `fetch_doc_*` entry point funnels
120/// here, so the single `fetch_doc_from_cid_with_policy` span covers each fetch
121/// path exactly once. The span records the requested `cid` and a stable
122/// `endpoint` label only; the configured read base URI — which may carry a
123/// gateway credential — is never recorded, matching the `Redacted<String>`
124/// posture of [`IpfsConfig`].
125///
126/// # Errors
127///
128/// Returns [`AppDataError`] if the transport fails or the fetched payload is not valid JSON.
129#[cfg_attr(
130 feature = "tracing",
131 tracing::instrument(
132 skip_all,
133 fields(
134 endpoint = "app_data.fetch_doc_from_cid",
135 cid = %cid,
136 ),
137 ),
138)]
139pub async fn fetch_doc_from_cid_with_policy(
140 cid: &str,
141 transport: &impl IpfsFetchTransport,
142 policy: &IpfsFetchPolicy,
143) -> Result<AppDataDoc, AppDataError> {
144 let raw = transport
145 .get(&format!("{}/{}", policy.read_base_uri(), cid))
146 .await?;
147 serde_json::from_str::<Value>(&raw).map_err(AppDataError::from)
148}
149
150/// Fetches an app-data document using the app-data hex digest.
151///
152/// The primary way to read a document you uploaded is the orderbook
153/// `GET /app_data/{hash}` request, which is served from the orderbook database
154/// and needs no IPFS gateway. This helper is the secondary, not-in-database
155/// path: it derives the keccak-256 `CIDv1` from `app_data_hex` and reads it
156/// through the injected transport, so `ipfs_uri` must point at a gateway that
157/// can resolve keccak-CID documents — a generic public gateway cannot.
158///
159/// # Errors
160///
161/// Returns [`AppDataError`] if CID derivation, policy creation, transport execution,
162/// or JSON decoding fails.
163pub async fn fetch_doc_from_app_data_hex(
164 app_data_hex: &str,
165 transport: &impl IpfsFetchTransport,
166 ipfs_uri: Option<&str>,
167) -> Result<AppDataDoc, AppDataError> {
168 fetch_doc_from_app_data_hex_with_policy(
169 app_data_hex,
170 transport,
171 &policy_from_optional_uri(ipfs_uri)?,
172 )
173 .await
174}
175
176/// Fetches an app-data document using the app-data hex digest and an explicit policy.
177///
178/// # Errors
179///
180/// Returns [`AppDataError`] if CID derivation, transport execution, or JSON decoding fails.
181pub async fn fetch_doc_from_app_data_hex_with_policy(
182 app_data_hex: &str,
183 transport: &impl IpfsFetchTransport,
184 policy: &IpfsFetchPolicy,
185) -> Result<AppDataDoc, AppDataError> {
186 // Propagate the original typed `InvalidAppDataHex` (class = Validation): a
187 // caller-supplied bad digest is a validation failure, not a transport/decode
188 // one, and the typed error carries the precise reason without re-wrapping the
189 // caller's input into a transport detail string.
190 let cid = app_data_hex_to_cid(app_data_hex)?;
191 fetch_doc_from_cid_with_policy(&cid, transport, policy).await
192}
193
194fn policy_from_optional_uri(ipfs_uri: Option<&str>) -> Result<IpfsFetchPolicy, AppDataError> {
195 ipfs_uri.map_or_else(|| Ok(IpfsFetchPolicy::default()), IpfsFetchPolicy::new)
196}
197
198fn normalize_read_base_uri(read_base_uri: &str) -> Result<String, AppDataError> {
199 let normalized = read_base_uri.trim().trim_end_matches('/').to_owned();
200
201 if normalized.is_empty() {
202 return Err(AppDataError::Transport {
203 class: cow_sdk_core::TransportErrorClass::Builder,
204 detail: "ipfs read base uri must not be empty".to_owned().into(),
205 });
206 }
207
208 Ok(normalized)
209}