Skip to main content

cratestack_cose/
call.rs

1//! [`CallBinding`]: the owned, FFI-shaped inputs of one call's [`Binding`].
2//!
3//! The Flutter and wasm glues (cratestack#1026) receive a call's binding
4//! across a bridge, where borrowed `Cow`s and `PathParams` cannot cross.
5//! This type is what they map into, so neither re-implements the canonical
6//! query or the AAD inputs: both go through [`CallBinding::request`] and
7//! [`CallBinding::response`], which build the same [`Binding`] the Rust
8//! client builds (`cratestack-client-rust`'s `request_sealed`).
9//!
10//! ```
11//! use cratestack_cose::CallBinding;
12//!
13//! let call = CallBinding {
14//!     audience: "payments".into(),
15//!     method: "GET".into(),
16//!     route: "/accounts/{id}".into(),
17//!     path_params: vec!["acc_42".into()],
18//!     // Spelled out of order: the binding carries the canonical form.
19//!     query: Some("b=2&a=1".into()),
20//!     contract_sha: [7; 32],
21//!     idempotency_key: None,
22//!     if_match: None,
23//!     // `None` is CBOR both ways; see [`CallBinding::payload_type`].
24//!     payload_type: None,
25//!     payload_accept: None,
26//! };
27//! let request = call.request();
28//! assert_eq!(request.query.as_deref(), Some("a=1&b=2"));
29//! assert_eq!(request.payload_media_type, "application/cbor");
30//! assert!(request.response.is_none());
31//!
32//! let response = call.response(b"the sealed request bytes", 200);
33//! assert_eq!(response.response.unwrap().status, 200);
34//! ```
35
36use std::borrow::Cow;
37
38use cratestack_core::{
39    Binding, BoundHeaders, ContractSelector, PathParams, ResponseBinding, canonical_query,
40    request_digest,
41};
42
43/// Everything one call binds, owned.
44///
45/// `query` is the raw query string as the client will send it; it is
46/// canonicalised here, and an empty result binds as `None`, as it does in
47/// the Rust client. `idempotency_key` and `if_match` are bound exactly as
48/// the request header will carry them, so the caller must send those
49/// headers byte for byte.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct CallBinding {
52    /// The configured name of the service addressed (never the `Host`).
53    pub audience: String,
54    /// HTTP method, e.g. `"POST"`.
55    pub method: String,
56    /// The RPC `op_id`, or the REST route template.
57    pub route: String,
58    /// REST path parameter values in template order; empty for RPC.
59    pub path_params: Vec<String>,
60    /// The request's query string, in any spelling.
61    pub query: Option<String>,
62    /// The op contract digest (`OP_CONTRACTS` of the generated client).
63    pub contract_sha: [u8; 32],
64    /// The request's `Idempotency-Key` header value, if it carries one.
65    pub idempotency_key: Option<String>,
66    /// The request's `If-Match` header value, if it carries one.
67    pub if_match: Option<String>,
68    /// The type the request payload is sealed under; `None` is
69    /// `application/cbor`. Bound in the AAD. For compact wire keys (compact
70    /// wire keys design ยง5.1) it is `application/vnd.cratestack.compact+cbor`.
71    /// When it is not CBOR the request must also carry
72    /// `Cratestack-Payload-Type` ([`selector_headers`](Self::selector_headers)).
73    pub payload_type: Option<String>,
74    /// The types the call reads its answer in, in order of preference;
75    /// `None` is just `application/cbor`. A response sealed under another
76    /// type is refused ([`response_for`](Self::response_for)). When it is
77    /// not just CBOR the request must also carry `Cratestack-Payload-Accept`.
78    pub payload_accept: Option<Vec<String>>,
79}
80
81impl CallBinding {
82    /// The request binding: what a request is sealed and opened under.
83    pub fn request(&self) -> Binding<'_> {
84        let query = Some(canonical_query(self.query.as_deref())).filter(|query| !query.is_empty());
85        Binding {
86            audience: Cow::Borrowed(&self.audience),
87            method: Cow::Borrowed(&self.method),
88            route: Cow::Borrowed(&self.route),
89            path_params: PathParams::Owned(self.path_params.clone()),
90            query: query.map(Cow::Owned),
91            contract_sha: self.contract_sha,
92            payload_media_type: Cow::Borrowed(self.request_payload_type()),
93            bound_headers: BoundHeaders {
94                idempotency_key: self.idempotency_key.as_deref().map(Cow::Borrowed),
95                if_match: self.if_match.as_deref().map(Cow::Borrowed),
96            },
97            response: None,
98        }
99    }
100
101    /// The binding of the response to `sealed_request` (the exact bytes
102    /// that travelled) with the HTTP `status` it came back with.
103    /// This is the CBOR-answer binding; for a call that accepts other types
104    /// use [`response_for`](Self::response_for).
105    pub fn response(&self, sealed_request: &[u8], status: u16) -> Binding<'_> {
106        Binding {
107            response: Some(ResponseBinding {
108                request: request_digest(sealed_request),
109                status,
110            }),
111            ..self.request()
112        }
113    }
114}
115
116/// The `Cratestack-Contract` header value for an op contract digest: the
117/// first 8 bytes as 11 characters of unpadded base64url. The header is not
118/// bound; it tells a server holding several accepted digests which one the
119/// request was sealed under.
120pub fn contract_header_value(contract_sha: &[u8; 32]) -> String {
121    ContractSelector::of(contract_sha).to_header_value()
122}
123
124mod payload;
125#[cfg(test)]
126mod tests;