Skip to main content

sui_graphql/client/
objects.rs

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