Skip to main content

hey_sdk/
operation.rs

1use std::borrow::Cow;
2use std::fmt::Display;
3
4use crate::http::Method;
5use bytes::Bytes;
6use serde::Serialize;
7use url::Url;
8
9use crate::error::Error;
10use crate::observability::OperationInfo;
11use crate::route::Route;
12
13/// A request the client has not sent yet. Generated service methods build one from a
14/// [`Route`]; [`crate::Client::request`] builds one for anything the model does not cover.
15#[derive(Clone)]
16#[allow(clippy::struct_excessive_bools)] // each flag is one independent choice about the send
17pub struct Operation {
18    pub(crate) id: Cow<'static, str>,
19    pub(crate) info: OperationInfo,
20    /// The modelled route this sends, whose retry policy the client honours. A path the
21    /// caller wrote has none, and gets the client's own defaults.
22    pub(crate) route: Option<&'static Route>,
23    pub(crate) method: Method,
24    pub(crate) path: String,
25    pub(crate) url: Option<Url>,
26    pub(crate) query: Vec<(String, String)>,
27    pub(crate) body: Option<Body>,
28    pub(crate) idempotent: bool,
29    pub(crate) empty_on: &'static [u16],
30    pub(crate) accept: &'static str,
31    /// HEY answers JSON to paths that end in `.json`, so a modelled route gets one put
32    /// back on. A raw path is sent as the caller wrote it.
33    pub(crate) json_suffix: bool,
34    pub(crate) no_cache: bool,
35    pub(crate) capture_redirects: bool,
36    /// One request inside another operation rather than an operation of its own. See
37    /// [`Operation::quiet`].
38    pub(crate) quiet: bool,
39}
40
41#[derive(Clone)]
42pub(crate) struct Body {
43    pub(crate) content_type: String,
44    pub(crate) bytes: Bytes,
45}
46
47/// An operation prints what it is and where it goes, not what it carries: the path
48/// without any query the caller wrote into it, the query's names without their values, the
49/// body's shape without its bytes. A `{:?}` is the kind of thing that lands in a log, and
50/// the values are the caller's data.
51impl std::fmt::Debug for Operation {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        let query: Vec<&str> = self.query.iter().map(|(name, _)| name.as_str()).collect();
54        let without_query =
55            |value: &str| -> String { value.split('?').next().unwrap_or_default().to_string() };
56        f.debug_struct("Operation")
57            .field("id", &without_query(&self.id))
58            .field("method", &self.method)
59            .field("path", &without_query(&self.path))
60            .field("query", &query)
61            .field("body", &self.body)
62            .field("idempotent", &self.idempotent)
63            .field("quiet", &self.quiet)
64            .finish_non_exhaustive()
65    }
66}
67
68/// A body prints as its type and its size, never its bytes: a `{:?}` of an operation is
69/// the kind of thing that lands in a log, and the body is the caller's data.
70impl std::fmt::Debug for Body {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("Body")
73            .field("content_type", &self.content_type)
74            .field("len", &self.bytes.len())
75            .finish()
76    }
77}
78
79/// The two redirects HEY's form-backed endpoints answer with, which
80/// [`Operation::capture_redirects`] takes for an answer rather than a failure.
81const REDIRECTS: &[u16] = &[302, 303];
82
83impl Operation {
84    pub(crate) fn for_route(route: &'static Route, params: &[&dyn Display]) -> Operation {
85        Operation {
86            id: Cow::Borrowed(route.id),
87            route: Some(route),
88            info: OperationInfo {
89                service: Cow::Borrowed(route.service),
90                operation: Cow::Borrowed(route.id),
91                resource_type: Cow::Borrowed(route.resource_type),
92                is_mutation: !route.readonly,
93                resource_id: None,
94            },
95            method: route.method.clone(),
96            path: route.fill(params),
97            url: None,
98            query: Vec::new(),
99            body: None,
100            idempotent: route.idempotent,
101            empty_on: route.empty_on,
102            accept: if route.html {
103                "text/html"
104            } else {
105                "application/json"
106            },
107            json_suffix: !route.html,
108            no_cache: false,
109            capture_redirects: false,
110            quiet: false,
111        }
112    }
113
114    pub(crate) fn raw(method: Method, path: String) -> Operation {
115        let idempotent = matches!(
116            method,
117            Method::GET | Method::HEAD | Method::PUT | Method::DELETE
118        );
119        let id = format!("{method} {path}");
120        let info = OperationInfo {
121            service: Cow::Borrowed("Raw"),
122            operation: Cow::Owned(id.clone()),
123            resource_type: Cow::Borrowed("raw"),
124            is_mutation: method != Method::GET,
125            resource_id: None,
126        };
127        Operation {
128            id: Cow::Owned(id),
129            route: None,
130            info,
131            method,
132            path,
133            url: None,
134            query: Vec::new(),
135            body: None,
136            idempotent,
137            empty_on: &[],
138            accept: "application/json",
139            json_suffix: true,
140            no_cache: false,
141            capture_redirects: false,
142            quiet: false,
143        }
144    }
145
146    pub(crate) fn at(method: Method, url: Url) -> Operation {
147        let mut operation = Operation::raw(method, url.path().to_string());
148        operation.url = Some(url);
149        operation
150    }
151
152    /// The operation as the model names it, or `METHOD /path` for one built by hand.
153    pub fn id(&self) -> &str {
154        &self.id
155    }
156
157    /// What an error or a log calls the operation: the name the model or a wrapper gave
158    /// it, or the method alone for a path the caller wrote, since that path — and whatever
159    /// query it carries — is the caller's.
160    pub(crate) fn label(&self) -> &str {
161        if self.info.service == "Raw" {
162            self.method.as_str()
163        } else {
164            &self.info.operation
165        }
166    }
167
168    /// The HTTP method the operation is sent with.
169    pub fn method(&self) -> &Method {
170        &self.method
171    }
172
173    /// The path the operation is sent to, parameters already filled in.
174    pub fn path(&self) -> &str {
175        &self.path
176    }
177
178    /// Adds a query parameter. The same name may be added more than once.
179    pub fn query(&mut self, name: &str, value: impl Display) -> &mut Operation {
180        self.query.push((name.to_string(), value.to_string()));
181        self
182    }
183
184    /// Adds a query parameter when there is a value for it, and nothing otherwise.
185    pub fn query_optional<T: Display>(&mut self, name: &str, value: Option<&T>) -> &mut Operation {
186        if let Some(value) = value {
187            self.query(name, value);
188        }
189        self
190    }
191
192    /// A JSON body, which is what every modelled write sends.
193    pub fn json<T: Serialize + ?Sized>(&mut self, body: &T) -> Result<&mut Operation, Error> {
194        self.body_bytes("application/json", Bytes::from(serde_json::to_vec(body)?));
195        Ok(self)
196    }
197
198    /// A form-encoded body, as a browser would post it.
199    pub fn form(&mut self, fields: &[(&str, &str)]) -> &mut Operation {
200        let encoded = url::form_urlencoded::Serializer::new(String::new())
201            .extend_pairs(fields)
202            .finish();
203        self.body_bytes("application/x-www-form-urlencoded", Bytes::from(encoded))
204    }
205
206    /// A multipart body the caller assembled, boundary and all. The content type has to
207    /// name that same boundary for HEY to read the parts.
208    pub fn multipart(&mut self, content_type: String, body: Bytes) -> &mut Operation {
209        self.body_bytes(content_type, body)
210    }
211
212    /// A body the caller encoded, for the representations the model does not describe.
213    pub fn body_bytes(&mut self, content_type: impl Into<String>, bytes: Bytes) -> &mut Operation {
214        self.body = Some(Body {
215            content_type: content_type.into(),
216            bytes,
217        });
218        self
219    }
220
221    /// Replaces the whole of what the operation announces itself as to the client's
222    /// [`crate::observability::Hooks`].
223    pub fn info(&mut self, info: OperationInfo) -> &mut Operation {
224        self.info = info;
225        self
226    }
227
228    /// Announces the operation as something other than the route it sends: HEY stops a
229    /// time track by updating one, and a wrapper that does so says `StopTimeTrack`.
230    pub fn operation_name(&mut self, name: impl Into<Cow<'static, str>>) -> &mut Operation {
231        self.info.operation = name.into();
232        self
233    }
234
235    /// Names the kind of record the operation acts on, in `snake_case`: `box_group`.
236    pub fn resource_type(&mut self, resource_type: impl Into<Cow<'static, str>>) -> &mut Operation {
237        self.info.resource_type = resource_type.into();
238        self
239    }
240
241    /// Names the record the operation acts on. Generated methods set this from the path
242    /// parameter that names it.
243    pub fn resource_id(&mut self, resource_id: i64) -> &mut Operation {
244        self.info.resource_id = Some(resource_id);
245        self
246    }
247
248    /// Marks the operation as safe to resend, or not, regardless of its HTTP method.
249    pub fn idempotent(&mut self, idempotent: bool) -> &mut Operation {
250        self.idempotent = idempotent;
251        self
252    }
253
254    /// The representation to ask HEY for, sent as `Accept`.
255    pub fn accept(&mut self, media_type: &'static str) -> &mut Operation {
256        self.accept = media_type;
257        self
258    }
259
260    /// Sends the path as it stands. A modelled route gets a `.json` suffix put back on the
261    /// paths Smithy cannot spell it into; a path the caller wrote needs no such repair.
262    pub fn without_json_suffix(&mut self) -> &mut Operation {
263        self.json_suffix = false;
264        self
265    }
266
267    /// Reads past the response cache for this send, so the answer is HEY's own. A blob is
268    /// read this way: the cache is for JSON documents.
269    pub fn no_cache(&mut self) -> &mut Operation {
270        self.no_cache = true;
271        self
272    }
273
274    /// Treats a redirect as the answer rather than following it. HEY's form-backed
275    /// endpoints answer a 302 or 303 naming what they created, and that `Location` is the
276    /// whole of what the request was for.
277    pub fn capture_redirects(&mut self) -> &mut Operation {
278        self.capture_redirects = true;
279        self.empty_on = REDIRECTS;
280        self
281    }
282
283    /// Asks for the HTML representation a form-backed endpoint serves: no `.json` suffix,
284    /// and no preference about what comes back.
285    pub fn form_representation(&mut self) -> &mut Operation {
286        self.without_json_suffix().accept("*/*")
287    }
288
289    /// Sends this without announcing an operation: no gate, no start, no end. For a request
290    /// made inside another operation — the read-back a write needs to answer with the record
291    /// it wrote — so the hooks hear about that operation once rather than twice.
292    ///
293    /// The request hooks still fire, so every send the SDK makes is still reported. So is
294    /// every layer the announced operation went through: a quiet send is inside its
295    /// bulkhead permit and under its circuit breaker, not beside them.
296    pub fn quiet(&mut self) -> &mut Operation {
297        self.quiet = true;
298        self
299    }
300}