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
16const 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#[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 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 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 pub fn body(mut self, body: Body) -> Self {
113 self.body = Some(Payload::Body(body));
114 self
115 }
116
117 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 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
144 self.headers.insert(name, value);
145 self
146 }
147
148 pub fn request_timeout(mut self, timeout: Duration) -> Self {
150 self.timeout = Some(timeout);
151 self
152 }
153
154 pub async fn send(self) -> Result<Response> {
158 self.send_as().await
159 }
160
161 #[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 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 pub(crate) fn selector(self, (key, value): (&str, &str)) -> Self {
179 self.nonempty(key, value).param(key, value)
180 }
181
182 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 pub(crate) fn invalid(mut self, message: String) -> Self {
192 self.fail(Error::InvalidRequest(message));
193 self
194 }
195
196 #[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 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 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 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
276impl 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
310macro_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 pub fn into_request(self) -> $crate::Request<'a> {
326 self.0.named(stringify!($name))
327 }
328
329 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 pub fn request_timeout(self, timeout: ::std::time::Duration) -> Self {
341 Self(self.0.request_timeout(timeout))
342 }
343
344 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}