Skip to main content

security_client_rs/
request.rs

1use std::{fmt, sync::Arc, time::Duration};
2
3use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
4use serde::Serialize;
5use serde_json::{Map, Value};
6
7use crate::{
8    Error, Kibana, Result, Scope,
9    error::Deferred,
10    http::{
11        Body, Method, Response, Transport,
12        headers::{HeaderMap, HeaderName, HeaderValue},
13    },
14};
15
16/// Characters escaped inside one path segment. URL parsing treats `\\` as `/`
17/// and resolves dot segments, so `/`, `\\` and `%` must never pass through.
18const SEGMENT: &AsciiSet = &CONTROLS
19    .add(b' ')
20    .add(b'"')
21    .add(b'#')
22    .add(b'<')
23    .add(b'>')
24    .add(b'?')
25    .add(b'`')
26    .add(b'{')
27    .add(b'}')
28    .add(b'/')
29    .add(b'\\')
30    .add(b'%');
31
32/// A request to any Kibana route, created by [`Kibana::request`].
33///
34/// Every endpoint builder wraps one of these. Construction never fails;
35/// invalid path segments and serialization errors are returned by `send`,
36/// with the same variant from every clone. Cloning shares the body, so a
37/// configured request can be reused cheaply, for example per page.
38#[derive(Clone)]
39#[must_use = "requests do nothing until sent"]
40pub struct Request<'a> {
41    transport: &'a Transport,
42    method: Method,
43    path: String,
44    query: Vec<(String, String)>,
45    headers: HeaderMap,
46    body: Option<Payload>,
47    timeout: Option<Duration>,
48    error: Option<Deferred>,
49    operation: &'static str,
50}
51
52#[derive(Clone)]
53enum Payload {
54    Json(Arc<Value>),
55    Body(Body),
56}
57
58impl<'a> Request<'a> {
59    pub(crate) fn new(client: &'a Kibana, method: Method, scope: Scope, segments: &[&str]) -> Self {
60        let space = match scope {
61            Scope::Global => None,
62            Scope::Space => client.space_id(),
63        };
64        let (path, error) = match path(space, segments) {
65            Ok(path) => (path, None),
66            Err(error) => (String::new(), Some(error.into())),
67        };
68        Self {
69            transport: client.transport(),
70            method,
71            path,
72            query: Vec::new(),
73            headers: HeaderMap::new(),
74            body: None,
75            timeout: None,
76            error,
77            operation: "request",
78        }
79    }
80
81    /// Appends URL-encoded query parameters serialized from `query`.
82    /// Avoid adding a second value for an existing selector or other
83    /// single-valued parameter; raw query additions do not check for conflicts.
84    pub fn query<Q: Serialize + ?Sized>(mut self, query: &Q) -> Self {
85        match serde_urlencoded::to_string(query) {
86            Ok(encoded) => self
87                .query
88                .extend(url::form_urlencoded::parse(encoded.as_bytes()).into_owned()),
89            Err(error) => self.fail(Error::serialize(error)),
90        }
91        self
92    }
93
94    /// Sends `body` as JSON, replacing any previous body. A body that would
95    /// repeat an object key, as when a flattened extension map sets a modeled
96    /// field such as `id`, fails with [`Error::InvalidRequest`].
97    ///
98    /// This is a raw replacement: it does not reapply the named builder's
99    /// selector, concurrency or URL/body identity checks. The caller must
100    /// retain those fields. An earlier construction error is still returned
101    /// by `send`, even if the replacement body would be valid.
102    pub fn json<B: Serialize + ?Sized>(mut self, body: &B) -> Self {
103        match crate::http::to_json(body) {
104            Ok(value) => self.body = Some(Payload::Json(Arc::new(value))),
105            Err(error) => self.fail(error),
106        }
107        self
108    }
109
110    /// Replaces the body without reapplying named-builder checks, as with
111    /// [`Self::json`]. Earlier construction errors still fail at `send`.
112    pub fn body(mut self, body: Body) -> Self {
113        self.body = Some(Payload::Body(body));
114        self
115    }
116
117    /// Checks the top-level JSON ID against the path argument. Some update
118    /// contracts require it; others allow it to be omitted.
119    pub(crate) fn check_body_id(mut self, id: &str, required: bool) -> Self {
120        if self.error.is_some() {
121            return self;
122        }
123        let object = match self.body.as_ref() {
124            Some(Payload::Json(body)) => body.as_object(),
125            _ => None,
126        };
127        let error = match object {
128            None => Some("request body must be a JSON object"),
129            Some(object) => match object.get("id") {
130                None if !required => None,
131                Some(Value::String(body_id)) if body_id == id => None,
132                _ => Some("request body id must be a string matching the path id"),
133            },
134        };
135        if let Some(error) = error {
136            self.fail(Error::InvalidRequest(error.into()));
137        }
138        self
139    }
140
141    /// Sets a header on this request only. It replaces every value of the same
142    /// name from the transport defaults or the body, including `Content-Type`.
143    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
144        self.headers.insert(name, value);
145        self
146    }
147
148    /// Overrides the transport timeout for this request.
149    pub fn request_timeout(mut self, timeout: Duration) -> Self {
150        self.timeout = Some(timeout);
151        self
152    }
153
154    /// Sends the request and checks its HTTP status. A successful response's body
155    /// is read separately with [`Response::json`], [`Response::bytes`] or a stream.
156    /// Construction errors are returned before HTTP. Requests are never retried.
157    pub async fn send(self) -> Result<Response> {
158        self.send_as().await
159    }
160
161    /// Sets a query parameter, replacing an earlier value with the same key.
162    #[allow(clippy::needless_pass_by_value)]
163    pub(crate) fn param(mut self, key: &str, value: impl ToString) -> Self {
164        self.query.retain(|(k, _)| k != key);
165        self.query.push((key.to_owned(), value.to_string()));
166        self
167    }
168
169    /// Sets a count parameter that must be at least one, such as a page size.
170    pub(crate) fn positive_param(mut self, key: &str, value: u32) -> Self {
171        if value == 0 {
172            self.fail(Error::InvalidRequest(format!("{key} must be at least 1")));
173        }
174        self.param(key, value)
175    }
176
177    /// Sets a selector query parameter, which must not be empty.
178    pub(crate) fn selector(self, (key, value): (&str, &str)) -> Self {
179        self.nonempty(key, value).param(key, value)
180    }
181
182    /// Fails the request when a required identifier is empty.
183    pub(crate) fn nonempty(mut self, key: &str, value: &str) -> Self {
184        if value.is_empty() {
185            self.fail(Error::InvalidRequest(format!("{key} must not be empty")));
186        }
187        self
188    }
189
190    /// Fails the request with `message`.
191    pub(crate) fn invalid(mut self, message: String) -> Self {
192        self.fail(Error::InvalidRequest(message));
193        self
194    }
195
196    /// Adds a query parameter, keeping earlier values with the same key.
197    #[allow(clippy::needless_pass_by_value)]
198    pub(crate) fn append_param(mut self, key: &str, value: impl ToString) -> Self {
199        self.query.push((key.to_owned(), value.to_string()));
200        self
201    }
202
203    /// Sets one field of a JSON object body, creating the object if needed.
204    pub(crate) fn field(mut self, key: &str, value: impl Serialize) -> Self {
205        let value = match crate::http::to_json(&value) {
206            Ok(value) => value,
207            Err(error) => {
208                self.fail(error);
209                return self;
210            }
211        };
212        let body = self
213            .body
214            .get_or_insert_with(|| Payload::Json(Arc::new(Value::Object(Map::new()))));
215        match body {
216            Payload::Json(json) => match Arc::make_mut(json) {
217                Value::Object(object) => {
218                    object.insert(key.to_owned(), value);
219                }
220                _ => self.fail(Error::InvalidRequest(format!(
221                    "cannot set {key:?} because the request body is not a JSON object"
222                ))),
223            },
224            Payload::Body(_) => self.fail(Error::InvalidRequest(format!(
225                "cannot set {key:?} because the request body is not a JSON object"
226            ))),
227        }
228        self
229    }
230
231    /// Sends `bytes` as the `file` part of a multipart upload.
232    pub(crate) fn file(mut self, file_name: &str, content_type: &str, bytes: Vec<u8>) -> Self {
233        match Body::file("file", file_name, content_type, bytes) {
234            Ok(body) => self.body = Some(Payload::Body(body)),
235            Err(error) => self.fail(error),
236        }
237        self
238    }
239
240    /// Labels the request with its endpoint name for trace events.
241    pub(crate) fn named(mut self, operation: &'static str) -> Self {
242        self.operation = operation;
243        self
244    }
245
246    pub(crate) async fn send_as<T>(self) -> Result<Response<T>> {
247        if let Some(error) = self.error {
248            return Err(error.into());
249        }
250        let body = match self.body {
251            None => None,
252            Some(Payload::Json(value)) => Some(Body::json(&*value)?),
253            Some(Payload::Body(body)) => Some(body),
254        };
255        let query = (!self.query.is_empty()).then_some(&self.query);
256        let response = self
257            .transport
258            .send_named(
259                self.operation,
260                self.method,
261                &self.path,
262                self.headers,
263                query,
264                body,
265                self.timeout,
266            )
267            .await?;
268        Ok(response.cast())
269    }
270
271    fn fail(&mut self, error: Error) {
272        self.error.get_or_insert_with(|| error.into());
273    }
274}
275
276/// Omits query values, header values and the body, which can carry secrets.
277impl fmt::Debug for Request<'_> {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        let query: Vec<&str> = self.query.iter().map(|(key, _)| key.as_str()).collect();
280        f.debug_struct("Request")
281            .field("method", &self.method)
282            .field("path", &self.path)
283            .field("query_keys", &query)
284            .finish_non_exhaustive()
285    }
286}
287
288fn path(space: Option<&str>, segments: &[&str]) -> Result<String> {
289    if segments.is_empty()
290        || segments
291            .iter()
292            .any(|s| s.is_empty() || *s == "." || *s == "..")
293    {
294        return Err(Error::InvalidRequest(
295            "path segments must be nonempty and cannot be dot segments".into(),
296        ));
297    }
298    let mut path = String::new();
299    for segment in space
300        .into_iter()
301        .flat_map(|id| ["s", id])
302        .chain(segments.iter().copied())
303    {
304        path.push('/');
305        path.extend(utf8_percent_encode(segment, SEGMENT));
306    }
307    Ok(path)
308}
309
310/// Declares an endpoint builder around a [`Request`] with the common per-request
311/// options and a `send` method returning `Response<$output>`.
312macro_rules! endpoint {
313    ($(#[$doc:meta])* $name:ident => $output:ty) => {
314        $(#[$doc])*
315        #[derive(Clone, Debug)]
316        #[must_use = "requests do nothing until sent"]
317        pub struct $name<'a>($crate::Request<'a>);
318
319        impl<'a> $name<'a> {
320            /// Converts this builder to a raw request, preserving its configured
321            /// route, scope, parameters, body, headers and timeout.
322            /// Replacing the raw body does not reapply this builder's identity,
323            /// concurrency or typed-field checks. Existing construction errors
324            /// remain errors and cannot be repaired through the raw request.
325            pub fn into_request(self) -> $crate::Request<'a> {
326                self.0.named(stringify!($name))
327            }
328
329            /// Sets a header on this request only. It replaces every value of the
330            /// same name from the transport defaults or the body.
331            pub fn header(
332                self,
333                name: $crate::http::headers::HeaderName,
334                value: $crate::http::headers::HeaderValue,
335            ) -> Self {
336                Self(self.0.header(name, value))
337            }
338
339            /// Overrides the transport timeout for this request.
340            pub fn request_timeout(self, timeout: ::std::time::Duration) -> Self {
341                Self(self.0.request_timeout(timeout))
342            }
343
344            /// Sends the request and checks its HTTP status. The successful body
345            /// remains unread; consume it through [`Response`](crate::http::Response).
346            /// Construction errors are returned before HTTP. Requests are never retried.
347            pub async fn send(self) -> $crate::Result<$crate::http::Response<$output>> {
348                self.into_request().send_as().await
349            }
350        }
351    };
352}
353pub(crate) use endpoint;
354
355#[cfg(test)]
356mod tests {
357    use serde_json::json;
358
359    use super::path;
360    use crate::{Error, Kibana, Scope, http::Method, http::Transport};
361
362    #[tokio::test]
363    async fn fields_cannot_be_merged_into_a_non_object_body() {
364        let client = Kibana::new(Transport::single_node("http://127.0.0.1:9").unwrap());
365        let error = client
366            .request(Method::PUT, Scope::Global, &["api", "x"])
367            .json(&json!(["not", "an", "object"]))
368            .field("id", "a")
369            .send()
370            .await
371            .unwrap_err();
372        assert!(
373            matches!(error, Error::InvalidRequest(ref message) if message.contains("\"id\"")),
374            "{error:?}"
375        );
376    }
377
378    #[test]
379    fn segments_are_encoded_individually_and_scoped_to_the_space() {
380        assert_eq!(
381            path(Some("soc team"), &["api", "cases", "a/b?c#d%e"]).unwrap(),
382            "/s/soc%20team/api/cases/a%2Fb%3Fc%23d%25e"
383        );
384        assert_eq!(path(None, &["api", "status"]).unwrap(), "/api/status");
385        assert_eq!(
386            path(Some("soc"), &["api", "cases", r"..\..\api\security\role"]).unwrap(),
387            "/s/soc/api/cases/..%5C..%5Capi%5Csecurity%5Crole"
388        );
389        for invalid in [&[][..], &["api", ""], &["api", "."], &["api", ".."]] {
390            assert!(path(None, invalid).is_err());
391        }
392    }
393}