Skip to content

Commit bfecf1b

Browse files
committed
sui-graphql-rust-sdk: add dynamic fields API [11/n]
1 parent e8df528 commit bfecf1b

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
//! Dynamic field related convenience methods.
2+
3+
use futures::Stream;
4+
use sui_graphql_macros::Response;
5+
6+
use super::Client;
7+
use crate::error::Error;
8+
use crate::pagination::Page;
9+
use crate::pagination::PageInfo;
10+
use crate::pagination::paginate;
11+
12+
/// The type of a dynamic field.
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub enum DynamicFieldType {
15+
/// A regular dynamic field (value is wrapped, not accessible by ID).
16+
Field,
17+
/// A dynamic object field (child object remains accessible by ID).
18+
Object,
19+
}
20+
21+
/// A dynamic field entry with its name and value.
22+
#[derive(Debug, Clone)]
23+
#[non_exhaustive]
24+
pub struct DynamicFieldEntry {
25+
/// The field name as JSON.
26+
pub name: serde_json::Value,
27+
/// The field name's Move type (e.g., "u64", "0x2::kiosk::Listing").
28+
pub name_type: String,
29+
/// The field value as JSON.
30+
pub value: serde_json::Value,
31+
/// Whether this is a Field or ObjectField.
32+
pub field_type: DynamicFieldType,
33+
}
34+
35+
impl Client {
36+
/// List all dynamic fields on an object as a paginated stream.
37+
///
38+
/// Returns a stream that yields each dynamic field entry with its name and value.
39+
/// The stream handles pagination automatically.
40+
pub fn get_dynamic_fields(
41+
&self,
42+
parent: &str,
43+
) -> impl Stream<Item = Result<DynamicFieldEntry, Error>> + '_ {
44+
let client = self.clone();
45+
let parent = parent.to_string();
46+
paginate(move |cursor| {
47+
let client = client.clone();
48+
let parent = parent.clone();
49+
async move {
50+
client
51+
.fetch_dynamic_fields_page(&parent, cursor.as_deref())
52+
.await
53+
}
54+
})
55+
}
56+
57+
/// Fetch a single page of dynamic fields.
58+
async fn fetch_dynamic_fields_page(
59+
&self,
60+
parent: &str,
61+
cursor: Option<&str>,
62+
) -> Result<Page<DynamicFieldEntry>, Error> {
63+
#[derive(Response)]
64+
struct Response {
65+
#[field(path = "object.dynamicFields.nodes[].name.type.repr")]
66+
name_types: Option<Vec<String>>,
67+
#[field(path = "object.dynamicFields.nodes[].name.json")]
68+
names: Option<Vec<serde_json::Value>>,
69+
#[field(path = "object.dynamicFields.nodes[].value")]
70+
values: Option<Vec<serde_json::Value>>,
71+
#[field(path = "object.dynamicFields.pageInfo")]
72+
page_info: Option<PageInfo>,
73+
}
74+
75+
const QUERY: &str = r#"
76+
query($parent: SuiAddress!, $cursor: String) {
77+
object(address: $parent) {
78+
dynamicFields(after: $cursor) {
79+
nodes {
80+
name {
81+
type { repr }
82+
json
83+
}
84+
value {
85+
... on MoveValue {
86+
__typename
87+
json
88+
}
89+
... on MoveObject {
90+
__typename
91+
contents { json }
92+
}
93+
}
94+
}
95+
pageInfo {
96+
hasNextPage
97+
endCursor
98+
}
99+
}
100+
}
101+
}
102+
"#;
103+
104+
let variables = serde_json::json!({
105+
"parent": parent,
106+
"cursor": cursor,
107+
});
108+
109+
let response = self.query::<Response>(QUERY, variables).await?;
110+
111+
let Some(data) = response.into_data() else {
112+
return Ok(Page {
113+
items: vec![],
114+
has_next_page: false,
115+
end_cursor: None,
116+
});
117+
};
118+
119+
let page_info = data.page_info.unwrap_or(PageInfo {
120+
has_next_page: false,
121+
end_cursor: None,
122+
});
123+
124+
let name_types = data.name_types.unwrap_or_default();
125+
let names = data.names.unwrap_or_default();
126+
let values = data.values.unwrap_or_default();
127+
128+
let items: Vec<DynamicFieldEntry> = name_types
129+
.into_iter()
130+
.zip(names)
131+
.zip(values)
132+
.map(|((name_type, name), value)| {
133+
// Determine field type based on the value's __typename
134+
let field_type = if let Some(obj) = value.as_object() {
135+
if obj.get("__typename").and_then(|t| t.as_str()) == Some("MoveObject") {
136+
DynamicFieldType::Object
137+
} else {
138+
DynamicFieldType::Field
139+
}
140+
} else {
141+
DynamicFieldType::Field
142+
};
143+
144+
// Extract the actual value (json field for MoveValue, contents.json for MoveObject)
145+
let extracted_value = if let Some(obj) = value.as_object() {
146+
if let Some(json) = obj.get("json") {
147+
json.clone()
148+
} else if let Some(contents) = obj.get("contents") {
149+
contents
150+
.get("json")
151+
.cloned()
152+
.unwrap_or(serde_json::Value::Null)
153+
} else {
154+
value
155+
}
156+
} else {
157+
value
158+
};
159+
160+
DynamicFieldEntry {
161+
name,
162+
name_type,
163+
value: extracted_value,
164+
field_type,
165+
}
166+
})
167+
.collect();
168+
169+
Ok(Page {
170+
items,
171+
has_next_page: page_info.has_next_page,
172+
end_cursor: page_info.end_cursor,
173+
})
174+
}
175+
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use super::*;
180+
use futures::StreamExt;
181+
use std::pin::pin;
182+
use wiremock::Mock;
183+
use wiremock::MockServer;
184+
use wiremock::ResponseTemplate;
185+
use wiremock::matchers::method;
186+
use wiremock::matchers::path;
187+
188+
#[tokio::test]
189+
async fn test_get_dynamic_fields_empty() {
190+
let mock_server = MockServer::start().await;
191+
192+
Mock::given(method("POST"))
193+
.and(path("/"))
194+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
195+
"data": {
196+
"object": {
197+
"dynamicFields": {
198+
"nodes": [],
199+
"pageInfo": {
200+
"hasNextPage": false,
201+
"endCursor": null
202+
}
203+
}
204+
}
205+
}
206+
})))
207+
.mount(&mock_server)
208+
.await;
209+
210+
let client = Client::new(&mock_server.uri()).unwrap();
211+
212+
let mut stream = pin!(client.get_dynamic_fields("0x123"));
213+
let result = stream.next().await;
214+
assert!(result.is_none());
215+
}
216+
217+
#[tokio::test]
218+
async fn test_get_dynamic_fields_with_values() {
219+
let mock_server = MockServer::start().await;
220+
221+
Mock::given(method("POST"))
222+
.and(path("/"))
223+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
224+
"data": {
225+
"object": {
226+
"dynamicFields": {
227+
"nodes": [
228+
{
229+
"name": {
230+
"type": { "repr": "u64" },
231+
"json": "123"
232+
},
233+
"value": {
234+
"__typename": "MoveValue",
235+
"json": { "balance": "1000" }
236+
}
237+
},
238+
{
239+
"name": {
240+
"type": { "repr": "0x2::kiosk::Listing" },
241+
"json": { "id": "0xabc" }
242+
},
243+
"value": {
244+
"__typename": "MoveObject",
245+
"contents": {
246+
"json": { "price": "500" }
247+
}
248+
}
249+
}
250+
],
251+
"pageInfo": {
252+
"hasNextPage": false,
253+
"endCursor": null
254+
}
255+
}
256+
}
257+
}
258+
})))
259+
.mount(&mock_server)
260+
.await;
261+
262+
let client = Client::new(&mock_server.uri()).unwrap();
263+
264+
let mut stream = pin!(client.get_dynamic_fields("0x123"));
265+
266+
// First field - MoveValue
267+
let field1 = stream.next().await.unwrap().unwrap();
268+
assert_eq!(field1.name_type, "u64");
269+
assert_eq!(field1.name, serde_json::json!("123"));
270+
assert_eq!(field1.field_type, DynamicFieldType::Field);
271+
assert_eq!(field1.value, serde_json::json!({ "balance": "1000" }));
272+
273+
// Second field - MoveObject
274+
let field2 = stream.next().await.unwrap().unwrap();
275+
assert_eq!(field2.name_type, "0x2::kiosk::Listing");
276+
assert_eq!(field2.field_type, DynamicFieldType::Object);
277+
assert_eq!(field2.value, serde_json::json!({ "price": "500" }));
278+
279+
// No more fields
280+
assert!(stream.next().await.is_none());
281+
}
282+
283+
#[tokio::test]
284+
async fn test_get_dynamic_fields_object_not_found() {
285+
let mock_server = MockServer::start().await;
286+
287+
Mock::given(method("POST"))
288+
.and(path("/"))
289+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
290+
"data": {
291+
"object": null
292+
}
293+
})))
294+
.mount(&mock_server)
295+
.await;
296+
297+
let client = Client::new(&mock_server.uri()).unwrap();
298+
299+
let mut stream = pin!(client.get_dynamic_fields("0xnonexistent"));
300+
let result = stream.next().await;
301+
assert!(result.is_none());
302+
}
303+
}

crates/sui-graphql/src/client/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub mod chain;
44
pub mod checkpoints;
55
pub mod coins;
6+
pub mod dynamic_fields;
67
mod objects;
78
pub mod transactions;
89

crates/sui-graphql/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ pub use client::Client;
1515
pub use client::chain::Epoch;
1616
pub use client::checkpoints::CheckpointResponse;
1717
pub use client::coins::Balance;
18+
pub use client::dynamic_fields::DynamicFieldEntry;
19+
pub use client::dynamic_fields::DynamicFieldType;
1820
pub use client::transactions::TransactionResponse;
1921
pub use error::Error;
2022
pub use error::GraphQLError;

0 commit comments

Comments
 (0)