Skip to main content

iota_sdk_grpc_client/api/
common.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Common utilities shared across API modules.
5
6use std::borrow::Cow;
7
8pub use iota_grpc_types::{
9    field::FieldMask, field_mask_normalize, google::rpc::Status as RpcStatus,
10    proto::TryFromProtoError,
11};
12use iota_grpc_types::{
13    proto::GrpcConversionError,
14    v1::{
15        bcs::BcsData,
16        ledger_service::{ObjectResult, TransactionResult, object_result, transaction_result},
17        object::Object as ProtoObject,
18        transaction::{ExecutedTransaction, Transaction as ProtoTransaction},
19        transaction_execution_service::{
20            ExecuteTransactionResult, SimulateTransactionResult, SimulatedTransaction,
21            execute_transaction_result, simulate_transaction_result,
22        },
23        types::ObjectId as ProtoObjectId,
24    },
25};
26use iota_types::{ObjectId, TransactionDigest, Version};
27use serde::Serialize;
28
29use super::MetadataEnvelope;
30
31/// Errors that can occur during gRPC client API operations.
32#[derive(Debug, thiserror::Error)]
33#[non_exhaustive]
34pub enum Error {
35    /// Error converting proto types to SDK types.
36    #[error("proto conversion error: {0}")]
37    ProtoConversion(#[from] Box<TryFromProtoError>),
38
39    /// Per-item error returned by the server (preserves code, message,
40    /// details).
41    #[error("server error (code {code}): {msg}", code = .0.code, msg = .0.message)]
42    Server(RpcStatus),
43
44    /// Client-side protocol error (e.g. checkpoint stream reassembly).
45    #[error("protocol error: {0}")]
46    Protocol(ProtocolError),
47
48    /// Error converting signatures to proto format.
49    #[error("signature conversion error: {0}")]
50    Signature(GrpcConversionError),
51
52    /// The caller passed an empty request (e.g. no object IDs or digests).
53    #[error("empty request: at least one item must be provided")]
54    EmptyRequest,
55
56    /// The server stream ended unexpectedly while `has_next` was still true.
57    #[error("stream ended unexpectedly: server indicated more results with has_next=true")]
58    UnexpectedEndOfStream,
59
60    /// gRPC transport or protocol error.
61    #[error("grpc error: {0}")]
62    Grpc(Box<tonic::Status>),
63}
64
65impl Error {
66    /// Returns `true` if the error carries a `NOT_FOUND` status, whether the
67    /// server reported it for the call or for a single item of a batched
68    /// request.
69    ///
70    /// **Warning:** do not test the outer error of a batched read to decide
71    /// that an item is absent. Those calls report an absent item against the
72    /// request that asked for it, so a `NOT_FOUND` for the call means the
73    /// endpoint is wrong; treating it as a missing item turns a misconfigured
74    /// endpoint into a normal "not there" answer. Test the per-item result
75    /// instead. Calls returning a single response, such as fetching a
76    /// checkpoint, do report absence at the call level.
77    pub fn is_not_found(&self) -> bool {
78        match self {
79            Error::Server(status) => status.code == i32::from(tonic::Code::NotFound),
80            Error::Grpc(status) => status.code() == tonic::Code::NotFound,
81            _ => false,
82        }
83    }
84}
85
86impl From<TryFromProtoError> for Error {
87    fn from(err: TryFromProtoError) -> Self {
88        Error::ProtoConversion(Box::new(err))
89    }
90}
91
92impl From<tonic::Status> for Error {
93    fn from(status: tonic::Status) -> Self {
94        Error::Grpc(Box::new(status))
95    }
96}
97
98impl From<Error> for tonic::Status {
99    fn from(err: Error) -> Self {
100        match err {
101            Error::ProtoConversion(e) => {
102                tonic::Status::internal(format!("proto conversion error: {e}"))
103            }
104            Error::Server(status) => status.to_tonic_status(),
105            Error::Protocol(err) => tonic::Status::internal(format!("protocol error: {err}")),
106            Error::Signature(err) => {
107                tonic::Status::internal(format!("signature conversion error: {err}"))
108            }
109            Error::EmptyRequest => {
110                tonic::Status::invalid_argument("empty request: at least one item must be provided")
111            }
112            Error::UnexpectedEndOfStream => {
113                tonic::Status::internal("stream ended unexpectedly: has_next was true")
114            }
115            Error::Grpc(status) => *status,
116        }
117    }
118}
119
120/// Protocol-level errors encountered while processing gRPC responses.
121#[derive(Debug, thiserror::Error)]
122#[non_exhaustive]
123pub enum ProtocolError {
124    /// Server returned an unrecognized proto oneof variant.
125    #[error("unknown {0} variant")]
126    UnknownVariant(&'static str),
127
128    /// A required response field was unexpectedly empty.
129    #[error("empty response field: {0}")]
130    EmptyResponseField(&'static str),
131
132    /// Error during checkpoint data stream reassembly.
133    #[error("checkpoint stream error: {0}")]
134    CheckpointStream(#[from] CheckpointStreamError),
135
136    /// A batched read returned a number of results that does not match the
137    /// number of requested items.
138    #[error("expected {expected} results, got {actual}")]
139    UnexpectedResultCount { expected: usize, actual: usize },
140
141    /// `get_objects` answered a position with a different object than the one
142    /// requested there.
143    #[error("requested object {expected} at position {position}, but got {actual}")]
144    UnexpectedObject {
145        position: usize,
146        expected: ObjectId,
147        actual: ObjectId,
148    },
149
150    /// `get_transactions` answered a position with a different transaction than
151    /// the one requested there.
152    #[error("requested transaction {expected} at position {position}, but got {actual}")]
153    UnexpectedTransaction {
154        position: usize,
155        expected: TransactionDigest,
156        actual: TransactionDigest,
157    },
158}
159
160/// Errors during checkpoint data stream reassembly.
161#[derive(Debug, thiserror::Error)]
162#[non_exhaustive]
163pub enum CheckpointStreamError {
164    /// Received a data chunk before the checkpoint header.
165    #[error("received {data_kind} before checkpoint header")]
166    DataBeforeHeader { data_kind: &'static str },
167
168    /// New checkpoint header received while previous was incomplete.
169    #[error("new checkpoint header before previous completed")]
170    IncompleteCheckpoint,
171
172    /// EndMarker sequence number doesn't match current checkpoint.
173    #[error("end marker sequence number {actual} does not match checkpoint {expected}")]
174    SequenceNumberMismatch { expected: u64, actual: u64 },
175
176    /// Unknown checkpoint data payload type.
177    #[error("unknown checkpoint data payload type")]
178    UnknownPayload,
179
180    /// Stream ended with incomplete checkpoint data.
181    #[error("stream ended with incomplete data for checkpoint {sequence_number}")]
182    IncompleteStream { sequence_number: u64 },
183}
184
185/// Result type alias for API operations.
186pub type Result<T> = std::result::Result<T, Error>;
187
188// =============================================================================
189// Field Masks
190// =============================================================================
191
192/// A low-level read mask string.
193///
194/// Most callers should use the scoped per-endpoint mask types in
195/// [`read_mask_fields`](crate::read_mask_fields)
196/// (e.g. [`ObjectReadMask`](crate::read_mask_fields::ObjectReadMask)) which
197/// are passed directly to the client methods. This type is the underlying
198/// string holder, useful when composing masks by hand:
199///
200/// ```
201/// use iota_sdk_grpc_client::ReadMask;
202///
203/// let mask = ReadMask::from("effects,checkpoint");
204/// assert_eq!(mask.as_str(), "effects,checkpoint");
205/// ```
206#[derive(Clone, Debug)]
207pub struct ReadMask<'a>(Cow<'a, str>);
208
209impl<'a> ReadMask<'a> {
210    /// Returns the comma-separated field mask string.
211    pub fn as_str(&self) -> &str {
212        &self.0
213    }
214}
215
216impl<'a> From<&'a str> for ReadMask<'a> {
217    fn from(s: &'a str) -> Self {
218        Self(Cow::Borrowed(s))
219    }
220}
221
222impl From<String> for ReadMask<'_> {
223    fn from(s: String) -> Self {
224        Self(Cow::Owned(s))
225    }
226}
227
228impl From<&[&str]> for ReadMask<'_> {
229    /// Paths are normalized: broader paths subsume their sub-paths.
230    fn from(paths: &[&str]) -> Self {
231        Self(Cow::Owned(field_mask_normalize(&paths.join(","))))
232    }
233}
234
235impl<const N: usize> From<&[&str; N]> for ReadMask<'_> {
236    /// Paths are normalized: broader paths subsume their sub-paths.
237    fn from(paths: &[&str; N]) -> Self {
238        Self::from(paths.as_slice())
239    }
240}
241
242impl From<FieldMask> for ReadMask<'_> {
243    /// Paths are normalized: broader paths subsume their sub-paths.
244    fn from(mask: FieldMask) -> Self {
245        Self(Cow::Owned(field_mask_normalize(&mask.paths.join(","))))
246    }
247}
248
249/// Safely convert a `usize` to `u32`, saturating at `u32::MAX` instead of
250/// silently truncating on 64-bit platforms.
251pub fn saturating_usize_to_u32(value: usize) -> u32 {
252    u32::try_from(value).unwrap_or(u32::MAX)
253}
254
255/// A trait for proto result types that follow the pattern of having
256/// `Some(Result::Value)`, `Some(Result::Error)`, or `None`.
257///
258/// This allows generic handling of gRPC response results that can be either
259/// a success value, a server error, or missing.
260pub trait ProtoResult {
261    /// The success value type.
262    type Value;
263
264    /// Extract the result, converting to our error types.
265    fn into_result(self) -> Result<Self::Value>;
266}
267
268/// Convert a batch of proto results into one result per requested item,
269/// preserving request order.
270///
271/// The batched RPCs report a failure for a single item as a `google.rpc.Status`
272/// in that item's slot, so the outcome for one item is independent of the
273/// others: a caller that needs every item can `collect::<Result<Vec<_>>>()`,
274/// while one that tolerates gaps can inspect each slot.
275pub fn into_item_results<T: ProtoResult>(batch: Vec<T>) -> Vec<Result<T::Value>> {
276    batch.into_iter().map(ProtoResult::into_result).collect()
277}
278
279/// Check that a batched read answered every requested item.
280///
281/// Callers pair results with requests by position, so a count that does not
282/// match the request leaves no way to tell which item each result belongs to.
283pub fn check_result_count<T>(results: &[T], expected: usize) -> Result<()> {
284    if results.len() == expected {
285        Ok(())
286    } else {
287        Err(Error::Protocol(ProtocolError::UnexpectedResultCount {
288            expected,
289            actual: results.len(),
290        }))
291    }
292}
293
294/// Check that each answered object is the one requested in that position.
295///
296/// A matching count only says how many results came back, not that position `i`
297/// holds object `i`, so the pairing callers rely on is checked rather than
298/// trusted.
299pub fn check_object_identity(
300    results: &[Result<ProtoObject>],
301    requested: &[(ObjectId, Option<Version>)],
302) -> Result<()> {
303    for (position, (result, (expected, _))) in results.iter().zip(requested).enumerate() {
304        let Ok(object) = result else { continue };
305        let Some(actual) = answered_object_id(object)? else {
306            continue;
307        };
308        if actual != *expected {
309            return Err(Error::Protocol(ProtocolError::UnexpectedObject {
310                position,
311                expected: *expected,
312                actual,
313            }));
314        }
315    }
316    Ok(())
317}
318
319/// Check that each answered transaction is the one requested in that position.
320///
321/// See [`check_object_identity`] for why the pairing is checked rather than
322/// trusted.
323pub fn check_transaction_identity(
324    results: &[Result<ExecutedTransaction>],
325    requested: &[TransactionDigest],
326) -> Result<()> {
327    for (position, (result, expected)) in results.iter().zip(requested).enumerate() {
328        let Ok(transaction) = result else { continue };
329        let Some(actual) = answered_transaction_digest(transaction)? else {
330            continue;
331        };
332        if actual != *expected {
333            return Err(Error::Protocol(ProtocolError::UnexpectedTransaction {
334                position,
335                expected: *expected,
336                actual,
337            }));
338        }
339    }
340    Ok(())
341}
342
343/// The id of an answered object, taken from its reference or, when the read
344/// mask left that out, from its BCS. `None` when it carries neither, leaving
345/// nothing to compare.
346fn answered_object_id(object: &ProtoObject) -> Result<Option<ObjectId>> {
347    if let Some(id) = object
348        .reference
349        .as_ref()
350        .and_then(|reference| reference.object_id.as_ref())
351    {
352        return Ok(Some(id.try_into()?));
353    }
354    if object.bcs.is_some() {
355        return Ok(Some(object.object()?.id()));
356    }
357    Ok(None)
358}
359
360/// The digest of an answered transaction, taken from the response or, when the
361/// read mask left it out, computed from the transaction's BCS. `None` when it
362/// carries neither, leaving nothing to compare.
363fn answered_transaction_digest(
364    transaction: &ExecutedTransaction,
365) -> Result<Option<TransactionDigest>> {
366    let Some(transaction) = transaction.transaction.as_ref() else {
367        return Ok(None);
368    };
369    if let Some(digest) = transaction.digest.as_ref() {
370        return Ok(Some(digest.try_into()?));
371    }
372    if transaction.bcs.is_some() {
373        return Ok(Some(transaction.transaction()?.digest()));
374    }
375    Ok(None)
376}
377
378impl ProtoResult for ObjectResult {
379    type Value = ProtoObject;
380
381    fn into_result(self) -> Result<Self::Value> {
382        match self.result {
383            Some(object_result::Result::Object(obj)) => Ok(obj),
384            Some(object_result::Result::Error(e)) => Err(Error::Server(e)),
385            None => Err(TryFromProtoError::missing("result").into()),
386            Some(_) => Err(Error::Protocol(ProtocolError::UnknownVariant(
387                "object result",
388            ))),
389        }
390    }
391}
392
393impl ProtoResult for TransactionResult {
394    type Value = ExecutedTransaction;
395
396    fn into_result(self) -> Result<Self::Value> {
397        match self.result {
398            Some(transaction_result::Result::ExecutedTransaction(tx)) => Ok(tx),
399            Some(transaction_result::Result::Error(e)) => Err(Error::Server(e)),
400            None => Err(TryFromProtoError::missing("result").into()),
401            Some(_) => Err(Error::Protocol(ProtocolError::UnknownVariant(
402                "transaction result",
403            ))),
404        }
405    }
406}
407
408impl ProtoResult for ExecuteTransactionResult {
409    type Value = ExecutedTransaction;
410
411    fn into_result(self) -> Result<Self::Value> {
412        match self.result {
413            Some(execute_transaction_result::Result::ExecutedTransaction(tx)) => Ok(tx),
414            Some(execute_transaction_result::Result::Error(e)) => Err(Error::Server(e)),
415            None => Err(TryFromProtoError::missing("result").into()),
416            Some(_) => Err(Error::Protocol(ProtocolError::UnknownVariant(
417                "execute transaction result",
418            ))),
419        }
420    }
421}
422
423impl ProtoResult for SimulateTransactionResult {
424    type Value = SimulatedTransaction;
425
426    fn into_result(self) -> Result<Self::Value> {
427        match self.result {
428            Some(simulate_transaction_result::Result::SimulatedTransaction(tx)) => Ok(tx),
429            Some(simulate_transaction_result::Result::Error(e)) => Err(Error::Server(e)),
430            None => Err(TryFromProtoError::missing("result").into()),
431            Some(_) => Err(Error::Protocol(ProtocolError::UnknownVariant(
432                "simulate transaction result",
433            ))),
434        }
435    }
436}
437
438/// Collect all items from a paginated gRPC stream into a single `Vec`.
439///
440/// This handles the common pattern of iterating over a `tonic::Streaming<T>`,
441/// extracting items from each message via the `extract` closure, and checking
442/// that the stream was not truncated (i.e. `has_next` is `false` on the last
443/// message).
444///
445/// The `extract` closure receives each stream message and must return
446/// `(has_next, items)`.  Because some streams require fallible per-item
447/// conversion (e.g. via [`ProtoResult`]), the closure itself returns
448/// `Result<…>`.
449pub async fn collect_stream<T, I, F>(
450    mut stream: tonic::Streaming<T>,
451    metadata: tonic::metadata::MetadataMap,
452    extract: F,
453) -> Result<MetadataEnvelope<Vec<I>>>
454where
455    F: Fn(T) -> Result<(bool, Vec<I>)>,
456{
457    let mut results = Vec::new();
458    let mut has_next = false;
459
460    while let Some(response) = stream.message().await? {
461        let (next, items) = extract(response)?;
462        has_next = next;
463        results.extend(items);
464    }
465
466    if has_next {
467        return Err(Error::UnexpectedEndOfStream);
468    }
469
470    Ok(MetadataEnvelope::new(results, metadata))
471}
472
473/// A single page of results from a paginated list endpoint.
474///
475/// Returned when awaiting a list query builder directly (single-page mode).
476/// Contains the items from this page plus an optional continuation token.
477#[derive(Clone, Debug)]
478pub struct Page<T> {
479    /// The items returned in this page.
480    pub items: Vec<T>,
481    /// Token to retrieve the next page. `None` when this is the last page.
482    pub next_page_token: Option<::prost::bytes::Bytes>,
483}
484
485/// Generate a paginated query builder for a list endpoint.
486///
487/// The generated struct implements [`IntoFuture`](std::future::IntoFuture) for
488/// single-page retrieval and provides a [`collect`] method for auto-pagination.
489///
490/// # Parameters
491///
492/// - `$query_name` — name of the generated builder struct
493/// - `$service_client_type` — the tonic service client type
494/// - `$item_type` — the item type exposed by the builder
495/// - `$rpc_method` — the RPC method name on the service client
496/// - `$items_field` — the field name on the response containing the items vec
497/// - `map_item` (optional) — a fallible `fn(&ProtoItem) -> Result<$item_type>`
498///   applied to each response element. When omitted, items are passed through
499///   unchanged (so `$item_type` must be the response field's element type).
500///
501/// # Example
502///
503/// ```ignore
504/// define_list_query! {
505///     pub struct ListOwnedObjectsQuery {
506///         service_client: StateServiceClient<InterceptedChannel>,
507///         request: ListOwnedObjectsRequest,
508///         item: Object,
509///         rpc_method: list_owned_objects,
510///         items_field: objects,
511///     }
512/// }
513/// ```
514///
515/// With a per-item conversion:
516///
517/// ```ignore
518/// define_list_query! {
519///     pub struct GetCoinsQuery {
520///         service_client: StateServiceClient<InterceptedChannel>,
521///         request: ListOwnedObjectsRequest,
522///         item: Coin,
523///         rpc_method: list_owned_objects,
524///         items_field: objects,
525///         map_item: object_to_coin, // fn(&Object) -> Result<Coin>
526///     }
527/// }
528/// ```
529macro_rules! define_list_query {
530    // Pass-through variant: `$item_type` is the response element type.
531    (
532        $(#[$meta:meta])*
533        pub struct $query_name:ident {
534            service_client: $service_client_type:ty,
535            request: $request_type:ty,
536            item: $item_type:ty,
537            rpc_method: $rpc_method:ident,
538            items_field: $items_field:ident,
539        }
540    ) => {
541        $crate::api::define_list_query! {
542            @impl
543            $(#[$meta])*
544            pub struct $query_name {
545                service_client: $service_client_type,
546                request: $request_type,
547                item: $item_type,
548                rpc_method: $rpc_method,
549                items_field: $items_field,
550                map_item: |item| $crate::api::Result::Ok(item),
551            }
552        }
553    };
554
555    // Conversion variant: each response element is mapped through `$map_item`,
556    // a fallible `fn(&ProtoItem) -> Result<$item_type>`.
557    (
558        $(#[$meta:meta])*
559        pub struct $query_name:ident {
560            service_client: $service_client_type:ty,
561            request: $request_type:ty,
562            item: $item_type:ty,
563            rpc_method: $rpc_method:ident,
564            items_field: $items_field:ident,
565            map_item: $map_item:expr,
566        }
567    ) => {
568        $crate::api::define_list_query! {
569            @impl
570            $(#[$meta])*
571            pub struct $query_name {
572                service_client: $service_client_type,
573                request: $request_type,
574                item: $item_type,
575                rpc_method: $rpc_method,
576                items_field: $items_field,
577                map_item: |item| $map_item(&item),
578            }
579        }
580    };
581
582    (
583        @impl
584        $(#[$meta:meta])*
585        pub struct $query_name:ident {
586            service_client: $service_client_type:ty,
587            request: $request_type:ty,
588            item: $item_type:ty,
589            rpc_method: $rpc_method:ident,
590            items_field: $items_field:ident,
591            map_item: $map_item:expr,
592        }
593    ) => {
594        $(#[$meta])*
595        pub struct $query_name {
596            service_client: $service_client_type,
597            base_request: $request_type,
598            max_message_size: Option<usize>,
599            page_size: Option<u32>,
600            page_token: Option<::prost::bytes::Bytes>,
601        }
602
603        impl $query_name {
604            pub(crate) fn new(
605                service_client: $service_client_type,
606                base_request: $request_type,
607                max_message_size: Option<usize>,
608                page_size: Option<u32>,
609                page_token: Option<::prost::bytes::Bytes>,
610            ) -> Self {
611                Self {
612                    service_client,
613                    base_request,
614                    max_message_size,
615                    page_size,
616                    page_token,
617                }
618            }
619
620            /// Auto-paginate through all pages, collecting up to `limit` items.
621            ///
622            /// If `limit` is `None`, collects all items across all pages.
623            pub async fn collect(
624                self,
625                limit: impl Into<Option<u32>>,
626            ) -> $crate::api::Result<$crate::api::MetadataEnvelope<Vec<$item_type>>> {
627                let limit = limit.into();
628                let mut all_items: Vec<$item_type> = Vec::new();
629                let mut next_page_token = self.page_token;
630                let mut result_metadata = None;
631                let mut service_client = self.service_client;
632
633                loop {
634                    let mut request = self.base_request.clone();
635
636                    // Cap page_size to the remaining items needed when a
637                    // limit is set, so we don't over-fetch from the server.
638                    let effective_page_size = match (self.page_size, limit) {
639                        (Some(ps), Some(l)) => {
640                            let remaining = (l as usize).saturating_sub(all_items.len());
641                            Some(ps.min(remaining as u32))
642                        }
643                        (Some(ps), None) => Some(ps),
644                        (None, Some(l)) => {
645                            let remaining = (l as usize).saturating_sub(all_items.len());
646                            Some(remaining as u32)
647                        }
648                        (None, None) => None,
649                    };
650                    if let Some(ps) = effective_page_size {
651                        request = request.with_page_size(ps);
652                    }
653                    if let Some(token) = next_page_token.take() {
654                        request = request.with_page_token(token);
655                    }
656                    if let Some(max_size) = self.max_message_size {
657                        request = request.with_max_message_size_bytes(
658                            $crate::api::saturating_usize_to_u32(max_size),
659                        );
660                    }
661
662                    let response = service_client.$rpc_method(request).await?;
663                    let (body, metadata) =
664                        $crate::api::MetadataEnvelope::from(response).into_parts();
665                    if result_metadata.is_none() {
666                        result_metadata = Some(metadata);
667                    }
668
669                    let map_item = $map_item;
670                    for item in body.$items_field {
671                        all_items.push(map_item(item)?);
672                    }
673
674                    match body.next_page_token {
675                        Some(token) => next_page_token = Some(token),
676                        None => break,
677                    }
678
679                    if limit.is_some_and(|l| all_items.len() >= l as usize) {
680                        break;
681                    }
682                }
683
684                Ok($crate::api::MetadataEnvelope::new(
685                    all_items,
686                    result_metadata.unwrap_or_default(),
687                ))
688            }
689        }
690
691        impl ::std::future::IntoFuture for $query_name {
692            type Output = $crate::api::Result<
693                $crate::api::MetadataEnvelope<$crate::api::Page<$item_type>>,
694            >;
695            type IntoFuture = ::std::pin::Pin<
696                Box<dyn ::std::future::Future<Output = Self::Output> + Send>,
697            >;
698
699            fn into_future(self) -> Self::IntoFuture {
700                Box::pin(async move {
701                    let mut service_client = self.service_client;
702                    let mut request = self.base_request;
703
704                    if let Some(ps) = self.page_size {
705                        request = request.with_page_size(ps);
706                    }
707                    if let Some(token) = self.page_token {
708                        request = request.with_page_token(token);
709                    }
710                    if let Some(max_size) = self.max_message_size {
711                        request = request.with_max_message_size_bytes(
712                            $crate::api::saturating_usize_to_u32(max_size),
713                        );
714                    }
715
716                    let response = service_client.$rpc_method(request).await?;
717                    let (body, metadata) =
718                        $crate::api::MetadataEnvelope::from(response).into_parts();
719
720                    let map_item = $map_item;
721                    let items = body
722                        .$items_field
723                        .into_iter()
724                        .map(map_item)
725                        .collect::<$crate::api::Result<Vec<$item_type>>>()?;
726
727                    Ok($crate::api::MetadataEnvelope::new(
728                        $crate::api::Page {
729                            items,
730                            next_page_token: body.next_page_token,
731                        },
732                        metadata,
733                    ))
734                })
735            }
736        }
737    };
738}
739
740pub(crate) use define_list_query;
741
742/// Convert an `ObjectId` to the gRPC proto `ObjectId` type.
743pub fn proto_object_id(id: ObjectId) -> ProtoObjectId {
744    ProtoObjectId::default().with_object_id(Vec::from(id))
745}
746
747/// Build a proto Transaction from serializable transaction data and digest.
748pub fn build_proto_transaction<T: Serialize>(
749    data: &T,
750    digest: TransactionDigest,
751) -> Result<ProtoTransaction> {
752    let bcs = BcsData::serialize(data)
753        .map_err(|e| Error::from(TryFromProtoError::invalid("transaction", e)))?;
754
755    let proto_transaction = ProtoTransaction::default()
756        .with_digest(digest)
757        .with_bcs(bcs);
758
759    Ok(proto_transaction)
760}
761
762#[cfg(test)]
763mod tests {
764    use iota_grpc_types::{
765        google::rpc::Status,
766        v1::{
767            object::Object, types::ObjectReference as ProtoObjectReference,
768            versioned::VersionedObject,
769        },
770    };
771    use iota_types::{
772        Address, MoveStruct, ObjectData, ObjectId, Owner, StructTag, TransactionDigest, Version,
773    };
774
775    use super::{
776        BcsData, Error, ExecutedTransaction, ObjectResult, ProtoTransaction, ProtocolError, Result,
777        check_object_identity, check_transaction_identity, into_item_results, proto_object_id,
778    };
779
780    #[test]
781    fn a_per_item_error_keeps_the_surrounding_items() {
782        let batch = vec![
783            ObjectResult::default().with_object(Object::default()),
784            ObjectResult::default().with_error(Status {
785                code: tonic::Code::NotFound.into(),
786                message: "Object 0x2 not found".to_owned(),
787                details: Vec::new(),
788            }),
789            ObjectResult::default().with_object(Object::default()),
790        ];
791
792        let items = into_item_results(batch);
793
794        assert_eq!(items.len(), 3);
795        assert!(items[0].is_ok());
796        assert!(matches!(items[1], Err(Error::Server(_))));
797        assert!(items[2].is_ok());
798    }
799
800    #[test]
801    fn an_all_error_batch_still_yields_one_item_per_request() {
802        let batch = vec![
803            ObjectResult::default().with_error(Status::default()),
804            ObjectResult::default().with_error(Status::default()),
805        ];
806
807        assert_eq!(into_item_results(batch).len(), 2);
808    }
809
810    fn object_id(byte: u8) -> ObjectId {
811        ObjectId::new([byte; ObjectId::LENGTH])
812    }
813
814    fn transaction_digest(byte: u8) -> TransactionDigest {
815        TransactionDigest::new([byte; TransactionDigest::LENGTH])
816    }
817
818    /// A proto object carrying just its reference, as the default read mask
819    /// requests.
820    fn answered(id: ObjectId) -> Result<Object> {
821        let mut object = Object::default();
822        object.reference =
823            Some(ProtoObjectReference::default().with_object_id(proto_object_id(id)));
824        Ok(object)
825    }
826
827    /// A proto object carrying only BCS, as a `bcs`-only read mask requests.
828    fn answered_with_bcs_only(id: ObjectId) -> Object {
829        let mut contents = Vec::from(id);
830        contents.extend_from_slice(&0u64.to_le_bytes());
831        let move_struct = MoveStruct::new(
832            StructTag::new_gas_coin().into(),
833            Version::from_u64(1),
834            contents,
835        )
836        .expect("contents contain a full object id");
837        let object = iota_types::Object::new(
838            ObjectData::Struct(move_struct),
839            Owner::Address(Address::ZERO),
840            TransactionDigest::ZERO,
841            0,
842        );
843
844        let mut answered = Object::default();
845        answered.bcs =
846            Some(BcsData::serialize(&VersionedObject::V1(object)).expect("object serializes"));
847        answered
848    }
849
850    /// A proto transaction carrying just its digest, as the default read mask
851    /// requests.
852    fn answered_transaction(digest: TransactionDigest) -> ExecutedTransaction {
853        ExecutedTransaction::default()
854            .with_transaction(ProtoTransaction::default().with_digest(digest))
855    }
856
857    #[test]
858    fn objects_answered_in_request_order_are_accepted() {
859        let requested = [(object_id(1), None), (object_id(2), None)];
860        let results = vec![answered(object_id(1)), answered(object_id(2))];
861
862        assert!(check_object_identity(&results, &requested).is_ok());
863    }
864
865    #[test]
866    fn a_substituted_object_is_rejected_with_both_ids() {
867        let requested = [(object_id(1), None), (object_id(2), None)];
868        let results = vec![answered(object_id(1)), answered(object_id(9))];
869
870        let err = check_object_identity(&results, &requested).unwrap_err();
871        let Error::Protocol(ProtocolError::UnexpectedObject {
872            position,
873            expected,
874            actual,
875        }) = err
876        else {
877            panic!("expected an UnexpectedObject error, got {err}");
878        };
879        assert_eq!(position, 1);
880        assert_eq!(expected, object_id(2));
881        assert_eq!(actual, object_id(9));
882    }
883
884    #[test]
885    fn a_position_the_server_errored_on_has_no_id_to_check() {
886        let requested = [(object_id(1), None), (object_id(2), None)];
887        let results = vec![
888            answered(object_id(1)),
889            Err(Error::Server(Status {
890                code: tonic::Code::NotFound.into(),
891                message: String::new(),
892                details: Vec::new(),
893            })),
894        ];
895
896        assert!(check_object_identity(&results, &requested).is_ok());
897    }
898
899    #[test]
900    fn an_object_answered_with_bcs_only_is_checked_against_the_id_in_its_bcs() {
901        let requested = [(object_id(1), None)];
902        let results = vec![Ok(answered_with_bcs_only(object_id(9)))];
903
904        let err = check_object_identity(&results, &requested).unwrap_err();
905        let Error::Protocol(ProtocolError::UnexpectedObject {
906            expected, actual, ..
907        }) = err
908        else {
909            panic!("expected an UnexpectedObject error, got {err}");
910        };
911        assert_eq!(expected, object_id(1));
912        assert_eq!(actual, object_id(9));
913    }
914
915    #[test]
916    fn an_object_carrying_neither_a_reference_nor_bcs_has_nothing_to_check() {
917        let requested = [(object_id(1), None)];
918        let results = vec![Ok(Object::default())];
919
920        assert!(check_object_identity(&results, &requested).is_ok());
921    }
922
923    #[test]
924    fn a_substituted_transaction_is_rejected_with_both_digests() {
925        let requested = [transaction_digest(1), transaction_digest(2)];
926        let results = vec![
927            Ok(answered_transaction(transaction_digest(1))),
928            Ok(answered_transaction(transaction_digest(9))),
929        ];
930
931        let err = check_transaction_identity(&results, &requested).unwrap_err();
932        let Error::Protocol(ProtocolError::UnexpectedTransaction {
933            position,
934            expected,
935            actual,
936        }) = err
937        else {
938            panic!("expected an UnexpectedTransaction error, got {err}");
939        };
940        assert_eq!(position, 1);
941        assert_eq!(expected, transaction_digest(2));
942        assert_eq!(actual, transaction_digest(9));
943    }
944
945    #[test]
946    fn not_found_is_recognized_at_the_call_and_item_level() {
947        let item_level = Error::Server(Status {
948            code: tonic::Code::NotFound.into(),
949            message: String::new(),
950            details: Vec::new(),
951        });
952        let call_level = Error::from(tonic::Status::not_found("gone"));
953        let other = Error::Server(Status {
954            code: tonic::Code::Internal.into(),
955            message: String::new(),
956            details: Vec::new(),
957        });
958
959        assert!(item_level.is_not_found());
960        assert!(call_level.is_not_found());
961        assert!(!other.is_not_found());
962        assert!(!Error::EmptyRequest.is_not_found());
963    }
964}