Skip to main content

toolkit_contract/ir/
binding.rs

1use serde::{Deserialize, Serialize};
2
3/// HTTP binding projection for a contract.
4///
5/// Deliberately NOT `#[non_exhaustive]` (see [`super::contract::ContractIr`]'s
6/// doc): `#[toolkit::rest_contract]` emits a struct-literal `HttpBindingIr { .. }`
7/// into the SDK crate's generated `<trait>_http_binding()` function.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct HttpBindingIr {
10    /// Base path prefix.
11    pub base_path: String,
12    /// Per-method HTTP bindings.
13    pub methods: Vec<HttpMethodBindingIr>,
14}
15
16impl HttpBindingIr {
17    /// Find the binding for a specific method by name.
18    #[must_use]
19    pub fn find_method(&self, method_name: &str) -> Option<&HttpMethodBindingIr> {
20        self.methods.iter().find(|m| m.method_name == method_name)
21    }
22}
23
24/// HTTP binding for a single method.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HttpMethodBindingIr {
27    /// Method name, matching a `MethodIr.name` in the contract.
28    pub method_name: String,
29    /// HTTP method.
30    pub http_method: HttpMethod,
31    /// Path template relative to `base_path`.
32    pub path_template: String,
33    /// How each input field maps to the HTTP request.
34    pub field_bindings: Vec<HttpFieldBinding>,
35    /// Whether the client may retry this call automatically when the
36    /// transport fails or the response is a retryable HTTP status.
37    #[serde(default)]
38    pub retryable: bool,
39    /// Whether this binding represents a server-streaming endpoint.
40    #[serde(default)]
41    pub streaming: bool,
42    /// Wire framing for a streaming binding. Meaningless when
43    /// [`streaming`](Self::streaming) is `false`, where it stays at its
44    /// default.
45    ///
46    /// Kept alongside `streaming` rather than folded into an
47    /// `Option<StreamFraming>`: `streaming` is read on its own by IR validation
48    /// and the `OpenAPI` back-ends, and collapsing the two would be a semantic
49    /// change to an already-serialized shape for no gain.
50    ///
51    /// `#[serde(default)]` so IR serialized before this field existed
52    /// deserializes to [`StreamFraming::ServerSentEvents`], the historical
53    /// behavior. That buys nothing for Rust construction — the struct is
54    /// deliberately not `#[non_exhaustive]` because the macro emits a struct
55    /// literal of it — so a new construction site must name the field.
56    #[serde(default)]
57    pub stream_framing: StreamFraming,
58    /// Whether the underlying contract method has a default body (peers
59    /// MAY omit this endpoint). Mirrors `MethodIr.optional`.
60    #[serde(default)]
61    pub optional: bool,
62}
63
64/// HTTP method verb.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[non_exhaustive]
67pub enum HttpMethod {
68    /// HTTP GET.
69    Get,
70    /// HTTP POST.
71    Post,
72    /// HTTP PUT.
73    Put,
74    /// HTTP PATCH.
75    Patch,
76    /// HTTP DELETE.
77    Delete,
78}
79
80/// Wire framing for a server-streaming HTTP binding.
81///
82/// Two framings at two paths are two operations, not variants of one — this
83/// selects which wire format a single streaming binding speaks, not a
84/// content-negotiation set.
85///
86/// `#[non_exhaustive]` is safe here (unlike [`HttpMethodBindingIr`], which the
87/// macro emits as a struct literal): `#[toolkit::rest_contract]` only ever
88/// emits a *variant path* of this enum, which `#[non_exhaustive]` permits.
89///
90/// `Default` is [`Self::ServerSentEvents`] so IR serialized before a framing
91/// selector existed deserializes to the historical behavior.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
93#[non_exhaustive]
94pub enum StreamFraming {
95    /// `text/event-stream` — W3C Server-Sent Events. The historical default.
96    #[default]
97    ServerSentEvents,
98    /// `multipart/mixed` — one JSON item per body part.
99    MultipartMixed,
100}
101
102impl StreamFraming {
103    /// Every framing variant, in declaration order.
104    ///
105    /// Lets consumers derive the set of streaming media types from the enum
106    /// (via [`media_type`](Self::media_type)) instead of hard-coding the
107    /// strings — e.g. the `OpenAPI` registry deciding which response media types
108    /// carry a JSON *item* schema rather than an opaque string. Kept complete by
109    /// the exhaustiveness guard below, so a new variant cannot be silently
110    /// omitted.
111    pub const ALL: &'static [Self] = &[Self::ServerSentEvents, Self::MultipartMixed];
112
113    /// The media type the client advertises in `Accept` and the server emits
114    /// in `Content-Type`.
115    ///
116    /// For `multipart/mixed` the emitted header additionally carries a
117    /// runtime-generated `boundary=` parameter, which is not part of this
118    /// value — see `toolkit::http::multipart::MultipartJsonStream`.
119    #[must_use]
120    pub const fn media_type(self) -> &'static str {
121        match self {
122            Self::ServerSentEvents => "text/event-stream",
123            Self::MultipartMixed => "multipart/mixed",
124        }
125    }
126
127    /// Whether `media_type` is the media type of some streaming framing.
128    ///
129    /// Derived from [`ALL`](Self::ALL) so a new framing is included
130    /// automatically; the `OpenAPI` registry uses it to decide whether a response
131    /// media type renders its schema as a `$ref` (streaming JSON item) rather
132    /// than an opaque string.
133    #[must_use]
134    pub fn is_stream_media_type(media_type: &str) -> bool {
135        Self::ALL
136            .iter()
137            .any(|framing| framing.media_type() == media_type)
138    }
139}
140
141// Exhaustiveness guard for [`StreamFraming::ALL`]. `#[non_exhaustive]` only
142// forces a wildcard on *downstream* crates; within this crate this match must
143// cover every variant, so adding one fails to compile here — the reminder to
144// append it to `ALL` above (and to `ALL.len()` below).
145const _: () = {
146    fn _assert_all_variants_listed(framing: StreamFraming) {
147        match framing {
148            StreamFraming::ServerSentEvents | StreamFraming::MultipartMixed => {}
149        }
150    }
151    // A variant added to the match but not to `ALL` (or vice versa) trips this.
152    assert!(StreamFraming::ALL.len() == 2);
153};
154
155/// How an input field is bound to the HTTP request.
156///
157/// `#[non_exhaustive]` at the enum level only: codegen only ever constructs
158/// the variants that exist today (`Path`/`Query`/`Body`), so this doesn't
159/// block macro-generated construction — it only forces downstream `match`
160/// arms to include a wildcard, so adding a future binding kind isn't a
161/// breaking change for crates that match on this type.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[non_exhaustive]
164pub enum HttpFieldBinding {
165    /// Field value goes into a URL path parameter.
166    Path {
167        /// Name of the field in `InputShape`.
168        field: String,
169        /// Name of the path parameter in the template.
170        param: String,
171    },
172    /// Field value goes into a query parameter.
173    Query {
174        /// Name of the field in `InputShape`.
175        field: String,
176        /// Name of the query parameter.
177        param: String,
178    },
179    /// Field value goes into the request body.
180    Body,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn every_framing_media_type_is_recognised_as_streaming() {
189        // Locks the derivation the OpenAPI registry relies on: each variant's
190        // own media type must be recognised, so a streaming response renders
191        // its item `$ref` rather than an opaque string.
192        for framing in StreamFraming::ALL {
193            assert!(
194                StreamFraming::is_stream_media_type(framing.media_type()),
195                "{framing:?} media type not recognised as streaming",
196            );
197        }
198    }
199
200    #[test]
201    fn non_streaming_media_types_are_not_recognised() {
202        assert!(!StreamFraming::is_stream_media_type("application/json"));
203        assert!(!StreamFraming::is_stream_media_type("text/plain"));
204    }
205}