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}