Skip to main content

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}