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;