reduct_rs/bucket/
link.rs

1// Copyright 2025 ReductStore
2// This Source Code Form is subject to the terms of the Mozilla Public
3//    License, v. 2.0. If a copy of the MPL was not distributed with this
4//    file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
6use crate::http_client::HttpClient;
7use crate::Bucket;
8use chrono::{DateTime, Utc};
9use http::Method;
10use reduct_base::error::ReductError;
11use reduct_base::msg::entry_api::QueryEntry;
12use reduct_base::msg::query_link_api::{QueryLinkCreateRequest, QueryLinkCreateResponse};
13use std::sync::Arc;
14
15pub struct CreateQueryLinkBuilder {
16    request: QueryLinkCreateRequest,
17    file_name: Option<String>,
18    http_client: Arc<HttpClient>,
19}
20
21impl CreateQueryLinkBuilder {
22    pub(crate) fn new(bucket: String, entry: String, http_client: Arc<HttpClient>) -> Self {
23        Self {
24            request: QueryLinkCreateRequest {
25                bucket,
26                entry,
27                expire_at: Utc::now() + chrono::Duration::hours(24),
28                ..Default::default()
29            },
30            file_name: None,
31            http_client,
32        }
33    }
34
35    /// Set the expiration time for the query link.
36    pub fn expire_at(mut self, expire_at: DateTime<Utc>) -> Self {
37        self.request.expire_at = expire_at;
38        self
39    }
40
41    /// Set the record index for the query link.
42    pub fn index(mut self, index: u64) -> Self {
43        self.request.index = Some(index);
44        self
45    }
46
47    /// Set the query to share.
48    pub fn query(mut self, query: QueryEntry) -> Self {
49        self.request.query = query;
50        self
51    }
52
53    /// Set the file name for the query link.
54    pub fn file_name(mut self, file_name: &str) -> Self {
55        self.file_name = Some(file_name.to_string());
56        self
57    }
58
59    /// Set the base URL for the query link.
60    pub fn base_url(mut self, base_url: &str) -> Self {
61        self.request.base_url = Some(base_url.to_string());
62        self
63    }
64
65    /// Send the create query link request.
66    pub async fn send(self) -> Result<String, ReductError> {
67        let file_name = self.file_name.unwrap_or(format!(
68            "{}_{}.bin",
69            self.request.entry,
70            self.request.index.unwrap_or(0)
71        ));
72        let response: QueryLinkCreateResponse = self
73            .http_client
74            .send_and_receive_json(
75                Method::POST,
76                &format!("/links/{}", file_name),
77                Some(self.request),
78            )
79            .await?;
80        Ok(response.link)
81    }
82}
83
84impl Bucket {
85    /// Create a query link for sharing.
86    ///
87    /// # Arguments
88    ///
89    /// * `entry` - The entry to create the query link for.
90    ///
91    /// # Returns
92    ///
93    /// Returns a builder for creating a query link.
94    pub fn create_query_link(&self, entry: &str) -> CreateQueryLinkBuilder {
95        CreateQueryLinkBuilder::new(
96            self.name.clone(),
97            entry.to_string(),
98            self.http_client.clone(),
99        )
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use crate::bucket::tests::bucket;
106    use crate::Bucket;
107    use reduct_base::msg::entry_api::QueryEntry;
108    use rstest::rstest;
109
110    #[cfg(feature = "test-api-117")]
111    #[rstest]
112    #[tokio::test]
113    async fn test_link_creation(#[future] bucket: Bucket) {
114        let bucket: Bucket = bucket.await;
115        let link = bucket
116            .create_query_link("entry-1")
117            .expire_at(chrono::Utc::now() + chrono::Duration::hours(1))
118            .send()
119            .await
120            .unwrap();
121
122        let body = reqwest::get(&link).await.unwrap().text().await.unwrap();
123        assert_eq!(body, "Hey entry-1!");
124    }
125
126    #[cfg(feature = "test-api-117")]
127    #[rstest]
128    #[tokio::test]
129    async fn test_link_creation_with_query(#[future] bucket: Bucket) {
130        let bucket: Bucket = bucket.await;
131        let link = bucket
132            .create_query_link("entry-1")
133            .query(QueryEntry {
134                start: Some(0),
135                ..Default::default()
136            })
137            .send()
138            .await
139            .unwrap();
140        let body = reqwest::get(&link).await.unwrap().text().await.unwrap();
141        assert_eq!(body, "Hey entry-1!");
142    }
143
144    #[cfg(feature = "test-api-117")]
145    #[rstest]
146    #[tokio::test]
147    async fn test_link_creation_with_index(#[future] bucket: Bucket) {
148        let bucket: Bucket = bucket.await;
149        let link = bucket
150            .create_query_link("entry-1")
151            .index(1)
152            .send()
153            .await
154            .unwrap();
155        let body = reqwest::get(&link).await.unwrap().text().await.unwrap();
156        assert_eq!(body, r#"{"detail": "Record number out of range"}"#)
157    }
158
159    #[cfg(feature = "test-api-117")]
160    #[rstest]
161    #[tokio::test]
162    async fn test_link_creation_expired(#[future] bucket: Bucket) {
163        let bucket: Bucket = bucket.await;
164        let link = bucket
165            .create_query_link("entry-1")
166            .expire_at(chrono::Utc::now() - chrono::Duration::hours(1))
167            .send()
168            .await
169            .unwrap();
170        let response = reqwest::get(&link).await.unwrap();
171        assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY);
172    }
173
174    #[cfg(feature = "test-api-117")]
175    #[rstest]
176    #[tokio::test]
177    async fn test_link_creation_file_name(#[future] bucket: Bucket) {
178        let bucket: Bucket = bucket.await;
179        let link = bucket
180            .create_query_link("entry-1")
181            .file_name("my-link.bin")
182            .send()
183            .await
184            .unwrap();
185        assert!(link.contains("links/my-link.bin?"));
186    }
187}