cratestack_axum/envelope_layer/builder_payload.rs
1//! [`EnvelopeLayerBuilder::payload_media_types`] (cratestack#1168).
2
3use cratestack_core::{CratestackError, DEFAULT_PAYLOAD_MEDIA_TYPE, is_sealable_payload_type};
4
5use super::builder::EnvelopeLayerBuilder;
6
7impl EnvelopeLayerBuilder {
8 /// The payload media types the layer allows inside the seal: `request`
9 /// for what a client seals, `response` for what the layer seals back.
10 /// **Default: `application/cbor` for both**, so a layer that never calls
11 /// this changes nothing for anyone: a request that names no type is
12 /// bound and opened with the bytes 0.15.3 used (AAD and body), and
13 /// answered by a response whose AAD and body are 0.15.3's too. The one
14 /// difference a 0.15.3 client can see is a new response header,
15 /// `Cratestack-Payload-Type`, which it ignores.
16 ///
17 /// A client names the type of its request in the unbound
18 /// `Cratestack-Payload-Type` header and the response types it reads, in
19 /// order, in `Cratestack-Payload-Accept` (the same selector pattern as
20 /// `Cratestack-Contract`: they select among what is allowed and never
21 /// widen it). Both are authenticated by the binding, not by the headers:
22 /// a request binding names the request payload's type, a response
23 /// binding names the **response payload's own** type, and a response
24 /// repeats it in `Cratestack-Payload-Type`. A header that lies about the
25 /// request fails the signature with the coarse `401`. Binding version 2
26 /// and the COSE wire are unchanged.
27 ///
28 /// What an op allows is this set intersected with the route's declared
29 /// types ([`ResolvedRoute::with_payload_types`], filled from the schema's
30 /// capabilities by [`RestBindingResolver`] and [`RpcBindingResolver`]).
31 /// `/rpc/batch` stays CBOR both ways. An empty route list (the generated
32 /// reads and deletes, which take no payload) constrains nothing, and a
33 /// `GET`, `HEAD` or `DELETE` is not checked for the type its **empty**
34 /// payload names (bound as sent, never refused): a payload that is not
35 /// empty is held to this set and the route's once opened, and refused as
36 /// a sealed `415`.
37 ///
38 /// A retry under the same `Idempotency-Key` must repeat its
39 /// `Cratestack-Payload-Accept`: the idempotency layer fingerprints the
40 /// request's content type and body, not the types it reads, so a
41 /// response stored for the first attempt in a type the retry did not
42 /// negotiate is never sealed for it (a sealed `500`, fail closed).
43 ///
44 /// Refusals are unsigned and made before any key is looked up or nonce
45 /// spent: a selector header sent twice or malformed is a `400`, a request
46 /// type outside the op's set a `415` (`payload_type_unsupported`), and a
47 /// `Cratestack-Payload-Accept` with no type the op can answer in a `406`
48 /// (`payload_type_not_acceptable`). A handler's success in a type the
49 /// request did not negotiate is a sealed `500`; its error in one is
50 /// re-encoded in the transport's error shape, in the client's first
51 /// choice of CBOR or JSON, and sealed.
52 ///
53 /// [`build`](Self::build) refuses a type outside the grammar of
54 /// `cratestack_core::parse_payload_type`, one that may never be sealed
55 /// (`application/cose*`, `application/cbor-seq`, `text/event-stream`,
56 /// `multipart/*`), an empty request set, and a response set without
57 /// `application/cbor` or `application/json`, which the layer's own
58 /// errors are sealed in.
59 ///
60 /// ```
61 /// use cratestack_axum::envelope_layer::EnvelopeLayer;
62 /// # fn demo(envelope: impl cratestack_axum::envelope_layer::ServerEnvelope) {
63 /// // vpay's shape: forms in, JSON out, and CBOR for the generated clients.
64 /// let builder = EnvelopeLayer::builder(envelope, "payments", &[]).payload_media_types(
65 /// ["application/cbor", "application/x-www-form-urlencoded"],
66 /// ["application/cbor", "application/json"],
67 /// );
68 /// # drop(builder);
69 /// # }
70 /// ```
71 ///
72 /// [`ResolvedRoute::with_payload_types`]: super::ResolvedRoute::with_payload_types
73 /// [`RestBindingResolver`]: super::RestBindingResolver
74 /// [`RpcBindingResolver`]: super::RpcBindingResolver
75 #[must_use]
76 pub fn payload_media_types<I, J>(mut self, request: I, response: J) -> Self
77 where
78 I: IntoIterator,
79 I::Item: Into<String>,
80 J: IntoIterator,
81 J::Item: Into<String>,
82 {
83 self.payload_request = request.into_iter().map(Into::into).collect();
84 self.payload_response = response.into_iter().map(Into::into).collect();
85 self
86 }
87}
88
89/// The checks `build()` makes of the configured payload types.
90pub(super) fn validate(request: &[String], response: &[String]) -> Result<(), CratestackError> {
91 let invalid = |message: String| Err(CratestackError::Validation(message));
92 if request.is_empty() {
93 return invalid("payload_media_types(..): the request set must not be empty".to_owned());
94 }
95 for (side, types) in [("request", request), ("response", response)] {
96 if let Some(bad) = types.iter().find(|t| !is_sealable_payload_type(t)) {
97 return invalid(format!(
98 "payload_media_types(..): {bad:?} cannot be a sealed {side} payload type \
99 (a lowercase `type/subtype` without parameters or wildcards, and never an \
100 envelope, a stream or a multipart body)"
101 ));
102 }
103 }
104 // The layer's own errors, and a handler's foreign-typed ones, are sealed
105 // in a type the transport's error codec writes.
106 if !response
107 .iter()
108 .any(|t| t == DEFAULT_PAYLOAD_MEDIA_TYPE || t == "application/json")
109 {
110 return invalid(
111 "payload_media_types(..): the response set needs application/cbor or \
112 application/json, which the layer's own errors are sealed in"
113 .to_owned(),
114 );
115 }
116 Ok(())
117}