1use crate::SearchError;
2use crate::models::FileSearchRequest;
3use crate::opensearch::models::{OsSearchIndexData, OsUpdateSearchIndexData, SearchResponse};
4
5use super::models::{FlattenedItemResult, PageResult, SearchScore};
6use super::{
7 SearchIndex,
8 models::{
9 FileSearchResults, SearchIndexData, SearchRequest, SearchResults, UpdateSearchIndexData,
10 },
11};
12use aws_config::SdkConfig;
13use docbox_database::DbTransaction;
14use docbox_database::models::document_box::DocumentBoxScopeRawRef;
15use docbox_database::models::file::FileId;
16use docbox_database::models::{
17 document_box::DocumentBoxScopeRaw, folder::FolderId, tenant::Tenant,
18};
19use opensearch::indices::IndicesGetParts;
20use opensearch::{
21 DeleteByQueryParts, OpenSearch, SearchParts,
22 http::{
23 Url,
24 request::JsonBody,
25 transport::{SingleNodeConnectionPool, TransportBuilder},
26 },
27 indices::{IndicesCreateParts, IndicesDeleteParts},
28};
29use reqwest::StatusCode;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32use serde_with::skip_serializing_none;
33use uuid::Uuid;
34
35pub use error::{OpenSearchIndexFactoryError, OpenSearchSearchError};
36
37pub mod error;
38mod models;
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct OpenSearchConfig {
42 pub url: String,
44}
45
46impl OpenSearchConfig {
47 pub fn from_env() -> Result<Self, OpenSearchIndexFactoryError> {
48 let url = std::env::var("OPENSEARCH_URL")
49 .or(std::env::var("DOCBOX_OPENSEARCH_URL"))
50 .map_err(|_| OpenSearchIndexFactoryError::MissingUrl)?;
51 Ok(Self { url })
52 }
53}
54
55#[derive(Clone)]
56pub struct OpenSearchIndexFactory {
57 client: OpenSearch,
58}
59
60impl OpenSearchIndexFactory {
61 pub fn from_config(
62 aws_config: &SdkConfig,
63 config: OpenSearchConfig,
64 ) -> Result<Self, OpenSearchIndexFactoryError> {
65 let url = reqwest::Url::parse(&config.url).map_err(|error| {
66 tracing::error!(?error, "failed to parse opensearch url");
67 OpenSearchIndexFactoryError::InvalidUrl
68 })?;
69 let client = create_open_search(aws_config, url)?;
70 Ok(Self { client })
71 }
72
73 pub fn create_search_index(&self, search_index: TenantSearchIndexName) -> OpenSearchIndex {
74 OpenSearchIndex {
75 client: self.client.clone(),
76 search_index,
77 }
78 }
79}
80
81#[derive(Clone)]
82pub struct OpenSearchIndex {
83 client: OpenSearch,
84 search_index: TenantSearchIndexName,
85}
86
87#[derive(Clone, Debug)]
89pub struct TenantSearchIndexName(String);
90
91impl TenantSearchIndexName {
92 pub fn from_tenant(tenant: &Tenant) -> Self {
93 Self(tenant.os_index_name.clone())
94 }
95}
96
97pub fn create_open_search(
99 aws_config: &SdkConfig,
100 url: Url,
101) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
102 if cfg!(debug_assertions) {
103 create_open_search_dev(url)
104 } else {
105 create_open_search_prod(aws_config, url)
106 }
107}
108
109pub fn create_open_search_dev(url: Url) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
111 let conn_pool = SingleNodeConnectionPool::new(url);
112
113 let transport = TransportBuilder::new(conn_pool)
114 .disable_proxy()
116 .cert_validation(opensearch::cert::CertificateValidation::None)
118 .build()
119 .map_err(|error| {
120 tracing::error!(?error, "failed to build opensearch transport");
121 OpenSearchIndexFactoryError::BuildTransport
122 })?;
123
124 let open_search = OpenSearch::new(transport);
125
126 Ok(open_search)
127}
128
129pub fn create_open_search_prod(
131 aws_config: &SdkConfig,
132 url: Url,
133) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
134 let conn_pool = SingleNodeConnectionPool::new(url);
136
137 let transport = TransportBuilder::new(conn_pool)
138 .disable_proxy()
140 .auth(aws_config.clone().try_into().map_err(|error| {
141 tracing::error!(?error, "failed to create opensearch transport auth config");
142 OpenSearchIndexFactoryError::CreateAuthConfig
143 })?)
144 .service_name("es")
145 .build()
146 .map_err(|error| {
147 tracing::error!(?error, "failed to build opensearch transport");
148 OpenSearchIndexFactoryError::BuildTransport
149 })?;
150
151 let open_search = OpenSearch::new(transport);
152
153 Ok(open_search)
154}
155
156impl SearchIndex for OpenSearchIndex {
157 async fn create_index(&self) -> Result<(), SearchError> {
158 let response = self
160 .client
161 .indices()
162 .create(IndicesCreateParts::Index(&self.search_index.0))
163 .body(json!({
164 "settings": {
165 "analysis": {
166 "tokenizer": {
167 "edge_ngram_tokenizer": {
168 "type": "edge_ngram",
169 "min_gram": 1,
170 "max_gram": 25,
171 "token_chars": [
172 "letter",
173 "digit"
174 ]
175 }
176 },
177 "analyzer": {
178 "edge_ngram_analyzer": {
179 "type": "custom",
180 "tokenizer": "edge_ngram_tokenizer"
181 }
182 }
183 }
184 },
185 "mappings" : {
186 "properties" : {
187 "item_id": { "type": "keyword" },
189 "item_type": { "type": "keyword" },
191 "mime": { "type": "keyword" },
193 "name" : { "type" : "text", "analyzer": "edge_ngram_analyzer" },
195 "content" : { "type" : "text" },
197 "created_at": { "type": "date", "format": "rfc3339_lenient" },
199 "created_by": { "type": "keyword" },
201 "folder_id": { "type": "keyword" },
203 "document_box": { "type": "keyword" },
205 "version": {
207 "type": "keyword"
208 },
209 "pages": {
211 "type": "nested",
212 "properties": {
213 "content" : { "type" : "text" },
215 "page": { "type": "integer" },
217 }
218 }
219 }
220 }
221 }))
222 .send()
223 .await
224 .map_err(|error| {
225 tracing::error!(?error, "failed to create index");
226 OpenSearchSearchError::CreateIndex
227 })?;
228
229 tracing::debug!("open search response {response:?}");
230
231 Ok(())
232 }
233
234 async fn index_exists(&self) -> Result<bool, SearchError> {
235 let response = self
237 .client
238 .indices()
239 .get(IndicesGetParts::Index(&[&self.search_index.0]))
240 .send()
241 .await
242 .map_err(|error| {
243 tracing::error!(?error, "failed to get index");
244 OpenSearchSearchError::GetIndex
245 })?;
246
247 if response.status_code() == StatusCode::NOT_FOUND {
248 return Ok(false);
249 }
250
251 response.error_for_status_code().map_err(|error| {
252 tracing::error!(?error, "failed to get index");
253 OpenSearchSearchError::GetIndex
254 })?;
255
256 Ok(true)
257 }
258
259 async fn delete_index(&self) -> Result<(), SearchError> {
260 let response = self
262 .client
263 .indices()
264 .delete(IndicesDeleteParts::Index(&[&self.search_index.0]))
265 .send()
266 .await
267 .map_err(|error| {
268 tracing::error!(?error, "failed to delete index");
269 OpenSearchSearchError::DeleteIndex
270 })?;
271
272 if response.status_code() == StatusCode::NOT_FOUND {
274 return Ok(());
275 }
276
277 response.error_for_status_code().map_err(|error| {
278 tracing::error!(?error, "failed to delete search index (response)");
279 OpenSearchSearchError::DeleteIndex
280 })?;
281
282 Ok(())
283 }
284
285 async fn search_index_file(
286 &self,
287 scope: &DocumentBoxScopeRaw,
288 file_id: docbox_database::models::file::FileId,
289 query: super::models::FileSearchRequest,
290 ) -> Result<FileSearchResults, SearchError> {
291 let offset = query.offset;
292 let query = create_opensearch_file_query(query, scope, file_id);
293
294 tracing::debug!(%query, "searching with query");
295
296 let response = self
298 .client
299 .search(SearchParts::Index(&[&self.search_index.0]))
300 .from(offset.unwrap_or(0) as i64)
301 .body(query)
302 .send()
303 .await
304 .map_err(|error| {
305 tracing::error!(?error, "failed to search index file");
306 OpenSearchSearchError::SearchIndex
307 })?;
308
309 let response: serde_json::Value = response.json().await.map_err(|error| {
310 tracing::error!(?error, "failed to get file search response");
311 OpenSearchSearchError::SearchIndex
312 })?;
313
314 tracing::debug!(%response, "search response");
315
316 let response: SearchResponse = serde_json::from_value(response).map_err(|error| {
317 tracing::error!(?error, "failed to parse file search response");
318 OpenSearchSearchError::SearchIndex
319 })?;
320
321 let (total_hits, results) = response
322 .hits
323 .hits
324 .into_iter()
325 .next()
326 .and_then(|item| item.inner_hits)
327 .map(|inner_hits| {
328 let total_hits = inner_hits.pages.hits.total.value;
329 let page_matches: Vec<PageResult> = inner_hits
330 .pages
331 .hits
332 .hits
333 .into_iter()
334 .map(|value| PageResult {
335 page: value._source.page,
336 matches: value.highlight.content,
337 })
338 .collect();
339 (total_hits, page_matches)
340 })
341 .unwrap_or_default();
342
343 Ok(FileSearchResults {
344 total_hits,
345 results,
346 })
347 }
348
349 async fn search_index(
350 &self,
351 scope: &[DocumentBoxScopeRaw],
352 query: SearchRequest,
353 folder_children: Option<Vec<FolderId>>,
354 ) -> Result<SearchResults, SearchError> {
355 let offset = query.offset;
356 let query = create_opensearch_query(query, scope, folder_children);
357
358 tracing::debug!(%query, "searching with query");
359
360 let response = self
362 .client
363 .search(SearchParts::Index(&[&self.search_index.0]))
364 .from(offset.unwrap_or(0) as i64)
365 .body(query)
366 .send()
367 .await
368 .map_err(|error| {
369 tracing::error!(?error, "failed to search index");
370 OpenSearchSearchError::SearchIndex
371 })?;
372
373 let response: serde_json::Value = response.json().await.map_err(|error| {
374 tracing::error!(?error, "failed to get search response");
375 OpenSearchSearchError::SearchIndex
376 })?;
377
378 tracing::debug!(%response);
379
380 let response: SearchResponse = serde_json::from_value(response).map_err(|error| {
381 tracing::error!(?error, "failed to parse search response");
382 OpenSearchSearchError::SearchIndex
383 })?;
384 let total_hits = response.hits.total.value;
385
386 const NAME_MATCH_KEYS: [&str; 2] = ["name_match_exact", "name_match_wildcard"];
387
388 let results: Vec<FlattenedItemResult> = response
389 .hits
390 .hits
391 .into_iter()
392 .map(|item| {
393 let (total_hits, page_matches) = match item.inner_hits {
394 Some(inner_hits) => {
395 let total_hits = inner_hits.pages.hits.total.value;
396 let page_matches: Vec<PageResult> = inner_hits
397 .pages
398 .hits
399 .hits
400 .into_iter()
401 .map(|value| PageResult {
402 page: value._source.page,
403 matches: value.highlight.content,
404 })
405 .collect();
406 (total_hits, page_matches)
407 }
408 None => (0, vec![]),
409 };
410
411 let name_match = item.matched_queries.is_some_and(|matches| {
412 matches
413 .iter()
414 .any(|value| NAME_MATCH_KEYS.contains(&value.as_str()))
415 });
416 let content_match = !page_matches.is_empty();
417
418 FlattenedItemResult {
419 item_ty: item._source.item_type,
420 item_id: item._source.item_id,
421 document_box: item._source.document_box,
422 score: SearchScore::Float(item._score),
423 page_matches,
424 total_hits,
425 name_match,
426 content_match,
427 }
428 })
429 .collect();
430
431 Ok(SearchResults {
432 total_hits,
433 results,
434 })
435 }
436
437 async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
438 let mapped_data: Vec<JsonBody<OsSearchIndexData>> = data
439 .into_iter()
440 .map(|data| {
441 JsonBody::new(OsSearchIndexData {
442 ty: data.ty,
443 folder_id: data.folder_id,
444 document_box: data.document_box,
445 item_id: data.item_id,
446 name: data.name,
447 mime: data.mime,
448 content: data.content,
449 created_at: data.created_at.to_rfc3339(),
450 created_by: data.created_by,
451 pages: data.pages,
452 })
453 })
454 .collect();
455
456 let result = self
458 .client
459 .bulk(opensearch::BulkParts::Index(&self.search_index.0))
461 .body(mapped_data)
462 .send()
463 .await
464 .map_err(|error| {
465 tracing::error!(?error, "failed to bulk add data");
466 OpenSearchSearchError::AddData
467 })?;
468
469 let status_code = result.status_code();
470
471 let response = result.text().await.map_err(|error| {
472 tracing::error!(?error, "failed to get bulk add response");
473 OpenSearchSearchError::AddData
474 })?;
475
476 if status_code.is_client_error() || status_code.is_server_error() {
477 tracing::error!(?response, "bulk add error response");
478 return Err(OpenSearchSearchError::AddData.into());
479 }
480 Ok(())
481 }
482
483 async fn update_data(
484 &self,
485 item_id: Uuid,
486 data: UpdateSearchIndexData,
487 ) -> Result<(), SearchError> {
488 let data = OsUpdateSearchIndexData {
489 folder_id: data.folder_id,
490 name: data.name,
491 content: data.content,
492 pages: data.pages,
493 };
494
495 let items = self.get_by_item_id(item_id).await.map_err(|error| {
496 tracing::error!(?error, "failed to find items to update");
497 OpenSearchSearchError::UpdateData
498 })?;
499
500 if items.is_empty() {
502 return Ok(());
503 }
504
505 #[derive(Serialize)]
507 enum BulkUpdateEntry<'a> {
508 #[serde(rename = "update")]
510 Update {
511 _id: String,
513 },
514 #[serde(rename = "doc")]
516 Document {
517 #[serde(flatten)]
518 data: &'a OsUpdateSearchIndexData,
519 },
520 }
521
522 let updates: Vec<JsonBody<BulkUpdateEntry<'_>>> = items
524 .into_iter()
525 .flat_map(|_id| {
526 [
527 BulkUpdateEntry::Update { _id },
528 BulkUpdateEntry::Document { data: &data },
529 ]
530 })
531 .map(JsonBody::new)
532 .collect();
533
534 let result = self
536 .client
537 .bulk(opensearch::BulkParts::Index(&self.search_index.0))
538 .body(updates)
539 .send()
540 .await
541 .map_err(|error| {
542 tracing::error!(?error, "failed to update data (request)");
543 OpenSearchSearchError::UpdateData
544 })?;
545
546 let status_code = result.status_code();
547 let response: serde_json::Value = result.json().await.map_err(|error| {
548 tracing::error!(?error, "failed to update data (response)");
549 OpenSearchSearchError::UpdateData
550 })?;
551
552 tracing::debug!(?response, "search index update response");
553
554 if status_code.is_client_error() || status_code.is_server_error() {
555 tracing::error!(?response, "update data error response");
556 return Err(OpenSearchSearchError::UpdateData.into());
557 }
558
559 Ok(())
560 }
561
562 async fn delete_data(&self, item_id: Uuid) -> Result<(), SearchError> {
563 self.client
564 .delete_by_query(DeleteByQueryParts::Index(&[&self.search_index.0]))
565 .body(json!({
566 "query": {
567 "term": { "item_id": item_id }
568 }
569 }))
570 .send()
571 .await
572 .map_err(|error| {
573 tracing::error!(?error, "failed to delete data");
574 OpenSearchSearchError::DeleteData
575 })?;
576
577 Ok(())
578 }
579
580 async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError> {
581 self.client
582 .delete_by_query(DeleteByQueryParts::Index(&[&self.search_index.0]))
583 .body(json!({
584 "query": {
585 "term": { "document_box": scope }
586 }
587 }))
588 .send()
589 .await
590 .map_err(|error| {
591 tracing::error!(?error, "failed to delete data by scope");
592 OpenSearchSearchError::DeleteData
593 })?;
594
595 Ok(())
596 }
597
598 async fn get_pending_migrations(
599 &self,
600 _applied_names: Vec<String>,
601 ) -> Result<Vec<String>, SearchError> {
602 Ok(Vec::new())
603 }
604
605 async fn apply_migration(
606 &self,
607 _tenant: &Tenant,
608 _root_t: &mut DbTransaction<'_>,
609 _t: &mut DbTransaction<'_>,
610 _name: &str,
611 ) -> Result<(), SearchError> {
612 Ok(())
613 }
614}
615
616impl OpenSearchIndex {
617 async fn get_by_item_id(&self, item_id: Uuid) -> Result<Vec<String>, OpenSearchSearchError> {
619 #[derive(Debug, Deserialize, Serialize)]
620 struct Response {
621 hits: Hits,
622 }
623
624 #[derive(Debug, Deserialize, Serialize)]
625 struct Hits {
626 hits: Vec<Hit>,
627 }
628
629 #[derive(Debug, Deserialize, Serialize)]
630 struct Hit {
631 _id: String,
632 }
633
634 let response = self
636 .client
637 .search(SearchParts::Index(&[&self.search_index.0]))
638 .from(0)
639 .size(10)
640 .body(json!({
641 "query": {
642 "term": { "item_id": item_id }
643 },
644 }))
645 .send()
646 .await
647 .map_err(|error| {
648 tracing::error!(?error, "failed to get search item by id");
649 OpenSearchSearchError::SearchIndex
650 })?;
651
652 let response: Response = response.json().await.map_err(|error| {
653 tracing::error!(?error, "failed to parse search item by id response");
654 OpenSearchSearchError::SearchIndex
655 })?;
656
657 Ok(response.hits.hits.into_iter().map(|hit| hit._id).collect())
658 }
659}
660
661#[skip_serializing_none]
663#[derive(Serialize)]
664struct DateRange {
665 gte: Option<String>,
666 lte: Option<String>,
667}
668
669pub fn create_opensearch_query(
670 req: SearchRequest,
671 scopes: &[DocumentBoxScopeRaw],
672 folder_children: Option<Vec<FolderId>>,
673) -> serde_json::Value {
674 let mut filters = vec![];
675 let mut should = Vec::new();
676
677 filters.push(json!({
679 "terms": { "document_box": scopes }
680 }));
681
682 let query = req
683 .query
684 .filter(|value| !value.is_empty());
686
687 if let Some(ref query) = query {
688 if req.include_name {
689 should.push(json!({
691 "term": {
692 "name": {
693 "value": query,
694 "boost": 2,
695 "_name": "name_match_exact",
696 "case_insensitive": true
697 }
698 }
699 }));
700 should.push(json!({
701 "wildcard": {
702 "name": {
703 "value": format!("*{query}*"),
704 "boost": 1.5,
705 "_name": "name_match_wildcard",
706 "case_insensitive": true
707 }
708 }
709 }));
710 }
711
712 if req.include_content {
713 should.push(json!({
715 "match": {
716 "content": {
717 "query": query,
718 "_name": "content_match"
720 },
721 }
722 }));
723
724 should.push(json!({
726 "nested": {
727 "path": "pages",
728 "query": {
730 "match": {
731 "pages.content": {
732 "query": query,
733 "_name": "content_match"
735 },
736 }
737 },
738 "inner_hits": {
739 "_source": ["pages.page"],
740 "highlight": {
742 "fields": {
743 "pages.content": {
744 "fragment_size": 150,
745 "number_of_fragments": 3,
746 "type": "unified"
747 }
748 }
749 },
750 "sort": [
752 {
753 "_score": {
754 "order": "desc"
755 }
756 }
757 ],
758 "size": req.max_pages.unwrap_or(3),
760 }
761 }
762 }));
763 }
764 }
765
766 if let Some(folder_children) = folder_children {
767 filters.push(json!({
768 "terms": { "folder_id": folder_children }
769 }));
770 }
771
772 if let Some(ref mime) = req.mime {
773 filters.push(json!({
774 "term": { "mime": mime }
775 }));
776 }
777
778 if let Some(ref created_at) = req.created_at {
779 let start = created_at.start.map(|value| value.to_rfc3339());
780 let end = created_at.end.map(|value| value.to_rfc3339());
781
782 if start.is_some() || end.is_some() {
783 filters.push(json!({
784 "range": {
785 "created_at": DateRange {
786 gte: start,
787 lte: end
788 }
789 }
790 }));
791 }
792 }
793
794 if let Some(ref created_by) = req.created_by {
795 filters.push(json!({
796 "term": { "created_by": created_by }
797 }));
798 }
799
800 let minimum_should_match = if !should.is_empty() { 1 } else { 0 };
802
803 json!({
804 "query": {
806 "bool": {
807 "filter": filters,
808 "should": should,
809 "minimum_should_match": minimum_should_match
810 },
811 },
812
813 "size": req.size.unwrap_or(50),
815 "from": req.offset.unwrap_or(0),
817
818 "_source": [
820 "item_id",
821 "item_type",
822 "document_box"
823 ],
824
825 "sort": [
827 {
828 "_score": {
829 "order": "desc"
830 }
831 }
832 ]
833 })
834}
835
836pub fn create_opensearch_file_query(
837 req: FileSearchRequest,
838 scope: &DocumentBoxScopeRaw,
839 file_id: FileId,
840) -> serde_json::Value {
841 let query = req.query.unwrap_or_default();
842
843 json!({
844 "query": {
846 "bool": {
847 "filter": [
848 {
849 "term": { "document_box": scope }
850 },
851 {
852 "term": { "item_id": file_id }
853 }
854 ],
855 "should": [
856 {
857 "nested": {
858 "path": "pages",
859 "query": {
861 "match": {
862 "pages.content": {
863 "query": query,
864 "_name": "content_match"
866 },
867 }
868 },
869 "inner_hits": {
870 "_source": ["pages.page"],
871 "highlight": {
873 "fields": {
874 "pages.content": {
875 "fragment_size": 150,
876 "number_of_fragments": 1,
877 "type": "unified"
878 }
879 }
880 },
881 "sort": [
883 {
884 "_score": {
885 "order": "desc"
886 }
887 }
888 ],
889 "size": req.limit.unwrap_or(3),
891 "from": req.offset.unwrap_or(0),
892 }
893 }
894 }
895 ],
896 "minimum_should_match": 1
897 },
898 },
899
900 "size": 1,
901 "from": 0,
902
903 "_source": [
905 "item_id",
906 "item_type",
907 "document_box"
908 ],
909
910 "sort": [
912 {
913 "_score": {
914 "order": "desc"
915 }
916 }
917 ]
918 })
919}