1use crate::client::{SearchClient, encode};
28use crate::error::{Result, SearchError};
29use rustlavel_core::Json;
30use rustlavel_http::Method;
31use std::collections::BTreeMap;
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Field {
36 options: BTreeMap<String, Json>,
37 sub_fields: BTreeMap<String, Field>,
38 properties: BTreeMap<String, Field>,
39}
40
41impl Field {
42 pub fn text() -> Field {
50 Field::of("text")
51 }
52
53 pub fn keyword() -> Field {
58 Field::of("keyword")
59 }
60
61 pub fn long() -> Field {
62 Field::of("long")
63 }
64
65 pub fn integer() -> Field {
66 Field::of("integer")
67 }
68
69 pub fn double() -> Field {
70 Field::of("double")
71 }
72
73 pub fn float() -> Field {
74 Field::of("float")
75 }
76
77 pub fn boolean() -> Field {
78 Field::of("boolean")
79 }
80
81 pub fn date() -> Field {
84 Field::of("date")
85 }
86
87 pub fn ip() -> Field {
88 Field::of("ip")
89 }
90
91 pub fn geo_point() -> Field {
92 Field::of("geo_point")
93 }
94
95 pub fn object() -> Field {
102 Field::of("object")
103 }
104
105 pub fn nested() -> Field {
107 Field::of("nested")
108 }
109
110 pub fn of(kind: &str) -> Field {
112 Field {
113 options: BTreeMap::from([("type".to_string(), Json::String(kind.to_string()))]),
114 sub_fields: BTreeMap::new(),
115 properties: BTreeMap::new(),
116 }
117 }
118
119 pub fn with_keyword(mut self) -> Field {
128 self.sub_fields.insert(
129 "keyword".to_string(),
130 Field::keyword().option("ignore_above", Json::from(256)),
131 );
132 self
133 }
134
135 pub fn analyzer(self, analyzer: &str) -> Field {
138 self.option("analyzer", Json::String(analyzer.to_string()))
139 }
140
141 pub fn format(self, format: &str) -> Field {
143 self.option("format", Json::String(format.to_string()))
144 }
145
146 pub fn not_indexed(self) -> Field {
149 self.option("index", Json::Bool(false))
150 }
151
152 pub fn ignore_malformed(self) -> Field {
160 self.option("ignore_malformed", Json::Bool(true))
161 }
162
163 pub fn field(mut self, name: impl Into<String>, field: Field) -> Field {
165 self.properties.insert(name.into(), field);
166 self
167 }
168
169 pub fn sub_field(mut self, name: impl Into<String>, field: Field) -> Field {
171 self.sub_fields.insert(name.into(), field);
172 self
173 }
174
175 pub fn option(mut self, name: impl Into<String>, value: Json) -> Field {
177 self.options.insert(name.into(), value);
178 self
179 }
180
181 pub fn json(&self) -> Json {
183 let mut body = self.options.clone();
184
185 if !self.sub_fields.is_empty() {
186 body.insert("fields".to_string(), properties_of(&self.sub_fields));
187 }
188 if !self.properties.is_empty() {
189 body.insert("properties".to_string(), properties_of(&self.properties));
190 }
191
192 Json::Object(body)
193 }
194}
195
196#[derive(Debug, Clone, Default, PartialEq)]
198pub struct IndexDefinition {
199 fields: BTreeMap<String, Field>,
200 settings: BTreeMap<String, Json>,
201 dynamic: Option<Json>,
202 aliases: Vec<String>,
203}
204
205impl IndexDefinition {
206 pub fn new() -> IndexDefinition {
207 IndexDefinition::default()
208 }
209
210 pub fn field(mut self, name: impl Into<String>, field: Field) -> IndexDefinition {
211 self.fields.insert(name.into(), field);
212 self
213 }
214
215 pub fn shards(self, shards: u32) -> IndexDefinition {
217 self.setting("number_of_shards", Json::from(shards))
218 }
219
220 pub fn replicas(self, replicas: u32) -> IndexDefinition {
226 self.setting("number_of_replicas", Json::from(replicas))
227 }
228
229 pub fn refresh_interval(self, interval: &str) -> IndexDefinition {
232 self.setting("refresh_interval", Json::String(interval.to_string()))
233 }
234
235 pub fn setting(mut self, name: impl Into<String>, value: Json) -> IndexDefinition {
236 self.settings.insert(name.into(), value);
237 self
238 }
239
240 pub fn dynamic_strict(mut self) -> IndexDefinition {
248 self.dynamic = Some(Json::String("strict".to_string()));
249 self
250 }
251
252 pub fn dynamic_false(mut self) -> IndexDefinition {
255 self.dynamic = Some(Json::Bool(false));
256 self
257 }
258
259 pub fn alias(mut self, alias: impl Into<String>) -> IndexDefinition {
261 self.aliases.push(alias.into());
262 self
263 }
264
265 pub fn mappings(&self) -> Json {
267 let mut body = BTreeMap::new();
268
269 if let Some(dynamic) = &self.dynamic {
270 body.insert("dynamic".to_string(), dynamic.clone());
271 }
272 if !self.fields.is_empty() {
273 body.insert("properties".to_string(), properties_of(&self.fields));
274 }
275
276 Json::Object(body)
277 }
278
279 pub fn body(&self) -> Json {
281 let mut body = BTreeMap::new();
282
283 if !self.settings.is_empty() {
284 body.insert("settings".to_string(), Json::Object(self.settings.clone()));
285 }
286
287 let mappings = self.mappings();
288 if !mappings.as_object().map(BTreeMap::is_empty).unwrap_or(true) {
289 body.insert("mappings".to_string(), mappings);
290 }
291
292 if !self.aliases.is_empty() {
293 let aliases = self
294 .aliases
295 .iter()
296 .map(|name| (name.clone(), Json::Object(BTreeMap::new())))
297 .collect();
298 body.insert("aliases".to_string(), Json::Object(aliases));
299 }
300
301 Json::Object(body)
302 }
303}
304
305fn properties_of(fields: &BTreeMap<String, Field>) -> Json {
306 Json::Object(fields.iter().map(|(name, field)| (name.clone(), field.json())).collect())
307}
308
309impl SearchClient {
310 pub async fn create_index(&self, name: &str, definition: &IndexDefinition) -> Result<()> {
314 let path = encode(name);
315 self.json(Method::Put, &path, name, Some(definition.body())).await?;
316 Ok(())
317 }
318
319 pub async fn create_index_if_missing(
327 &self,
328 name: &str,
329 definition: &IndexDefinition,
330 ) -> Result<bool> {
331 match self.create_index(name, definition).await {
332 Ok(()) => Ok(true),
333 Err(SearchError::IndexAlreadyExists { .. }) => Ok(false),
334 Err(error) => Err(error),
335 }
336 }
337
338 pub async fn delete_index(&self, name: &str) -> Result<bool> {
340 let path = encode(name);
341 match self.json(Method::Delete, &path, name, None).await {
342 Ok(_) => Ok(true),
343 Err(SearchError::IndexNotFound { .. }) => Ok(false),
344 Err(error) => Err(error),
345 }
346 }
347
348 pub async fn index_exists(&self, name: &str) -> Result<bool> {
354 self.exists_at(&format!("{}?filter_path=none", encode(name)), name).await
355 }
356
357 pub async fn put_mapping(&self, name: &str, definition: &IndexDefinition) -> Result<()> {
363 let path = format!("{}/_mapping", encode(name));
364 self.json(Method::Put, &path, name, Some(definition.mappings())).await?;
365 Ok(())
366 }
367
368 pub async fn get_mapping(&self, name: &str) -> Result<Json> {
371 let path = format!("{}/_mapping", encode(name));
372 self.json(Method::Get, &path, name, None).await
373 }
374
375 pub async fn refresh(&self, name: &str) -> Result<()> {
388 let path = format!("{}/_refresh", encode(name));
389 self.json(Method::Post, &path, name, None).await?;
390 Ok(())
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use rustlavel_client::{Fake, FakeResponse};
398
399 fn json(body: &str) -> Json {
400 Json::parse(body).expect("valid JSON in a test")
401 }
402
403 #[test]
404 fn a_definition_sends_only_the_sections_that_have_something_in_them() {
405 assert_eq!(IndexDefinition::new().body().to_string(), "{}");
406
407 let definition = IndexDefinition::new().field("title", Field::text());
408 assert_eq!(
409 definition.body().to_string(),
410 r#"{"mappings":{"properties":{"title":{"type":"text"}}}}"#
411 );
412 }
413
414 #[test]
415 fn a_text_field_with_a_keyword_gets_the_sub_field_aggregations_need() {
416 let definition = IndexDefinition::new().field("title", Field::text().with_keyword());
419
420 assert_eq!(
421 definition.mappings().to_string(),
422 r#"{"properties":{"title":{"fields":{"keyword":{"ignore_above":256,"type":"keyword"}},"type":"text"}}}"#
423 );
424 }
425
426 #[test]
427 fn settings_mappings_and_aliases_all_land_in_the_create_body() {
428 let definition = IndexDefinition::new()
429 .shards(1)
430 .replicas(0)
431 .refresh_interval("30s")
432 .dynamic_strict()
433 .alias("posts-current")
434 .field("views", Field::long());
435
436 assert_eq!(
437 definition.body().to_string(),
438 r#"{"aliases":{"posts-current":{}},"mappings":{"dynamic":"strict","properties":{"views":{"type":"long"}}},"settings":{"number_of_replicas":0,"number_of_shards":1,"refresh_interval":"30s"}}"#
439 );
440 }
441
442 #[test]
443 fn a_nested_field_carries_its_own_properties() {
444 let field = Field::nested()
445 .field("street", Field::text())
446 .field("postcode", Field::keyword());
447
448 assert_eq!(
449 field.json().to_string(),
450 r#"{"properties":{"postcode":{"type":"keyword"},"street":{"type":"text"}},"type":"nested"}"#
451 );
452 }
453
454 #[test]
455 fn field_options_reach_the_mapping() {
456 assert_eq!(
457 Field::text().analyzer("english").json().to_string(),
458 r#"{"analyzer":"english","type":"text"}"#
459 );
460 assert_eq!(
461 Field::date().format("yyyy-MM-dd").json().to_string(),
462 r#"{"format":"yyyy-MM-dd","type":"date"}"#
463 );
464 assert_eq!(
465 Field::keyword().not_indexed().json().to_string(),
466 r#"{"index":false,"type":"keyword"}"#
467 );
468 assert_eq!(
469 Field::long().ignore_malformed().json().to_string(),
470 r#"{"ignore_malformed":true,"type":"long"}"#
471 );
472 }
473
474 #[tokio::test]
475 async fn creating_an_index_puts_the_body_at_the_index_url() {
476 let client = SearchClient::new("http://localhost:9200").faking(
477 Fake::new().fallback(FakeResponse::json(json(
478 r#"{"acknowledged":true,"shards_acknowledged":true,"index":"posts"}"#,
479 ))),
480 );
481
482 client
483 .create_index("posts", &IndexDefinition::new().field("title", Field::text()))
484 .await
485 .unwrap();
486
487 let sent = &client.fake().unwrap().recorded()[0];
488 assert_eq!(sent.method, Method::Put);
489 assert_eq!(sent.url, "http://localhost:9200/posts");
490 assert!(sent.body_text().contains(r#""title":{"type":"text"}"#));
491 }
492
493 #[tokio::test]
494 async fn creating_an_index_that_exists_is_forgiven_only_when_it_was_asked_to_be() {
495 let body = r#"{"error":{"root_cause":[{"type":"resource_already_exists_exception","reason":"index [posts/9jNXbCyMS4WTRDMCsQiSuQ] already exists","index_uuid":"9jNXbCyMS4WTRDMCsQiSuQ","index":"posts"}],"type":"resource_already_exists_exception","reason":"index [posts/9jNXbCyMS4WTRDMCsQiSuQ] already exists","index_uuid":"9jNXbCyMS4WTRDMCsQiSuQ","index":"posts"},"status":400}"#;
496 let definition = IndexDefinition::new();
497
498 let client = SearchClient::new("http://localhost:9200")
499 .faking(Fake::new().fallback(FakeResponse::text(body).status(400)));
500 assert!(matches!(
501 client.create_index("posts", &definition).await,
502 Err(SearchError::IndexAlreadyExists { .. })
503 ));
504
505 let client = SearchClient::new("http://localhost:9200")
506 .faking(Fake::new().fallback(FakeResponse::text(body).status(400)));
507 assert!(
508 !client.create_index_if_missing("posts", &definition).await.unwrap(),
509 "it existed, so nothing was created"
510 );
511 }
512
513 #[tokio::test]
514 async fn deleting_an_index_that_is_not_there_is_not_an_error() {
515 let body = r#"{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index [gone]","resource.type":"index_or_alias","resource.id":"gone","index_uuid":"_na_","index":"gone"}],"type":"index_not_found_exception","reason":"no such index [gone]","resource.type":"index_or_alias","resource.id":"gone","index_uuid":"_na_","index":"gone"},"status":404}"#;
516 let client = SearchClient::new("http://localhost:9200")
517 .faking(Fake::new().fallback(FakeResponse::text(body).status(404)));
518
519 assert!(!client.delete_index("gone").await.unwrap());
520 }
521
522 #[tokio::test]
523 async fn existence_asks_for_as_little_as_possible_and_reads_a_404_as_an_answer() {
524 let present = SearchClient::new("http://localhost:9200")
527 .faking(Fake::new().fallback(FakeResponse::json(json("{}"))));
528 assert!(present.index_exists("posts").await.unwrap());
529
530 let sent = &present.fake().unwrap().recorded()[0];
531 assert_eq!(sent.method, Method::Get);
532 assert_eq!(sent.url, "http://localhost:9200/posts?filter_path=none");
533
534 let absent = SearchClient::new("http://localhost:9200")
535 .faking(Fake::new().fallback(FakeResponse::text("").status(404)));
536 assert!(!absent.index_exists("posts").await.unwrap());
537 }
538
539 #[tokio::test]
540 async fn a_mapping_update_sends_only_the_mappings_half() {
541 let client = SearchClient::new("http://localhost:9200")
542 .faking(Fake::new().fallback(FakeResponse::json(json(r#"{"acknowledged":true}"#))));
543
544 client
547 .put_mapping(
548 "posts",
549 &IndexDefinition::new().shards(3).field("summary", Field::text()),
550 )
551 .await
552 .unwrap();
553
554 let sent = &client.fake().unwrap().recorded()[0];
555 assert_eq!(sent.url, "http://localhost:9200/posts/_mapping");
556 assert_eq!(sent.body_text(), r#"{"properties":{"summary":{"type":"text"}}}"#);
557 }
558
559 #[tokio::test]
560 async fn refreshing_posts_to_the_refresh_endpoint() {
561 let client = SearchClient::new("http://localhost:9200").faking(Fake::new().fallback(
562 FakeResponse::json(json(r#"{"_shards":{"total":2,"successful":1,"failed":0}}"#)),
563 ));
564
565 client.refresh("posts").await.unwrap();
566
567 let sent = &client.fake().unwrap().recorded()[0];
568 assert_eq!(sent.method, Method::Post);
569 assert_eq!(sent.url, "http://localhost:9200/posts/_refresh");
570 }
571}