sui_graphql/client/
objects.rs

1//! Object-related convenience methods.
2
3use futures::Stream;
4use sui_graphql_macros::Response;
5use sui_sdk_types::Address;
6use sui_sdk_types::Object;
7
8use super::Client;
9use crate::bcs::Bcs;
10use crate::error::Error;
11use crate::pagination::Page;
12use crate::pagination::PageInfo;
13use crate::pagination::paginate;
14
15impl Client {
16    /// Fetch an object by its ID and deserialize from BCS.
17    ///
18    /// Returns:
19    /// - `Ok(Some(object))` if the object exists
20    /// - `Ok(None)` if the object does not exist
21    /// - `Err(Error::Request)` for network errors
22    /// - `Err(Error::Base64)` / `Err(Error::Bcs)` for decoding errors
23    ///
24    /// # Example
25    ///
26    /// ```no_run
27    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
28    /// use sui_graphql::Client;
29    /// use sui_sdk_types::Address;
30    ///
31    /// let client = Client::new(Client::MAINNET)?;
32    /// let object_id: Address = "0x5".parse()?;
33    ///
34    /// match client.get_object(object_id).await? {
35    ///     Some(object) => println!("Object version: {}", object.version()),
36    ///     None => println!("Object not found"),
37    /// }
38    /// # Ok(())
39    /// # }
40    /// ```
41    pub async fn get_object(&self, object_id: Address) -> Result<Option<Object>, Error> {
42        #[derive(Response)]
43        struct Response {
44            #[field(path = "object?.objectBcs?")]
45            object: Option<Bcs<Object>>,
46        }
47
48        const QUERY: &str = r#"
49            query($id: SuiAddress!) {
50                object(address: $id) {
51                    objectBcs
52                }
53            }
54        "#;
55
56        let variables = serde_json::json!({ "id": object_id });
57
58        let response = self.query::<Response>(QUERY, variables).await?;
59
60        Ok(response.into_data().and_then(|d| d.object).map(|b| b.0))
61    }
62
63    /// Fetch an object at a specific version.
64    pub async fn get_object_at_version(
65        &self,
66        object_id: Address,
67        version: u64,
68    ) -> Result<Option<Object>, Error> {
69        #[derive(Response)]
70        struct Response {
71            #[field(path = "object?.objectBcs?")]
72            object: Option<Bcs<Object>>,
73        }
74
75        const QUERY: &str = r#"
76            query($id: SuiAddress!, $version: UInt53) {
77                object(address: $id, version: $version) {
78                    objectBcs
79                }
80            }
81        "#;
82
83        let variables = serde_json::json!({
84            "id": object_id,
85            "version": version,
86        });
87
88        let response = self.query::<Response>(QUERY, variables).await?;
89
90        Ok(response.into_data().and_then(|d| d.object).map(|b| b.0))
91    }
92
93    /// Fetch an object at a specific checkpoint.
94    ///
95    /// Returns the object's state as of the given checkpoint.
96    pub async fn get_object_at_checkpoint(
97        &self,
98        object_id: Address,
99        checkpoint: u64,
100    ) -> Result<Option<Object>, Error> {
101        #[derive(Response)]
102        struct Response {
103            #[field(path = "object?.objectBcs?")]
104            object: Option<Bcs<Object>>,
105        }
106
107        const QUERY: &str = r#"
108            query($id: SuiAddress!, $atCheckpoint: UInt53) {
109                object(address: $id, atCheckpoint: $atCheckpoint) {
110                    objectBcs
111                }
112            }
113        "#;
114
115        let variables = serde_json::json!({
116            "id": object_id,
117            "atCheckpoint": checkpoint,
118        });
119
120        let response = self.query::<Response>(QUERY, variables).await?;
121
122        Ok(response.into_data().and_then(|d| d.object).map(|b| b.0))
123    }
124
125    /// Fetch an object with a root version bound.
126    ///
127    /// This is useful for fetching child or wrapped objects bounded by their
128    /// root object's version. The object will be fetched at the latest version
129    /// at or before the given root version.
130    pub async fn get_object_with_root_version(
131        &self,
132        object_id: Address,
133        root_version: u64,
134    ) -> Result<Option<Object>, Error> {
135        #[derive(Response)]
136        struct Response {
137            #[field(path = "object?.objectBcs?")]
138            object: Option<Bcs<Object>>,
139        }
140
141        const QUERY: &str = r#"
142            query($id: SuiAddress!, $rootVersion: UInt53) {
143                object(address: $id, rootVersion: $rootVersion) {
144                    objectBcs
145                }
146            }
147        "#;
148
149        let variables = serde_json::json!({
150            "id": object_id,
151            "rootVersion": root_version,
152        });
153
154        let response = self.query::<Response>(QUERY, variables).await?;
155
156        Ok(response.into_data().and_then(|d| d.object).map(|b| b.0))
157    }
158
159    /// Stream all objects owned by an address.
160    ///
161    /// Handles pagination automatically, fetching pages as needed.
162    ///
163    /// # Example
164    ///
165    /// ```no_run
166    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
167    /// use futures::StreamExt;
168    /// use std::pin::pin;
169    /// use sui_graphql::Client;
170    /// use sui_sdk_types::Address;
171    ///
172    /// let client = Client::new(Client::TESTNET)?;
173    /// let owner: Address = "0x123...".parse()?;
174    ///
175    /// let mut stream = pin!(client.list_objects(owner));
176    /// while let Some(result) = stream.next().await {
177    ///     let object = result?;
178    ///     println!("Object version: {}", object.version());
179    /// }
180    /// # Ok(())
181    /// # }
182    /// ```
183    pub fn list_objects(&self, owner: Address) -> impl Stream<Item = Result<Object, Error>> + '_ {
184        let client = self.clone();
185        paginate(move |cursor| {
186            let client = client.clone();
187            async move { client.fetch_objects_page(owner, cursor.as_deref()).await }
188        })
189    }
190
191    /// Fetch a single page of objects owned by an address.
192    async fn fetch_objects_page(
193        &self,
194        owner: Address,
195        cursor: Option<&str>,
196    ) -> Result<Page<Object>, Error> {
197        #[derive(Response)]
198        struct Response {
199            #[field(path = "objects?.pageInfo?")]
200            page_info: Option<PageInfo>,
201            #[field(path = "objects?.nodes?[].objectBcs")]
202            objects: Option<Vec<Bcs<Object>>>,
203        }
204
205        const QUERY: &str = r#"
206            query($owner: SuiAddress!, $after: String) {
207                objects(filter: { owner: $owner }, after: $after) {
208                    pageInfo {
209                        hasNextPage
210                        endCursor
211                    }
212                    nodes {
213                        objectBcs
214                    }
215                }
216            }
217        "#;
218
219        let variables = serde_json::json!({
220            "owner": owner,
221            "after": cursor,
222        });
223
224        let response = self.query::<Response>(QUERY, variables).await?;
225
226        let data = response.into_data();
227        let page_info = data
228            .as_ref()
229            .and_then(|d| d.page_info.clone())
230            .unwrap_or_default();
231
232        let objects = data
233            .and_then(|d| d.objects)
234            .unwrap_or_default()
235            .into_iter()
236            .map(|b| b.0)
237            .collect();
238
239        Ok(Page {
240            items: objects,
241            has_next_page: page_info.has_next_page,
242            end_cursor: page_info.end_cursor,
243            ..Default::default()
244        })
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use futures::StreamExt;
252    use std::sync::Arc;
253    use std::sync::atomic::AtomicUsize;
254    use std::sync::atomic::Ordering;
255    use wiremock::Mock;
256    use wiremock::MockServer;
257    use wiremock::ResponseTemplate;
258    use wiremock::matchers::method;
259    use wiremock::matchers::path;
260
261    /// BCS-encoded SUI coin from sui-sdk-types test fixtures, encoded as base64.
262    fn test_object_bcs() -> String {
263        use base64ct::Base64;
264        use base64ct::Encoding;
265
266        // From sui-sdk-types/src/object.rs test fixtures (SUI_COIN)
267        const SUI_COIN_BCS: &[u8] = &[
268            0, 1, 1, 32, 79, 43, 0, 0, 0, 0, 0, 40, 35, 95, 175, 213, 151, 87, 206, 190, 35, 131,
269            79, 35, 254, 22, 15, 181, 40, 108, 28, 77, 68, 229, 107, 254, 191, 160, 196, 186, 42,
270            2, 122, 53, 52, 133, 199, 58, 0, 0, 0, 0, 0, 79, 255, 208, 0, 85, 34, 190, 75, 192, 41,
271            114, 76, 127, 15, 110, 215, 9, 58, 107, 243, 160, 155, 144, 230, 47, 97, 220, 21, 24,
272            30, 26, 62, 32, 17, 197, 192, 38, 64, 173, 142, 143, 49, 111, 15, 211, 92, 84, 48, 160,
273            243, 102, 229, 253, 251, 137, 210, 101, 119, 173, 228, 51, 141, 20, 15, 85, 96, 19, 15,
274            0, 0, 0, 0, 0,
275        ];
276        Base64::encode_string(SUI_COIN_BCS)
277    }
278
279    #[tokio::test]
280    async fn test_get_object_not_found() {
281        let mock_server = MockServer::start().await;
282
283        Mock::given(method("POST"))
284            .and(path("/"))
285            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
286                "data": {
287                    "object": null
288                }
289            })))
290            .mount(&mock_server)
291            .await;
292
293        let client = Client::new(&mock_server.uri()).unwrap();
294        let object_id: Address = "0x5".parse().unwrap();
295
296        let result = client.get_object(object_id).await;
297        assert!(result.is_ok());
298        assert!(result.unwrap().is_none());
299    }
300
301    #[tokio::test]
302    async fn test_get_object_found() {
303        let mock_server = MockServer::start().await;
304
305        Mock::given(method("POST"))
306            .and(path("/"))
307            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
308                "data": {
309                    "object": {
310                        "objectBcs": test_object_bcs()
311                    }
312                }
313            })))
314            .mount(&mock_server)
315            .await;
316
317        let client = Client::new(&mock_server.uri()).unwrap();
318        let object_id: Address = "0x5".parse().unwrap();
319
320        let result = client.get_object(object_id).await;
321        assert!(result.is_ok());
322        assert!(result.unwrap().is_some());
323    }
324
325    #[tokio::test]
326    async fn test_list_objects_empty() {
327        let mock_server = MockServer::start().await;
328
329        Mock::given(method("POST"))
330            .and(path("/"))
331            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
332                "data": {
333                    "objects": {
334                        "pageInfo": {
335                            "hasNextPage": false,
336                            "endCursor": null
337                        },
338                        "nodes": []
339                    }
340                }
341            })))
342            .mount(&mock_server)
343            .await;
344
345        let client = Client::new(&mock_server.uri()).unwrap();
346        let owner: Address = "0x1".parse().unwrap();
347
348        let stream = client.list_objects(owner);
349        let objects: Vec<_> = futures::StreamExt::collect(stream).await;
350
351        assert!(objects.is_empty());
352    }
353
354    #[tokio::test]
355    async fn test_list_objects_with_pagination() {
356        let mock_server = MockServer::start().await;
357        let call_count = Arc::new(AtomicUsize::new(0));
358        let call_count_clone = call_count.clone();
359
360        Mock::given(method("POST"))
361            .and(path("/"))
362            .respond_with(move |_req: &wiremock::Request| {
363                let count = call_count_clone.fetch_add(1, Ordering::SeqCst);
364                match count {
365                    // Page 1: 3 objects
366                    0 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
367                        "data": {
368                            "objects": {
369                                "pageInfo": {
370                                    "hasNextPage": true,
371                                    "endCursor": "cursor1"
372                                },
373                                "nodes": [
374                                    { "objectBcs": test_object_bcs() },
375                                    { "objectBcs": test_object_bcs() },
376                                    { "objectBcs": test_object_bcs() }
377                                ]
378                            }
379                        }
380                    })),
381                    // Page 2: 2 objects
382                    1 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
383                        "data": {
384                            "objects": {
385                                "pageInfo": {
386                                    "hasNextPage": false,
387                                    "endCursor": null
388                                },
389                                "nodes": [
390                                    { "objectBcs": test_object_bcs() },
391                                    { "objectBcs": test_object_bcs() }
392                                ]
393                            }
394                        }
395                    })),
396                    _ => ResponseTemplate::new(200).set_body_json(serde_json::json!({
397                        "data": { "objects": { "pageInfo": { "hasNextPage": false, "endCursor": null }, "nodes": [] } }
398                    })),
399                }
400            })
401            .mount(&mock_server)
402            .await;
403
404        let client = Client::new(&mock_server.uri()).unwrap();
405        let owner: Address = "0x1".parse().unwrap();
406
407        let stream = client.list_objects(owner);
408        let objects: Vec<_> = futures::StreamExt::collect(stream).await;
409
410        // Should have fetched 5 objects across 2 pages (3 + 2)
411        assert_eq!(objects.len(), 5);
412        assert_eq!(call_count.load(Ordering::SeqCst), 2);
413
414        for result in objects {
415            assert!(result.is_ok());
416        }
417    }
418
419    #[tokio::test]
420    async fn test_list_objects_partial_consumption() {
421        let mock_server = MockServer::start().await;
422        let call_count = Arc::new(AtomicUsize::new(0));
423        let call_count_clone = call_count.clone();
424
425        Mock::given(method("POST"))
426            .and(path("/"))
427            .respond_with(move |_req: &wiremock::Request| {
428                let count = call_count_clone.fetch_add(1, Ordering::SeqCst);
429                match count {
430                    // Page 1: 3 objects
431                    0 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
432                        "data": {
433                            "objects": {
434                                "pageInfo": {
435                                    "hasNextPage": true,
436                                    "endCursor": "cursor1"
437                                },
438                                "nodes": [
439                                    { "objectBcs": test_object_bcs() },
440                                    { "objectBcs": test_object_bcs() },
441                                    { "objectBcs": test_object_bcs() }
442                                ]
443                            }
444                        }
445                    })),
446                    // Page 2: 2 objects
447                    1 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
448                        "data": {
449                            "objects": {
450                                "pageInfo": {
451                                    "hasNextPage": false,
452                                    "endCursor": null
453                                },
454                                "nodes": [
455                                    { "objectBcs": test_object_bcs() },
456                                    { "objectBcs": test_object_bcs() }
457                                ]
458                            }
459                        }
460                    })),
461                    _ => panic!("unexpected page request"),
462                }
463            })
464            .mount(&mock_server)
465            .await;
466
467        let client = Client::new(&mock_server.uri()).unwrap();
468        let owner: Address = "0x1".parse().unwrap();
469
470        // Only take 3 objects out of 5 available
471        let stream = client.list_objects(owner).take(3);
472        let objects: Vec<_> = stream.collect().await;
473
474        // Should have only fetched 3 objects from the first page
475        assert_eq!(objects.len(), 3);
476        assert_eq!(call_count.load(Ordering::SeqCst), 1);
477
478        for result in objects {
479            assert!(result.is_ok());
480        }
481    }
482}