Skip to main content

polyoxide_gamma/api/
search.rs

1use polyoxide_core::{HttpClient, QueryBuilder, Request};
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    error::GammaError,
6    types::{Event, Tag},
7};
8
9/// Search namespace for search operations
10#[derive(Clone)]
11pub struct Search {
12    pub(crate) http_client: HttpClient,
13}
14
15impl Search {
16    /// Search profiles, events, and tags
17    pub fn public_search(&self, query: impl Into<String>) -> PublicSearch {
18        let request =
19            Request::new(self.http_client.clone(), "/public-search").query("q", query.into());
20        PublicSearch { request }
21    }
22}
23
24/// Request builder for public search
25pub struct PublicSearch {
26    request: Request<SearchResponse, GammaError>,
27}
28
29impl PublicSearch {
30    /// Include profile results in search
31    pub fn search_profiles(mut self, include: bool) -> Self {
32        self.request = self.request.query("search_profiles", include);
33        self
34    }
35
36    /// Set maximum results per type
37    pub fn limit_per_type(mut self, limit: u32) -> Self {
38        self.request = self.request.query("limit_per_type", limit);
39        self
40    }
41
42    /// Set page number
43    pub fn page(mut self, page: u32) -> Self {
44        self.request = self.request.query("page", page);
45        self
46    }
47
48    /// Enable/disable caching
49    pub fn cache(mut self, cache: bool) -> Self {
50        self.request = self.request.query("cache", cache);
51        self
52    }
53
54    /// Filter by event status
55    pub fn events_status(mut self, status: impl Into<String>) -> Self {
56        self.request = self.request.query("events_status", status.into());
57        self
58    }
59
60    /// Filter by event tag IDs
61    ///
62    /// Safe batch size: ≤ 200 per request. URLs over ~8 KB are rejected
63    /// upstream with `414 URI Too Long`.
64    pub fn events_tag(mut self, tag_ids: impl IntoIterator<Item = impl ToString>) -> Self {
65        self.request = self.request.query_many("events_tag", tag_ids);
66        self
67    }
68
69    /// Include closed markets in results
70    pub fn keep_closed_markets(mut self, keep: i32) -> Self {
71        self.request = self.request.query("keep_closed_markets", keep);
72        self
73    }
74
75    /// Set sort order
76    pub fn sort(mut self, sort: impl Into<String>) -> Self {
77        self.request = self.request.query("sort", sort.into());
78        self
79    }
80
81    /// Sort direction (used only when [`sort`](Self::sort) is set).
82    pub fn ascending(mut self, ascending: bool) -> Self {
83        self.request = self.request.query("ascending", ascending);
84        self
85    }
86
87    /// Include tag search results
88    pub fn search_tags(mut self, include: bool) -> Self {
89        self.request = self.request.query("search_tags", include);
90        self
91    }
92
93    /// Filter by recurrence pattern
94    pub fn recurrence(mut self, recurrence: impl Into<String>) -> Self {
95        self.request = self.request.query("recurrence", recurrence.into());
96        self
97    }
98
99    /// Exclude events with specified tag IDs
100    ///
101    /// Safe batch size: ≤ 500 per request. Tag IDs are short integers
102    /// (~5 B/entry); URLs over ~8 KB are rejected upstream with `414`.
103    pub fn exclude_tag_id(mut self, tag_ids: impl IntoIterator<Item = i64>) -> Self {
104        self.request = self.request.query_many("exclude_tag_id", tag_ids);
105        self
106    }
107
108    /// Enable optimized search
109    pub fn optimized(mut self, optimized: bool) -> Self {
110        self.request = self.request.query("optimized", optimized);
111        self
112    }
113
114    /// Execute the request
115    pub async fn send(self) -> Result<SearchResponse, GammaError> {
116        self.request.send().await
117    }
118}
119
120/// Response from public search
121#[cfg_attr(feature = "specta", derive(specta::Type))]
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct SearchResponse {
125    /// Matching user profiles
126    #[serde(default)]
127    pub profiles: Vec<SearchProfile>,
128    /// Matching events
129    #[serde(default)]
130    pub events: Vec<Event>,
131    /// Matching tags
132    #[serde(default)]
133    pub tags: Vec<Tag>,
134}
135
136/// Profile result from search
137#[cfg_attr(feature = "specta", derive(specta::Type))]
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct SearchProfile {
141    /// User address
142    pub address: Option<String>,
143    /// Display name
144    pub name: Option<String>,
145    /// Profile image URL
146    pub profile_image: Option<String>,
147    /// User pseudonym
148    pub pseudonym: Option<String>,
149    /// User biography
150    pub bio: Option<String>,
151    /// Proxy wallet address
152    pub proxy_wallet: Option<String>,
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::Gamma;
159
160    fn gamma() -> Gamma {
161        Gamma::new().unwrap()
162    }
163
164    #[test]
165    fn test_public_search_full_chain() {
166        let _search = gamma()
167            .search()
168            .public_search("bitcoin")
169            .search_profiles(true)
170            .limit_per_type(10)
171            .page(1)
172            .cache(false)
173            .events_status("active")
174            .events_tag(vec![1i64, 2])
175            .keep_closed_markets(0)
176            .sort("volume")
177            .search_tags(true)
178            .recurrence("daily")
179            .exclude_tag_id(vec![99i64])
180            .optimized(true);
181    }
182
183    #[test]
184    fn test_search_response_deserialization() {
185        let json = r#"{
186            "profiles": [
187                {
188                    "address": "0xabc",
189                    "name": "trader1",
190                    "profileImage": null,
191                    "pseudonym": null,
192                    "bio": null,
193                    "proxyWallet": "0xproxy"
194                }
195            ],
196            "events": [],
197            "tags": []
198        }"#;
199        let resp: SearchResponse = serde_json::from_str(json).unwrap();
200        assert_eq!(resp.profiles.len(), 1);
201        assert_eq!(resp.profiles[0].address.as_deref(), Some("0xabc"));
202        assert!(resp.events.is_empty());
203        assert!(resp.tags.is_empty());
204    }
205
206    #[test]
207    fn test_search_response_empty() {
208        let json = r#"{"profiles": [], "events": [], "tags": []}"#;
209        let resp: SearchResponse = serde_json::from_str(json).unwrap();
210        assert!(resp.profiles.is_empty());
211    }
212
213    #[test]
214    fn test_search_response_missing_fields() {
215        let json = r#"{}"#;
216        let resp: SearchResponse = serde_json::from_str(json).unwrap();
217        assert!(resp.profiles.is_empty());
218        assert!(resp.events.is_empty());
219        assert!(resp.tags.is_empty());
220    }
221
222    #[test]
223    fn test_search_profile_deserialization() {
224        let json = r#"{
225            "address": "0x123",
226            "name": "Searcher",
227            "profileImage": "https://img.example.com/pic.png",
228            "pseudonym": "anon",
229            "bio": "A bio",
230            "proxyWallet": "0xproxy123"
231        }"#;
232        let profile: SearchProfile = serde_json::from_str(json).unwrap();
233        assert_eq!(profile.address.as_deref(), Some("0x123"));
234        assert_eq!(profile.name.as_deref(), Some("Searcher"));
235        assert_eq!(profile.bio.as_deref(), Some("A bio"));
236        assert_eq!(profile.proxy_wallet.as_deref(), Some("0xproxy123"));
237    }
238
239    #[test]
240    fn test_search_profile_all_null() {
241        let json = r#"{}"#;
242        let profile: SearchProfile = serde_json::from_str(json).unwrap();
243        assert!(profile.address.is_none());
244        assert!(profile.name.is_none());
245        assert!(profile.profile_image.is_none());
246        assert!(profile.pseudonym.is_none());
247        assert!(profile.bio.is_none());
248        assert!(profile.proxy_wallet.is_none());
249    }
250}