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    /// Include tag search results
82    pub fn search_tags(mut self, include: bool) -> Self {
83        self.request = self.request.query("search_tags", include);
84        self
85    }
86
87    /// Filter by recurrence pattern
88    pub fn recurrence(mut self, recurrence: impl Into<String>) -> Self {
89        self.request = self.request.query("recurrence", recurrence.into());
90        self
91    }
92
93    /// Exclude events with specified tag IDs
94    ///
95    /// Safe batch size: ≤ 500 per request. Tag IDs are short integers
96    /// (~5 B/entry); URLs over ~8 KB are rejected upstream with `414`.
97    pub fn exclude_tag_id(mut self, tag_ids: impl IntoIterator<Item = i64>) -> Self {
98        self.request = self.request.query_many("exclude_tag_id", tag_ids);
99        self
100    }
101
102    /// Enable optimized search
103    pub fn optimized(mut self, optimized: bool) -> Self {
104        self.request = self.request.query("optimized", optimized);
105        self
106    }
107
108    /// Execute the request
109    pub async fn send(self) -> Result<SearchResponse, GammaError> {
110        self.request.send().await
111    }
112}
113
114/// Response from public search
115#[cfg_attr(feature = "specta", derive(specta::Type))]
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct SearchResponse {
119    /// Matching user profiles
120    #[serde(default)]
121    pub profiles: Vec<SearchProfile>,
122    /// Matching events
123    #[serde(default)]
124    pub events: Vec<Event>,
125    /// Matching tags
126    #[serde(default)]
127    pub tags: Vec<Tag>,
128}
129
130/// Profile result from search
131#[cfg_attr(feature = "specta", derive(specta::Type))]
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct SearchProfile {
135    /// User address
136    pub address: Option<String>,
137    /// Display name
138    pub name: Option<String>,
139    /// Profile image URL
140    pub profile_image: Option<String>,
141    /// User pseudonym
142    pub pseudonym: Option<String>,
143    /// User biography
144    pub bio: Option<String>,
145    /// Proxy wallet address
146    pub proxy_wallet: Option<String>,
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::Gamma;
153
154    fn gamma() -> Gamma {
155        Gamma::new().unwrap()
156    }
157
158    #[test]
159    fn test_public_search_full_chain() {
160        let _search = gamma()
161            .search()
162            .public_search("bitcoin")
163            .search_profiles(true)
164            .limit_per_type(10)
165            .page(1)
166            .cache(false)
167            .events_status("active")
168            .events_tag(vec![1i64, 2])
169            .keep_closed_markets(0)
170            .sort("volume")
171            .search_tags(true)
172            .recurrence("daily")
173            .exclude_tag_id(vec![99i64])
174            .optimized(true);
175    }
176
177    #[test]
178    fn test_search_response_deserialization() {
179        let json = r#"{
180            "profiles": [
181                {
182                    "address": "0xabc",
183                    "name": "trader1",
184                    "profileImage": null,
185                    "pseudonym": null,
186                    "bio": null,
187                    "proxyWallet": "0xproxy"
188                }
189            ],
190            "events": [],
191            "tags": []
192        }"#;
193        let resp: SearchResponse = serde_json::from_str(json).unwrap();
194        assert_eq!(resp.profiles.len(), 1);
195        assert_eq!(resp.profiles[0].address.as_deref(), Some("0xabc"));
196        assert!(resp.events.is_empty());
197        assert!(resp.tags.is_empty());
198    }
199
200    #[test]
201    fn test_search_response_empty() {
202        let json = r#"{"profiles": [], "events": [], "tags": []}"#;
203        let resp: SearchResponse = serde_json::from_str(json).unwrap();
204        assert!(resp.profiles.is_empty());
205    }
206
207    #[test]
208    fn test_search_response_missing_fields() {
209        let json = r#"{}"#;
210        let resp: SearchResponse = serde_json::from_str(json).unwrap();
211        assert!(resp.profiles.is_empty());
212        assert!(resp.events.is_empty());
213        assert!(resp.tags.is_empty());
214    }
215
216    #[test]
217    fn test_search_profile_deserialization() {
218        let json = r#"{
219            "address": "0x123",
220            "name": "Searcher",
221            "profileImage": "https://img.example.com/pic.png",
222            "pseudonym": "anon",
223            "bio": "A bio",
224            "proxyWallet": "0xproxy123"
225        }"#;
226        let profile: SearchProfile = serde_json::from_str(json).unwrap();
227        assert_eq!(profile.address.as_deref(), Some("0x123"));
228        assert_eq!(profile.name.as_deref(), Some("Searcher"));
229        assert_eq!(profile.bio.as_deref(), Some("A bio"));
230        assert_eq!(profile.proxy_wallet.as_deref(), Some("0xproxy123"));
231    }
232
233    #[test]
234    fn test_search_profile_all_null() {
235        let json = r#"{}"#;
236        let profile: SearchProfile = serde_json::from_str(json).unwrap();
237        assert!(profile.address.is_none());
238        assert!(profile.name.is_none());
239        assert!(profile.profile_image.is_none());
240        assert!(profile.pseudonym.is_none());
241        assert!(profile.bio.is_none());
242        assert!(profile.proxy_wallet.is_none());
243    }
244}