Skip to main content

canton_ledger/
request.rs

1//! Typed request builders for the client's read paths.
2//!
3//! The plain client methods ([`CantonClient::updates`],
4//! [`CantonClient::completions`]) cover the common case — wildcard filters,
5//! ledger-effects shape, an unbounded stream — with a two-argument call.
6//! These builders open up the rest of the request surface without touching
7//! those signatures: a bounded stream for catch-up reads (`until`), template
8//! and interface filters, the ACS-delta shape, created-event blobs, and the
9//! completion stream's `user_id`.
10//!
11//! Defaults match the plain methods exactly, so
12//! `client.updates_with(UpdatesRequest::new(parties, 0))` and
13//! `client.updates(parties, 0)` issue the same wire request.
14//!
15//! ```no_run
16//! # async fn run(client: canton_ledger::CantonClient, party: String)
17//! #     -> canton_ledger::Result<()> {
18//! use canton_ledger::request::UpdatesRequest;
19//! use tokio_stream::StreamExt as _;
20//!
21//! // A bounded catch-up read: everything between two offsets, one template.
22//! let stream = client
23//!     .updates_with(
24//!         UpdatesRequest::new(vec![party], 0)
25//!             .until(41_000)
26//!             .for_templates(["#my-app:My.Module:Asset"])?,
27//!     )
28//!     .await?;
29//! tokio::pin!(stream);
30//! while let Some(update) = stream.next().await {
31//!     println!("{:?}", update?);
32//! }
33//! # Ok(()) }
34//! ```
35//!
36//! [`CantonClient::updates`]: crate::CantonClient::updates
37//! [`CantonClient::completions`]: crate::CantonClient::completions
38
39use canton_core::{Error, Result};
40use canton_proto::com::daml::ledger::api::v2 as pb;
41
42/// The shape of transactions in an update stream — which events are returned
43/// and who has to see them (see the Ledger API's `TransactionShape`).
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum TransactionShape {
47    /// Created and archived events describing the net change to the active
48    /// contract set; a requesting party must be a **stakeholder** of an event.
49    AcsDelta,
50    /// Create and (non-)consuming exercise events as executed; a requesting
51    /// party must be a **witness** of an event. The default, matching
52    /// [`CantonClient::updates`](crate::CantonClient::updates).
53    #[default]
54    LedgerEffects,
55}
56
57impl TransactionShape {
58    pub(crate) fn as_grpc(self) -> pb::TransactionShape {
59        match self {
60            Self::AcsDelta => pb::TransactionShape::AcsDelta,
61            Self::LedgerEffects => pb::TransactionShape::LedgerEffects,
62        }
63    }
64}
65
66/// A builder for [`CantonClient::updates_with`] /
67/// [`CantonClient::updates_resumable_with`]: the update stream with the full
68/// request surface exposed.
69///
70/// [`CantonClient::updates_with`]: crate::CantonClient::updates_with
71/// [`CantonClient::updates_resumable_with`]: crate::CantonClient::updates_resumable_with
72#[derive(Clone, Debug)]
73#[must_use = "a request does nothing until passed to a client method"]
74// The bools mirror four genuinely independent wire flags of `UpdateFormat`;
75// folding them into state enums would obscure the 1:1 proto mapping.
76#[allow(clippy::struct_excessive_bools)]
77pub struct UpdatesRequest {
78    pub(crate) parties: Vec<String>,
79    pub(crate) begin_exclusive: i64,
80    end_inclusive: Option<i64>,
81    shape: TransactionShape,
82    /// Parsed template filters (empty + empty interfaces ⇒ wildcard).
83    templates: Vec<pb::Identifier>,
84    /// Parsed interface filters.
85    interfaces: Vec<pb::Identifier>,
86    include_created_event_blobs: bool,
87    include_reassignments: bool,
88    include_topology_events: bool,
89    verbose: bool,
90    any_party: bool,
91    descending: bool,
92}
93
94impl UpdatesRequest {
95    /// Refuse a request the participant is certain to refuse.
96    ///
97    /// An offset is a position on the ledger, so a negative one is not a range
98    /// the participant can answer — it comes back as `INVALID_ARGUMENT` after a
99    /// round trip, reading like a server-side problem. So does an inverted
100    /// range, and a request naming no parties returns nothing at all while
101    /// looking like a working subscription.
102    pub(crate) fn validate(&self) -> crate::Result<()> {
103        use canton_core::Error;
104        if self.parties.is_empty() && !self.any_party {
105            return Err(Error::InvalidRequest(
106                "a read needs at least one party (or filters_for_any_party)".to_string(),
107            ));
108        }
109        if self.begin_exclusive < 0 {
110            return Err(Error::InvalidRequest(format!(
111                "begin offset must not be negative, got {}",
112                self.begin_exclusive
113            )));
114        }
115        if let Some(end) = self.end_inclusive {
116            if end < 0 {
117                return Err(Error::InvalidRequest(format!(
118                    "end offset must not be negative, got {end}"
119                )));
120            }
121            if !self.descending && end < self.begin_exclusive {
122                return Err(Error::InvalidRequest(format!(
123                    "end offset {end} is before the begin offset {}",
124                    self.begin_exclusive
125                )));
126            }
127        }
128        Ok(())
129    }
130
131    /// Updates visible to `parties`, starting after `begin_exclusive`. The
132    /// defaults beyond that are the plain [`updates`] ones: unbounded,
133    /// ledger-effects shape, all templates, reassignments included, verbose
134    /// (labelled) records.
135    ///
136    /// [`updates`]: crate::CantonClient::updates
137    pub fn new(parties: Vec<String>, begin_exclusive: i64) -> Self {
138        Self {
139            parties,
140            begin_exclusive,
141            end_inclusive: None,
142            shape: TransactionShape::LedgerEffects,
143            templates: Vec::new(),
144            interfaces: Vec::new(),
145            include_created_event_blobs: false,
146            include_reassignments: true,
147            include_topology_events: false,
148            verbose: true,
149            any_party: false,
150            descending: false,
151        }
152    }
153
154    /// Bound the stream: only updates at offsets `<= end_inclusive`, after
155    /// which the stream ends. This is the catch-up/sync-tool form — without
156    /// it the stream is live and never terminates.
157    pub fn until(mut self, end_inclusive: i64) -> Self {
158        self.end_inclusive = Some(end_inclusive);
159        self
160    }
161
162    /// Return updates in descending offset order (newest first). Requires a
163    /// bounded request ([`Self::until`]); rejected on the resumable stream,
164    /// whose position tracking assumes ascending order.
165    pub fn descending(mut self) -> Self {
166        self.descending = true;
167        self
168    }
169
170    /// Additionally apply this request's filter set for **any** party the
171    /// token can read for (`filters_for_any_party`) — the full-ledger
172    /// ingestion form. Requires wildcard read authorization on the
173    /// participant; the explicit `parties` list (which may be empty) is still
174    /// applied on top.
175    pub fn for_any_party(mut self) -> Self {
176        self.any_party = true;
177        self
178    }
179
180    /// Select the transaction shape (default: ledger effects).
181    pub fn with_shape(mut self, shape: TransactionShape) -> Self {
182        self.shape = shape;
183        self
184    }
185
186    /// Only events of these templates. Each id is `package:Module:Entity`,
187    /// where `package` is a package id or a `#package-name` reference (the
188    /// SCU-friendly form). May be combined with [`Self::for_interfaces`];
189    /// each call adds to the filter.
190    ///
191    /// # Errors
192    /// Returns [`Error::InvalidRequest`] on a malformed id.
193    pub fn for_templates<I, S>(mut self, template_ids: I) -> Result<Self>
194    where
195        I: IntoIterator<Item = S>,
196        S: AsRef<str>,
197    {
198        for id in template_ids {
199            self.templates.push(parse_identifier(id.as_ref())?);
200        }
201        Ok(self)
202    }
203
204    /// Only events of contracts implementing these interfaces (the returned
205    /// created events carry the interface views). Same id format and
206    /// accumulation as [`Self::for_templates`].
207    ///
208    /// # Errors
209    /// Returns [`Error::InvalidRequest`] on a malformed id.
210    pub fn for_interfaces<I, S>(mut self, interface_ids: I) -> Result<Self>
211    where
212        I: IntoIterator<Item = S>,
213        S: AsRef<str>,
214    {
215        for id in interface_ids {
216            self.interfaces.push(parse_identifier(id.as_ref())?);
217        }
218        Ok(self)
219    }
220
221    /// Include each created event's `created_event_blob`, for use as a
222    /// disclosed contract in later commands.
223    pub fn with_created_event_blobs(mut self) -> Self {
224        self.include_created_event_blobs = true;
225        self
226    }
227
228    /// Exclude reassignment events (included by default).
229    pub fn without_reassignments(mut self) -> Self {
230        self.include_reassignments = false;
231        self
232    }
233
234    /// Include participant-authorization topology events for the requested
235    /// parties (excluded by default).
236    pub fn with_topology_events(mut self) -> Self {
237        self.include_topology_events = true;
238        self
239    }
240
241    /// Omit record labels from the returned values (verbose is the default).
242    /// Non-verbose payloads are smaller; typed decoding handles both.
243    pub fn non_verbose(mut self) -> Self {
244        self.verbose = false;
245        self
246    }
247
248    /// Restart marker for the resumable stream: same request, new begin.
249    pub(crate) fn resume_after(&self, offset: i64) -> Self {
250        let mut request = self.clone();
251        request.begin_exclusive = offset;
252        request
253    }
254
255    /// The bounds, for the paged read.
256    pub(crate) fn bounds(&self) -> (i64, Option<i64>) {
257        (self.begin_exclusive, self.end_inclusive)
258    }
259
260    /// Whether descending order was requested.
261    pub(crate) fn is_descending(&self) -> bool {
262        self.descending
263    }
264
265    /// The wire `UpdateFormat` (shared by the streaming and paged reads).
266    pub(crate) fn update_format(&self) -> pb::UpdateFormat {
267        let filters = build_filters(
268            &self.templates,
269            &self.interfaces,
270            self.include_created_event_blobs,
271        );
272        let event_format = |verbose: bool| pb::EventFormat {
273            filters_by_party: self
274                .parties
275                .iter()
276                .map(|party| (party.clone(), filters.clone()))
277                .collect(),
278            filters_for_any_party: self.any_party.then(|| filters.clone()),
279            verbose,
280        };
281        pb::UpdateFormat {
282            include_transactions: Some(pb::TransactionFormat {
283                event_format: Some(event_format(self.verbose)),
284                transaction_shape: self.shape.as_grpc() as i32,
285            }),
286            // Reassignment events are always ACS-delta shaped and never
287            // verbose-labelled, but share the party filters.
288            include_reassignments: self
289                .include_reassignments
290                .then(|| event_format(self.verbose)),
291            include_topology_events: self.include_topology_events.then(|| pb::TopologyFormat {
292                include_participant_authorization_events: Some(
293                    pb::ParticipantAuthorizationTopologyFormat {
294                        parties: self.parties.clone(),
295                    },
296                ),
297            }),
298        }
299    }
300
301    /// The wire request.
302    pub(crate) fn into_grpc(self) -> pb::GetUpdatesRequest {
303        pb::GetUpdatesRequest {
304            begin_exclusive: self.begin_exclusive,
305            end_inclusive: self.end_inclusive,
306            descending_order: self.descending,
307            update_format: Some(self.update_format()),
308        }
309    }
310
311    /// The JSON API request body (`POST /v2/updates` and the WS lane) — the
312    /// same query as [`Self::update_format`], in the JSON transport's shape.
313    pub(crate) fn json_body(&self) -> serde_json::Value {
314        let event_format = || {
315            event_format_json(
316                &self.parties,
317                &self.templates,
318                &self.interfaces,
319                self.include_created_event_blobs,
320                self.verbose,
321                self.any_party,
322            )
323        };
324        let shape = match self.shape {
325            TransactionShape::AcsDelta => "TRANSACTION_SHAPE_ACS_DELTA",
326            TransactionShape::LedgerEffects => "TRANSACTION_SHAPE_LEDGER_EFFECTS",
327        };
328        let mut update_format = serde_json::json!({
329            "includeTransactions": {
330                "eventFormat": event_format(),
331                "transactionShape": shape,
332            }
333        });
334        if self.include_reassignments {
335            update_format["includeReassignments"] = event_format();
336        }
337        if self.include_topology_events {
338            update_format["includeTopologyEvents"] = serde_json::json!({
339                "includeParticipantAuthorizationEvents": { "parties": self.parties }
340            });
341        }
342        let mut body = serde_json::json!({
343            "beginExclusive": self.begin_exclusive,
344            "updateFormat": update_format,
345        });
346        if let Some(end) = self.end_inclusive {
347            body["endInclusive"] = serde_json::json!(end);
348        }
349        if self.descending {
350            body["descendingOrder"] = serde_json::json!(true);
351        }
352        body
353    }
354}
355
356/// A builder for the Active Contract Set reads
357/// ([`CantonClient::active_contracts_with`],
358/// [`CantonClient::active_contracts_page_with`],
359/// [`CantonClient::active_contracts_resumable_with`]): the snapshot with the
360/// full request surface exposed.
361///
362/// [`CantonClient::active_contracts_with`]: crate::CantonClient::active_contracts_with
363/// [`CantonClient::active_contracts_page_with`]: crate::CantonClient::active_contracts_page_with
364/// [`CantonClient::active_contracts_resumable_with`]: crate::CantonClient::active_contracts_resumable_with
365#[derive(Clone, Debug)]
366#[must_use = "a request does nothing until passed to a client method"]
367pub struct ActiveContractsRequest {
368    pub(crate) parties: Vec<String>,
369    pub(crate) active_at_offset: i64,
370    templates: Vec<pb::Identifier>,
371    interfaces: Vec<pb::Identifier>,
372    include_created_event_blobs: bool,
373    verbose: bool,
374    any_party: bool,
375}
376
377impl ActiveContractsRequest {
378    /// Refuse a snapshot request the participant is certain to refuse: an
379    /// offset it cannot have reached, or a filter naming nobody.
380    pub(crate) fn validate(&self) -> crate::Result<()> {
381        use canton_core::Error;
382        if self.parties.is_empty() && !self.any_party {
383            return Err(Error::InvalidRequest(
384                "an ACS read needs at least one party (or filters_for_any_party)".to_string(),
385            ));
386        }
387        if self.active_at_offset < 0 {
388            return Err(Error::InvalidRequest(format!(
389                "active_at_offset must not be negative, got {}",
390                self.active_at_offset
391            )));
392        }
393        Ok(())
394    }
395
396    /// The ACS visible to `parties` as of `active_at_offset` (typically the
397    /// current ledger end). The defaults beyond that are the plain
398    /// [`active_contracts`] ones: all templates, verbose (labelled) records.
399    ///
400    /// [`active_contracts`]: crate::CantonClient::active_contracts
401    pub fn new(parties: Vec<String>, active_at_offset: i64) -> Self {
402        Self {
403            parties,
404            active_at_offset,
405            templates: Vec::new(),
406            interfaces: Vec::new(),
407            include_created_event_blobs: false,
408            verbose: true,
409            any_party: false,
410        }
411    }
412
413    /// Additionally apply this request's filter set for **any** party the
414    /// token can read for (`filters_for_any_party`) — the full-ledger
415    /// ingestion form. Requires wildcard read authorization on the
416    /// participant; the explicit `parties` list (which may be empty) is still
417    /// applied on top.
418    pub fn for_any_party(mut self) -> Self {
419        self.any_party = true;
420        self
421    }
422
423    /// Only contracts of these templates. Same id format and accumulation as
424    /// [`UpdatesRequest::for_templates`].
425    ///
426    /// # Errors
427    /// Returns [`Error::InvalidRequest`] on a malformed id.
428    pub fn for_templates<I, S>(mut self, template_ids: I) -> Result<Self>
429    where
430        I: IntoIterator<Item = S>,
431        S: AsRef<str>,
432    {
433        for id in template_ids {
434            self.templates.push(parse_identifier(id.as_ref())?);
435        }
436        Ok(self)
437    }
438
439    /// Only contracts implementing these interfaces (the returned created
440    /// events carry the interface views). Same id format and accumulation as
441    /// [`UpdatesRequest::for_interfaces`].
442    ///
443    /// # Errors
444    /// Returns [`Error::InvalidRequest`] on a malformed id.
445    pub fn for_interfaces<I, S>(mut self, interface_ids: I) -> Result<Self>
446    where
447        I: IntoIterator<Item = S>,
448        S: AsRef<str>,
449    {
450        for id in interface_ids {
451            self.interfaces.push(parse_identifier(id.as_ref())?);
452        }
453        Ok(self)
454    }
455
456    /// Include each contract's `created_event_blob`, for use as a disclosed
457    /// contract in later commands.
458    pub fn with_created_event_blobs(mut self) -> Self {
459        self.include_created_event_blobs = true;
460        self
461    }
462
463    /// Omit record labels from the returned values (verbose is the default).
464    pub fn non_verbose(mut self) -> Self {
465        self.verbose = false;
466        self
467    }
468
469    /// The wire `EventFormat` (shared by the streaming and paged reads).
470    pub(crate) fn event_format(&self) -> pb::EventFormat {
471        let filters = build_filters(
472            &self.templates,
473            &self.interfaces,
474            self.include_created_event_blobs,
475        );
476        pb::EventFormat {
477            filters_by_party: self
478                .parties
479                .iter()
480                .map(|party| (party.clone(), filters.clone()))
481                .collect(),
482            filters_for_any_party: self.any_party.then(|| filters.clone()),
483            verbose: self.verbose,
484        }
485    }
486
487    /// The JSON API request body (`POST /v2/state/active-contracts` and the
488    /// WS lane) — the same query as [`Self::event_format`], in the JSON
489    /// transport's shape.
490    pub(crate) fn json_body(&self) -> serde_json::Value {
491        serde_json::json!({
492            "activeAtOffset": self.active_at_offset,
493            "eventFormat": event_format_json(
494                &self.parties,
495                &self.templates,
496                &self.interfaces,
497                self.include_created_event_blobs,
498                self.verbose,
499                self.any_party,
500            ),
501        })
502    }
503}
504
505/// A builder for [`CantonClient::completions_with`]: the command-completion
506/// stream with the full request surface exposed.
507///
508/// [`CantonClient::completions_with`]: crate::CantonClient::completions_with
509#[derive(Clone, Debug)]
510#[must_use = "a request does nothing until passed to a client method"]
511pub struct CompletionsRequest {
512    pub(crate) parties: Vec<String>,
513    pub(crate) begin_exclusive: i64,
514    user_id: Option<String>,
515}
516
517impl CompletionsRequest {
518    /// Refuse a completion subscription the participant is certain to refuse.
519    pub(crate) fn validate(&self) -> crate::Result<()> {
520        use canton_core::Error;
521        if self.parties.is_empty() {
522            return Err(Error::InvalidRequest(
523                "a completion subscription needs at least one party".to_string(),
524            ));
525        }
526        if self.begin_exclusive < 0 {
527            return Err(Error::InvalidRequest(format!(
528                "begin offset must not be negative, got {}",
529                self.begin_exclusive
530            )));
531        }
532        Ok(())
533    }
534
535    /// Completions of commands acted on by `parties`, starting after
536    /// `begin_exclusive`.
537    pub fn new(parties: Vec<String>, begin_exclusive: i64) -> Self {
538        Self {
539            parties,
540            begin_exclusive,
541            user_id: None,
542        }
543    }
544
545    /// Only completions of commands submitted with this `user_id` (pair it
546    /// with [`Submit::with_user_id`]). Without it, the participant uses the
547    /// user id carried by the access token — which fails for tokens that do
548    /// not carry one (custom-claims or admin tokens).
549    ///
550    /// [`Submit::with_user_id`]: crate::Submit::with_user_id
551    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
552        self.user_id = Some(user_id.into());
553        self
554    }
555
556    /// The wire request.
557    pub(crate) fn into_grpc(self) -> pb::CompletionStreamRequest {
558        pb::CompletionStreamRequest {
559            user_id: self.user_id.unwrap_or_default(),
560            parties: self.parties,
561            begin_exclusive: self.begin_exclusive,
562        }
563    }
564
565    /// The JSON API request body. Gated on `ws` because the JSON Ledger API
566    /// has no POST endpoint for completions — the WS lane is its only reader,
567    /// so without the feature this body has nobody to build it for.
568    #[cfg(feature = "ws")]
569    pub(crate) fn json_body(&self) -> serde_json::Value {
570        let mut body = serde_json::json!({
571            "parties": self.parties,
572            "beginExclusive": self.begin_exclusive,
573        });
574        if let Some(user_id) = &self.user_id {
575            body["userId"] = serde_json::json!(user_id);
576        }
577        body
578    }
579}
580
581/// The per-party `Filters`: wildcard when no template or interface filter was
582/// added, else the union of the requested filters (per the API, each entry
583/// widens the match).
584fn build_filters(
585    templates: &[pb::Identifier],
586    interfaces: &[pb::Identifier],
587    include_created_event_blobs: bool,
588) -> pb::Filters {
589    use pb::cumulative_filter::IdentifierFilter;
590
591    if templates.is_empty() && interfaces.is_empty() {
592        return pb::Filters {
593            cumulative: vec![pb::CumulativeFilter {
594                identifier_filter: Some(IdentifierFilter::WildcardFilter(pb::WildcardFilter {
595                    include_created_event_blob: include_created_event_blobs,
596                })),
597            }],
598        };
599    }
600
601    let template_filters = templates.iter().map(|id| {
602        IdentifierFilter::TemplateFilter(pb::TemplateFilter {
603            template_id: Some(id.clone()),
604            include_created_event_blob: include_created_event_blobs,
605        })
606    });
607    let interface_filters = interfaces.iter().map(|id| {
608        IdentifierFilter::InterfaceFilter(pb::InterfaceFilter {
609            interface_id: Some(id.clone()),
610            include_interface_view: true,
611            include_created_event_blob: include_created_event_blobs,
612        })
613    });
614    pb::Filters {
615        cumulative: template_filters
616            .chain(interface_filters)
617            .map(|filter| pb::CumulativeFilter {
618                identifier_filter: Some(filter),
619            })
620            .collect(),
621    }
622}
623
624/// The JSON-transport `EventFormat` for the same filter set as
625/// [`build_filters`] — wildcard when no filter was added, else the union of
626/// template and interface filters. The JSON API spells identifiers as
627/// `package:Module:Entity` strings and wraps each oneof case in
628/// `{"Case": {"value": …}}`.
629fn event_format_json(
630    parties: &[String],
631    templates: &[pb::Identifier],
632    interfaces: &[pb::Identifier],
633    include_created_event_blobs: bool,
634    verbose: bool,
635    any_party: bool,
636) -> serde_json::Value {
637    use serde_json::json;
638
639    let cumulative: Vec<serde_json::Value> = if templates.is_empty() && interfaces.is_empty() {
640        vec![json!({
641            "identifierFilter": {
642                "WildcardFilter": {
643                    "value": { "includeCreatedEventBlob": include_created_event_blobs }
644                }
645            }
646        })]
647    } else {
648        let identifier = |id: &pb::Identifier| {
649            format!("{}:{}:{}", id.package_id, id.module_name, id.entity_name)
650        };
651        templates
652            .iter()
653            .map(|id| {
654                json!({
655                    "identifierFilter": {
656                        "TemplateFilter": {
657                            "value": {
658                                "templateId": identifier(id),
659                                "includeCreatedEventBlob": include_created_event_blobs,
660                            }
661                        }
662                    }
663                })
664            })
665            .chain(interfaces.iter().map(|id| {
666                json!({
667                    "identifierFilter": {
668                        "InterfaceFilter": {
669                            "value": {
670                                "interfaceId": identifier(id),
671                                "includeInterfaceView": true,
672                                "includeCreatedEventBlob": include_created_event_blobs,
673                            }
674                        }
675                    }
676                })
677            }))
678            .collect()
679    };
680    let filters_by_party: serde_json::Map<String, serde_json::Value> = parties
681        .iter()
682        .map(|party| (party.clone(), json!({ "cumulative": cumulative })))
683        .collect();
684    let mut format = json!({ "filtersByParty": filters_by_party, "verbose": verbose });
685    if any_party {
686        format["filtersForAnyParty"] = json!({ "cumulative": cumulative });
687    }
688    format
689}
690
691/// Parse `package:Module:Entity` into an [`pb::Identifier`]. The package part
692/// is a package id or a `#package-name` reference; module and entity are
693/// dotted names (never containing `:`).
694fn parse_identifier(id: &str) -> Result<pb::Identifier> {
695    let mut parts = id.splitn(3, ':');
696    match (parts.next(), parts.next(), parts.next()) {
697        (Some(package), Some(module), Some(entity))
698            if !package.is_empty() && !module.is_empty() && !entity.is_empty() =>
699        {
700            Ok(pb::Identifier {
701                package_id: package.to_string(),
702                module_name: module.to_string(),
703                entity_name: entity.to_string(),
704            })
705        }
706        _ => Err(Error::InvalidRequest(format!(
707            "malformed identifier `{id}`: expected `package:Module:Entity` \
708             (package id, or `#package-name`)"
709        ))),
710    }
711}
712
713#[cfg(test)]
714#[allow(clippy::unwrap_used)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn the_default_request_matches_the_plain_updates_call() {
720        // `updates_with(UpdatesRequest::new(..))` must issue byte-identical
721        // wire requests to `updates(..)` — the builder only *adds* surface.
722        let request = UpdatesRequest::new(vec!["alice".to_string()], 7).into_grpc();
723
724        assert_eq!(request.begin_exclusive, 7);
725        assert_eq!(request.end_inclusive, None);
726        assert!(!request.descending_order);
727        let format = request.update_format.unwrap();
728        let transactions = format.include_transactions.unwrap();
729        assert_eq!(
730            transactions.transaction_shape,
731            pb::TransactionShape::LedgerEffects as i32
732        );
733        let events = transactions.event_format.unwrap();
734        assert!(events.verbose);
735        assert!(events.filters_for_any_party.is_none());
736        let filters = &events.filters_by_party["alice"];
737        assert_eq!(filters.cumulative.len(), 1);
738        assert!(matches!(
739            filters.cumulative[0].identifier_filter,
740            Some(pb::cumulative_filter::IdentifierFilter::WildcardFilter(
741                pb::WildcardFilter {
742                    include_created_event_blob: false
743                }
744            ))
745        ));
746        assert!(format.include_reassignments.is_some());
747        assert!(format.include_topology_events.is_none());
748    }
749
750    #[test]
751    fn every_builder_knob_reaches_the_wire_request() {
752        let request = UpdatesRequest::new(vec!["alice".to_string()], 0)
753            .until(41)
754            .with_shape(TransactionShape::AcsDelta)
755            .for_templates(["#my-app:My.Mod:Asset"])
756            .unwrap()
757            .for_interfaces(["#my-app:My.Api:IAsset"])
758            .unwrap()
759            .with_created_event_blobs()
760            .without_reassignments()
761            .with_topology_events()
762            .non_verbose()
763            .into_grpc();
764
765        assert_eq!(request.end_inclusive, Some(41));
766        let format = request.update_format.unwrap();
767        assert!(format.include_reassignments.is_none());
768        assert_eq!(
769            format
770                .include_topology_events
771                .unwrap()
772                .include_participant_authorization_events
773                .unwrap()
774                .parties,
775            vec!["alice".to_string()]
776        );
777        let transactions = format.include_transactions.unwrap();
778        assert_eq!(
779            transactions.transaction_shape,
780            pb::TransactionShape::AcsDelta as i32
781        );
782        let events = transactions.event_format.unwrap();
783        assert!(!events.verbose);
784        let filters = &events.filters_by_party["alice"].cumulative;
785        assert_eq!(filters.len(), 2, "one template + one interface filter");
786        let Some(pb::cumulative_filter::IdentifierFilter::TemplateFilter(template)) =
787            &filters[0].identifier_filter
788        else {
789            panic!("expected a template filter first");
790        };
791        assert_eq!(template.template_id.as_ref().unwrap().package_id, "#my-app");
792        assert!(template.include_created_event_blob);
793        let Some(pb::cumulative_filter::IdentifierFilter::InterfaceFilter(interface)) =
794            &filters[1].identifier_filter
795        else {
796            panic!("expected an interface filter second");
797        };
798        assert_eq!(
799            interface.interface_id.as_ref().unwrap().entity_name,
800            "IAsset"
801        );
802        assert!(interface.include_interface_view);
803    }
804
805    #[test]
806    fn identifiers_parse_and_malformed_ones_are_refused() {
807        let id = parse_identifier("#pkg-name:Some.Dotted.Module:Entity").unwrap();
808        assert_eq!(id.package_id, "#pkg-name");
809        assert_eq!(id.module_name, "Some.Dotted.Module");
810        assert_eq!(id.entity_name, "Entity");
811
812        for bad in ["", "nope", "a:b", ":Mod:Ent", "pkg::Ent", "pkg:Mod:"] {
813            assert!(parse_identifier(bad).is_err(), "`{bad}` should be refused");
814        }
815    }
816
817    #[test]
818    fn acs_request_knobs_reach_the_wire_and_json_bodies() {
819        let request = ActiveContractsRequest::new(vec!["alice".to_string()], 42)
820            .for_templates(["#app:Mod:Asset"])
821            .unwrap()
822            .for_interfaces(["#app:Api:IAsset"])
823            .unwrap()
824            .with_created_event_blobs()
825            .non_verbose();
826
827        // gRPC wire shape.
828        let format = request.event_format();
829        assert!(!format.verbose);
830        let filters = &format.filters_by_party["alice"].cumulative;
831        assert_eq!(filters.len(), 2);
832        assert!(matches!(
833            &filters[0].identifier_filter,
834            Some(pb::cumulative_filter::IdentifierFilter::TemplateFilter(t))
835                if t.include_created_event_blob
836        ));
837
838        // JSON body: same query, transport-shaped.
839        let body = request.json_body();
840        assert_eq!(body["activeAtOffset"], 42);
841        let cumulative = &body["eventFormat"]["filtersByParty"]["alice"]["cumulative"];
842        assert_eq!(
843            cumulative[0]["identifierFilter"]["TemplateFilter"]["value"]["templateId"],
844            "#app:Mod:Asset"
845        );
846        assert_eq!(
847            cumulative[1]["identifierFilter"]["InterfaceFilter"]["value"]["interfaceId"],
848            "#app:Api:IAsset"
849        );
850        assert_eq!(body["eventFormat"]["verbose"], false);
851
852        // The default is a wildcard, verbose.
853        let plain = ActiveContractsRequest::new(vec!["alice".to_string()], 42).json_body();
854        assert!(plain["eventFormat"]["filtersByParty"]["alice"]["cumulative"][0]
855            ["identifierFilter"]["WildcardFilter"]
856            .is_object());
857        assert_eq!(plain["eventFormat"]["verbose"], true);
858    }
859
860    #[test]
861    fn updates_json_body_mirrors_the_grpc_query() {
862        let body = UpdatesRequest::new(vec!["alice".to_string()], 5)
863            .until(9)
864            .with_shape(TransactionShape::AcsDelta)
865            .for_templates(["#app:Mod:Asset"])
866            .unwrap()
867            .without_reassignments()
868            .with_topology_events()
869            .json_body();
870
871        assert_eq!(body["beginExclusive"], 5);
872        assert_eq!(body["endInclusive"], 9);
873        let format = &body["updateFormat"];
874        assert_eq!(
875            format["includeTransactions"]["transactionShape"],
876            "TRANSACTION_SHAPE_ACS_DELTA"
877        );
878        assert!(format.get("includeReassignments").is_none());
879        assert_eq!(
880            format["includeTopologyEvents"]["includeParticipantAuthorizationEvents"]["parties"][0],
881            "alice"
882        );
883        assert_eq!(
884            format["includeTransactions"]["eventFormat"]["filtersByParty"]["alice"]["cumulative"]
885                [0]["identifierFilter"]["TemplateFilter"]["value"]["templateId"],
886            "#app:Mod:Asset"
887        );
888    }
889
890    #[test]
891    fn descending_and_any_party_reach_both_wire_shapes() {
892        let request = UpdatesRequest::new(vec!["alice".to_string()], 5)
893            .until(9)
894            .descending()
895            .for_any_party();
896
897        let body = request.clone().json_body();
898        assert_eq!(body["descendingOrder"], true);
899        let format = &body["updateFormat"]["includeTransactions"]["eventFormat"];
900        assert!(format["filtersForAnyParty"]["cumulative"].is_array());
901        assert!(format["filtersByParty"]["alice"].is_object());
902
903        let grpc = request.into_grpc();
904        assert!(grpc.descending_order);
905        let Some(format) = grpc
906            .update_format
907            .and_then(|f| f.include_transactions)
908            .and_then(|t| t.event_format)
909        else {
910            panic!("expected an event format");
911        };
912        assert!(format.filters_for_any_party.is_some());
913        assert!(format.filters_by_party.contains_key("alice"));
914
915        // Plain requests keep both off.
916        let plain = UpdatesRequest::new(vec!["alice".to_string()], 5).json_body();
917        assert!(plain.get("descendingOrder").is_none());
918        assert!(
919            plain["updateFormat"]["includeTransactions"]["eventFormat"]
920                .get("filtersForAnyParty")
921                .is_none()
922        );
923    }
924
925    #[test]
926    fn acs_any_party_reaches_both_wire_shapes() {
927        let request = ActiveContractsRequest::new(vec![], 7).for_any_party();
928
929        let body = request.json_body();
930        assert!(body["eventFormat"]["filtersForAnyParty"]["cumulative"].is_array());
931
932        let format = request.event_format();
933        assert!(format.filters_for_any_party.is_some());
934        assert!(format.filters_by_party.is_empty());
935    }
936
937    #[test]
938    #[cfg(feature = "ws")] // the body exists only for the WS completions lane
939    fn completions_json_body_carries_the_user_id_only_when_set() {
940        let plain = CompletionsRequest::new(vec!["p".to_string()], 3).json_body();
941        assert!(plain.get("userId").is_none());
942        assert_eq!(plain["beginExclusive"], 3);
943
944        let scoped = CompletionsRequest::new(vec!["p".to_string()], 3)
945            .with_user_id("sync-tool")
946            .json_body();
947        assert_eq!(scoped["userId"], "sync-tool");
948    }
949
950    #[test]
951    fn completions_request_carries_the_user_id() {
952        let plain = CompletionsRequest::new(vec!["p".to_string()], 3).into_grpc();
953        assert_eq!(plain.user_id, "");
954        assert_eq!(plain.begin_exclusive, 3);
955
956        let scoped = CompletionsRequest::new(vec!["p".to_string()], 3)
957            .with_user_id("sync-tool")
958            .into_grpc();
959        assert_eq!(scoped.user_id, "sync-tool");
960    }
961}