boundless_market/storage/providers/
pinata.rs1use crate::storage::{StorageError, StorageUploader, StorageUploaderConfig, StorageUploaderType};
21use anyhow::anyhow;
22use async_trait::async_trait;
23use reqwest::{
24 multipart::{Form, Part},
25 Client,
26};
27use url::Url;
28
29const DEFAULT_PINATA_API_URL: &str = "https://uploads.pinata.cloud";
30const DEFAULT_GATEWAY_URL: &str = "https://gateway.pinata.cloud";
31
32#[derive(Clone, Debug)]
42pub struct PinataStorageUploader {
43 client: Client,
44 pinata_jwt: String,
45 pinata_api_url: Url,
46 ipfs_gateway_url: Url,
47}
48
49impl PinataStorageUploader {
50 pub fn from_config(config: &StorageUploaderConfig) -> Result<Self, StorageError> {
52 assert_eq!(config.storage_uploader, StorageUploaderType::Pinata);
53
54 let jwt =
55 config.pinata_jwt.clone().ok_or_else(|| StorageError::MissingConfig("pinata_jwt"))?;
56
57 let api_url = config
58 .pinata_api_url
59 .clone()
60 .unwrap_or_else(|| Url::parse(DEFAULT_PINATA_API_URL).unwrap());
61
62 let gateway_url = config
63 .ipfs_gateway_url
64 .clone()
65 .unwrap_or_else(|| Url::parse(DEFAULT_GATEWAY_URL).unwrap());
66
67 Ok(Self {
68 client: Client::new(),
69 pinata_jwt: jwt,
70 pinata_api_url: api_url,
71 ipfs_gateway_url: gateway_url,
72 })
73 }
74
75 pub fn new(jwt: String, api_url: Url, gateway_url: Url) -> Self {
77 Self {
78 client: Client::new(),
79 pinata_jwt: jwt,
80 pinata_api_url: api_url,
81 ipfs_gateway_url: gateway_url,
82 }
83 }
84
85 async fn upload(&self, data: &[u8], filename: &str) -> Result<Url, StorageError> {
87 let url = self.pinata_api_url.join("/v3/files")?;
88
89 let form = Form::new()
90 .part(
91 "file",
92 Part::bytes(data.to_vec())
93 .mime_str("application/octet-stream")
94 .map_err(StorageError::http)?
95 .file_name(filename.to_string()),
96 )
97 .part("network", Part::text("public"));
98
99 let request = self
100 .client
101 .post(url)
102 .header("Authorization", format!("Bearer {}", self.pinata_jwt))
103 .multipart(form)
104 .build()
105 .map_err(StorageError::http)?;
106
107 tracing::debug!("Sending Pinata upload request: {}", request.url());
108
109 let response = self.client.execute(request).await.map_err(StorageError::http)?;
110
111 tracing::debug!("Pinata response status: {}", response.status());
112
113 let response = response.error_for_status().map_err(StorageError::http)?;
114
115 let json_value: serde_json::Value = response.json().await.map_err(StorageError::http)?;
116
117 let ipfs_hash = json_value
118 .as_object()
119 .ok_or_else(|| StorageError::Other(anyhow!("response is not a JSON object")))?
120 .get("data")
121 .ok_or_else(|| StorageError::Other(anyhow!("response missing 'data' field")))?
122 .get("cid")
123 .ok_or_else(|| StorageError::Other(anyhow!("response missing 'data.cid' field")))?
124 .as_str()
125 .ok_or_else(|| StorageError::Other(anyhow!("invalid IPFS hash type")))?;
126
127 let data_url = self.ipfs_gateway_url.join(&format!("ipfs/{ipfs_hash}"))?;
128 Ok(data_url)
129 }
130}
131
132#[async_trait]
133impl StorageUploader for PinataStorageUploader {
134 async fn upload_bytes(&self, data: &[u8], key: &str) -> Result<Url, StorageError> {
135 self.upload(data, key).await
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::storage::{HttpDownloader, StorageDownloader, StorageUploader};
143 use std::env;
144
145 #[tokio::test]
146 #[ignore = "requires PINATA_JWT credentials"]
147 async fn test_pinata_roundtrip() {
148 let jwt = env::var("PINATA_JWT").expect("PINATA_JWT missing");
149 let api_url = Url::parse(DEFAULT_PINATA_API_URL).unwrap();
150 let gateway_url = Url::parse(DEFAULT_GATEWAY_URL).unwrap();
151 let uploader = PinataStorageUploader::new(jwt, api_url, gateway_url);
152
153 let test_data = b"pinata integration test data";
154 let url = uploader.upload_input(test_data).await.expect("upload failed");
155
156 assert!(
157 url.scheme() == "https" || url.scheme() == "http",
158 "expected HTTPS gateway URL, got {}",
159 url.scheme()
160 );
161
162 let downloader = HttpDownloader::default();
163 let downloaded = downloader.download_url(url).await.expect("download failed");
164
165 assert_eq!(downloaded, test_data);
166 }
167}