Skip to main content

ruma_client_api/search/
search_events.rs

1//! `POST /_matrix/client/*/search`
2//!
3//! Search events.
4
5mod result_group_map_serde;
6
7pub mod v3 {
8    //! `/v3/` ([spec])
9    //!
10    //! [spec]: https://spec.matrix.org/v1.19/client-server-api/#post_matrixclientv3search
11
12    use std::{
13        collections::{BTreeMap, btree_map},
14        ops::Deref,
15    };
16
17    use as_variant::as_variant;
18    use js_int::{UInt, uint};
19    use ruma_common::{
20        OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
21        api::{auth_scheme::AccessToken, request, response},
22        metadata,
23        serde::{Raw, StringEnum},
24    };
25    use ruma_events::{AnyStateEvent, AnyTimelineEvent};
26    use serde::{Deserialize, Serialize};
27
28    use crate::{PrivOwnedStr, filter::RoomEventFilter};
29
30    metadata! {
31        method: POST,
32        rate_limited: true,
33        authentication: AccessToken,
34        history: {
35            1.0 => "/_matrix/client/r0/search",
36            1.1 => "/_matrix/client/v3/search",
37        }
38    }
39
40    /// Request type for the `search` endpoint.
41    #[request]
42    pub struct Request {
43        /// The point to return events from.
44        ///
45        /// If given, this should be a `next_batch` result from a previous call to this endpoint.
46        #[ruma_api(query)]
47        pub next_batch: Option<String>,
48
49        /// Describes which categories to search in and their criteria.
50        pub search_categories: Categories,
51    }
52
53    /// Response type for the `search` endpoint.
54    #[response]
55    pub struct Response {
56        /// A grouping of search results by category.
57        pub search_categories: ResultCategories,
58    }
59
60    impl Request {
61        /// Creates a new `Request` with the given categories.
62        pub fn new(search_categories: Categories) -> Self {
63            Self { next_batch: None, search_categories }
64        }
65    }
66
67    impl Response {
68        /// Creates a new `Response` with the given search results.
69        pub fn new(search_categories: ResultCategories) -> Self {
70            Self { search_categories }
71        }
72    }
73
74    /// Categories of events that can be searched for.
75    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
76    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
77    pub struct Categories {
78        /// Criteria for searching room events.
79        #[serde(skip_serializing_if = "Option::is_none")]
80        pub room_events: Option<Criteria>,
81    }
82
83    impl Categories {
84        /// Creates an empty `Categories`.
85        pub fn new() -> Self {
86            Default::default()
87        }
88    }
89
90    /// Criteria for searching a category of events.
91    #[derive(Clone, Debug, Deserialize, Serialize)]
92    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
93    pub struct Criteria {
94        /// The string to search events for.
95        pub search_term: String,
96
97        /// The keys to search for.
98        ///
99        /// Defaults to all keys.
100        #[serde(skip_serializing_if = "Option::is_none")]
101        pub keys: Option<Vec<SearchKeys>>,
102
103        /// A `Filter` to apply to the search.
104        #[serde(default, skip_serializing_if = "RoomEventFilter::is_empty")]
105        pub filter: RoomEventFilter,
106
107        /// The order in which to search for results.
108        #[serde(skip_serializing_if = "Option::is_none")]
109        pub order_by: Option<OrderBy>,
110
111        /// Configures whether any context for the events returned are included in the response.
112        #[serde(default, skip_serializing_if = "EventContext::is_default")]
113        pub event_context: EventContext,
114
115        /// Requests the server return the current state for each room returned.
116        #[serde(skip_serializing_if = "Option::is_none")]
117        pub include_state: Option<bool>,
118
119        /// Requests that the server partitions the result set based on the provided list of keys.
120        #[serde(default, skip_serializing_if = "Groupings::is_empty")]
121        pub groupings: Groupings,
122    }
123
124    impl Criteria {
125        /// Creates a new `Criteria` with the given search term.
126        pub fn new(search_term: String) -> Self {
127            Self {
128                search_term,
129                keys: None,
130                filter: RoomEventFilter::default(),
131                order_by: None,
132                event_context: Default::default(),
133                include_state: None,
134                groupings: Default::default(),
135            }
136        }
137    }
138
139    /// Configures whether any context for the events returned are included in the response.
140    #[derive(Clone, Debug, Deserialize, Serialize)]
141    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
142    pub struct EventContext {
143        /// How many events before the result are returned.
144        #[serde(
145            default = "default_event_context_limit",
146            skip_serializing_if = "is_default_event_context_limit"
147        )]
148        pub before_limit: UInt,
149
150        /// How many events after the result are returned.
151        #[serde(
152            default = "default_event_context_limit",
153            skip_serializing_if = "is_default_event_context_limit"
154        )]
155        pub after_limit: UInt,
156
157        /// Requests that the server returns the historic profile information for the users that
158        /// sent the events that were returned.
159        #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
160        pub include_profile: bool,
161    }
162
163    fn default_event_context_limit() -> UInt {
164        uint!(5)
165    }
166
167    #[allow(clippy::trivially_copy_pass_by_ref)]
168    fn is_default_event_context_limit(val: &UInt) -> bool {
169        *val == default_event_context_limit()
170    }
171
172    impl EventContext {
173        /// Creates an `EventContext` with all-default values.
174        pub fn new() -> Self {
175            Self {
176                before_limit: default_event_context_limit(),
177                after_limit: default_event_context_limit(),
178                include_profile: false,
179            }
180        }
181
182        /// Returns whether all fields have their default value.
183        pub fn is_default(&self) -> bool {
184            self.before_limit == default_event_context_limit()
185                && self.after_limit == default_event_context_limit()
186                && !self.include_profile
187        }
188    }
189
190    impl Default for EventContext {
191        fn default() -> Self {
192            Self::new()
193        }
194    }
195
196    /// Context for search results, if requested.
197    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
198    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
199    pub struct EventContextResult {
200        /// Pagination token for the end of the chunk.
201        #[serde(skip_serializing_if = "Option::is_none")]
202        pub end: Option<String>,
203
204        /// Events just after the result.
205        #[serde(default, skip_serializing_if = "Vec::is_empty")]
206        pub events_after: Vec<Raw<AnyTimelineEvent>>,
207
208        /// Events just before the result.
209        #[serde(default, skip_serializing_if = "Vec::is_empty")]
210        pub events_before: Vec<Raw<AnyTimelineEvent>>,
211
212        /// The historic profile information of the users that sent the events returned.
213        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214        pub profile_info: BTreeMap<OwnedUserId, UserProfile>,
215
216        /// Pagination token for the start of the chunk.
217        #[serde(skip_serializing_if = "Option::is_none")]
218        pub start: Option<String>,
219    }
220
221    impl EventContextResult {
222        /// Creates an empty `EventContextResult`.
223        pub fn new() -> Self {
224            Default::default()
225        }
226
227        /// Returns whether all fields are `None` or an empty list.
228        pub fn is_empty(&self) -> bool {
229            let Self { end, events_after, events_before, profile_info, start } = self;
230            end.is_none()
231                && events_after.is_empty()
232                && events_before.is_empty()
233                && profile_info.is_empty()
234                && start.is_none()
235        }
236    }
237
238    /// A grouping for partitioning the result set.
239    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
240    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
241    pub struct Grouping {
242        /// The key within events to use for this grouping.
243        pub key: Option<GroupingKey>,
244    }
245
246    impl Grouping {
247        /// Creates an empty `Grouping`.
248        pub fn new() -> Self {
249            Default::default()
250        }
251
252        /// Returns whether `key` is `None`.
253        pub fn is_empty(&self) -> bool {
254            let Self { key } = self;
255            key.is_none()
256        }
257    }
258
259    /// The key within events to use for this grouping.
260    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
261    #[derive(Clone, StringEnum)]
262    #[ruma_enum(rename_all = "snake_case")]
263    #[non_exhaustive]
264    pub enum GroupingKey {
265        /// `room_id`
266        RoomId,
267
268        /// `sender`
269        Sender,
270
271        #[doc(hidden)]
272        _Custom(PrivOwnedStr),
273    }
274
275    /// Requests that the server partitions the result set based on the provided list of keys.
276    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
277    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
278    pub struct Groupings {
279        /// List of groups to request.
280        #[serde(default, skip_serializing_if = "<[_]>::is_empty")]
281        pub group_by: Vec<Grouping>,
282    }
283
284    impl Groupings {
285        /// Creates an empty `Groupings`.
286        pub fn new() -> Self {
287            Default::default()
288        }
289
290        /// Returns `true` if all fields are empty.
291        pub fn is_empty(&self) -> bool {
292            let Self { group_by } = self;
293            group_by.is_empty()
294        }
295    }
296
297    /// The keys to search for.
298    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
299    #[derive(Clone, StringEnum)]
300    #[non_exhaustive]
301    pub enum SearchKeys {
302        /// content.body
303        #[ruma_enum(rename = "content.body")]
304        ContentBody,
305
306        /// content.name
307        #[ruma_enum(rename = "content.name")]
308        ContentName,
309
310        /// content.topic
311        #[ruma_enum(rename = "content.topic")]
312        ContentTopic,
313
314        #[doc(hidden)]
315        _Custom(PrivOwnedStr),
316    }
317
318    /// The order in which to search for results.
319    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
320    #[derive(Clone, StringEnum)]
321    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
322    #[ruma_enum(rename_all = "snake_case")]
323    pub enum OrderBy {
324        /// Prioritize recent events.
325        Recent,
326
327        /// Prioritize events by a numerical ranking of how closely they matched the search
328        /// criteria.
329        Rank,
330
331        #[doc(hidden)]
332        _Custom(PrivOwnedStr),
333    }
334
335    /// Categories of events that can be searched for.
336    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
337    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
338    pub struct ResultCategories {
339        /// Room event results.
340        #[serde(default, skip_serializing_if = "ResultRoomEvents::is_empty")]
341        pub room_events: ResultRoomEvents,
342    }
343
344    impl ResultCategories {
345        /// Creates an empty `ResultCategories`.
346        pub fn new() -> Self {
347            Default::default()
348        }
349    }
350
351    /// Categories of events that can be searched for.
352    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
353    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
354    pub struct ResultRoomEvents {
355        /// An approximate count of the total number of results found.
356        #[serde(skip_serializing_if = "Option::is_none")]
357        pub count: Option<UInt>,
358
359        /// Any groups that were requested.
360        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
361        pub groups: ResultGroupMapsByGroupingKey,
362
363        /// Token that can be used to get the next batch of results, by passing as the `next_batch`
364        /// parameter to the next call.
365        ///
366        /// If this field is absent, there are no more results.
367        #[serde(skip_serializing_if = "Option::is_none")]
368        pub next_batch: Option<String>,
369
370        /// List of results in the requested order.
371        #[serde(default, skip_serializing_if = "Vec::is_empty")]
372        pub results: Vec<SearchResult>,
373
374        /// The current state for every room in the results.
375        ///
376        /// This is included if the request had the `include_state` key set with a value of `true`.
377        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
378        pub state: BTreeMap<OwnedRoomId, Vec<Raw<AnyStateEvent>>>,
379
380        /// List of words which should be highlighted, useful for stemming which may
381        /// change the query terms.
382        #[serde(default, skip_serializing_if = "Vec::is_empty")]
383        pub highlights: Vec<String>,
384    }
385
386    impl ResultRoomEvents {
387        /// Creates an empty `ResultRoomEvents`.
388        pub fn new() -> Self {
389            Default::default()
390        }
391
392        /// Returns `true` if all fields are empty / `None`.
393        pub fn is_empty(&self) -> bool {
394            let Self { count, groups, next_batch, results, state, highlights } = self;
395            count.is_none()
396                && groups.is_empty()
397                && next_batch.is_none()
398                && results.is_empty()
399                && state.is_empty()
400                && highlights.is_empty()
401        }
402    }
403
404    /// A map of [`GroupingKey`] to the associated [`ResultGroupMap`].
405    ///
406    /// This type is used to ensure that a supported [`ResultGroupMap`] always uses the appropriate
407    /// [`GroupingKey`].
408    #[derive(Clone, Debug, Default)]
409    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
410    pub struct ResultGroupMapsByGroupingKey(BTreeMap<GroupingKey, ResultGroupMap>);
411
412    impl ResultGroupMapsByGroupingKey {
413        /// Construct an empty `ResultGroupMapsByGroupingKey`.
414        pub fn new() -> Self {
415            Self::default()
416        }
417
418        /// Insert the given [`ResultGroupMap`].
419        ///
420        /// If a map with the same [`GroupingKey`] was already present, it is returned.
421        pub fn insert(&mut self, map: ResultGroupMap) -> Option<ResultGroupMap> {
422            self.0.insert(map.grouping_key(), map)
423        }
424    }
425
426    impl Deref for ResultGroupMapsByGroupingKey {
427        type Target = BTreeMap<GroupingKey, ResultGroupMap>;
428
429        fn deref(&self) -> &Self::Target {
430            &self.0
431        }
432    }
433
434    impl FromIterator<ResultGroupMap> for ResultGroupMapsByGroupingKey {
435        fn from_iter<T: IntoIterator<Item = ResultGroupMap>>(iter: T) -> Self {
436            Self(iter.into_iter().map(|map| (map.grouping_key(), map)).collect())
437        }
438    }
439
440    impl Extend<ResultGroupMap> for ResultGroupMapsByGroupingKey {
441        fn extend<T: IntoIterator<Item = ResultGroupMap>>(&mut self, iter: T) {
442            self.0.extend(iter.into_iter().map(|map| (map.grouping_key(), map)));
443        }
444    }
445
446    impl IntoIterator for ResultGroupMapsByGroupingKey {
447        type Item = ResultGroupMap;
448        type IntoIter = btree_map::IntoValues<GroupingKey, ResultGroupMap>;
449
450        fn into_iter(self) -> Self::IntoIter {
451            self.0.into_values()
452        }
453    }
454
455    /// A map of results grouped by key.
456    #[derive(Clone, Debug)]
457    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
458    pub enum ResultGroupMap {
459        /// Results grouped by room ID.
460        RoomId(BTreeMap<OwnedRoomId, ResultGroup>),
461
462        /// Results grouped by sender.
463        Sender(BTreeMap<OwnedUserId, ResultGroup>),
464
465        #[doc(hidden)]
466        _Custom(CustomResultGroupMap),
467    }
468
469    impl ResultGroupMap {
470        /// The key that was used to group this map.
471        pub fn grouping_key(&self) -> GroupingKey {
472            match self {
473                Self::RoomId(_) => GroupingKey::RoomId,
474                Self::Sender(_) => GroupingKey::Sender,
475                Self::_Custom(custom) => custom.grouping_key.as_str().into(),
476            }
477        }
478
479        /// The map of grouped results, if this uses a custom key.
480        pub fn custom_map(&self) -> Option<&BTreeMap<String, ResultGroup>> {
481            as_variant!(self, Self::_Custom).map(|custom| &custom.map)
482        }
483
484        /// Convert this into the map of grouped results, if this uses a custom key.
485        pub fn into_custom_map(self) -> Option<BTreeMap<String, ResultGroup>> {
486            as_variant!(self, Self::_Custom).map(|custom| custom.map)
487        }
488    }
489
490    /// A map of results grouped by custom key type.
491    #[doc(hidden)]
492    #[derive(Clone, Debug)]
493    pub struct CustomResultGroupMap {
494        /// The type of key that was used to group the results.
495        pub(super) grouping_key: String,
496
497        /// The grouped results.
498        pub(super) map: BTreeMap<String, ResultGroup>,
499    }
500
501    /// A group of results.
502    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
503    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
504    pub struct ResultGroup {
505        /// Token that can be used to get the next batch of results in the group, by passing as the
506        /// `next_batch` parameter to the next call.
507        ///
508        /// If this field is absent, there are no more results in this group.
509        #[serde(skip_serializing_if = "Option::is_none")]
510        pub next_batch: Option<String>,
511
512        /// Key that can be used to order different groups.
513        #[serde(skip_serializing_if = "Option::is_none")]
514        pub order: Option<UInt>,
515
516        /// Which results are in this group.
517        #[serde(default, skip_serializing_if = "Vec::is_empty")]
518        pub results: Vec<OwnedEventId>,
519    }
520
521    impl ResultGroup {
522        /// Creates an empty `ResultGroup`.
523        pub fn new() -> Self {
524            Default::default()
525        }
526
527        /// Returns `true` if all fields are empty / `None`.
528        pub fn is_empty(&self) -> bool {
529            let Self { next_batch, order, results } = self;
530            next_batch.is_none() && order.is_none() && results.is_empty()
531        }
532    }
533
534    /// A search result.
535    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
536    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
537    pub struct SearchResult {
538        /// Context for result, if requested.
539        #[serde(default, skip_serializing_if = "EventContextResult::is_empty")]
540        pub context: EventContextResult,
541
542        /// A number that describes how closely this result matches the search.
543        ///
544        /// Higher is closer.
545        #[serde(skip_serializing_if = "Option::is_none")]
546        pub rank: Option<f64>,
547
548        /// The event that matched.
549        #[serde(skip_serializing_if = "Option::is_none")]
550        pub result: Option<Raw<AnyTimelineEvent>>,
551    }
552
553    impl SearchResult {
554        /// Creates an empty `SearchResult`.
555        pub fn new() -> Self {
556            Default::default()
557        }
558
559        /// Returns `true` if all fields are empty / `None`.
560        pub fn is_empty(&self) -> bool {
561            let Self { context, rank, result } = self;
562            context.is_empty() && rank.is_none() && result.is_none()
563        }
564    }
565
566    /// A user profile.
567    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
568    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
569    pub struct UserProfile {
570        /// The user's avatar URL, if set.
571        ///
572        /// If you activate the `compat-empty-string-null` feature, this field being an empty
573        /// string in JSON will result in `None` here during deserialization.
574        #[serde(skip_serializing_if = "Option::is_none")]
575        #[cfg_attr(
576            feature = "compat-empty-string-null",
577            serde(default, deserialize_with = "ruma_common::serde::empty_string_as_none")
578        )]
579        pub avatar_url: Option<OwnedMxcUri>,
580
581        /// The user's display name, if set.
582        #[serde(skip_serializing_if = "Option::is_none")]
583        pub displayname: Option<String>,
584    }
585
586    impl UserProfile {
587        /// Creates an empty `UserProfile`.
588        pub fn new() -> Self {
589            Default::default()
590        }
591
592        /// Returns `true` if all fields are `None`.
593        pub fn is_empty(&self) -> bool {
594            let Self { avatar_url, displayname } = self;
595            avatar_url.is_none() && displayname.is_none()
596        }
597    }
598}
599
600#[cfg(all(test, feature = "client", feature = "server"))]
601mod tests {
602    use std::{borrow::Cow, collections::BTreeMap};
603
604    use assert_matches2::assert_matches;
605    use js_int::uint;
606    use ruma_common::{
607        api::{
608            IncomingRequest, IncomingResponse, OutgoingRequestExt as _, OutgoingResponse,
609            SupportedVersions, auth_scheme::SendAccessToken,
610        },
611        event_id, room_id,
612    };
613    use serde_json::{
614        Value as JsonValue, from_slice as from_json_slice, json, to_vec as to_json_vec,
615    };
616
617    use super::v3::{GroupingKey, OrderBy, Request, Response, ResultGroupMap, SearchKeys};
618
619    #[test]
620    fn request_roundtrip() {
621        let body = json!({
622            "search_categories": {
623                "room_events": {
624                    "groupings": {
625                        "group_by": [
626                            { "key": "room_id" },
627                        ],
628                    },
629                    "keys": ["content.body"],
630                    "order_by": "recent",
631                    "search_term": "martians and men"
632                }
633            }
634        });
635
636        let http_request = http::Request::post("http://localhost/_matrix/client/v3/search")
637            .body(to_json_vec(&body).unwrap())
638            .unwrap();
639        let request = Request::try_from_http_request(http_request, &[] as &[&str]).unwrap();
640
641        let criteria = request.search_categories.room_events.as_ref().unwrap();
642        assert_eq!(criteria.groupings.group_by.len(), 1);
643        assert_eq!(criteria.groupings.group_by[0].key, Some(GroupingKey::RoomId));
644        let keys = criteria.keys.as_ref().unwrap();
645        assert_eq!(keys.len(), 1);
646        assert_eq!(keys[0], SearchKeys::ContentBody);
647        assert_eq!(criteria.order_by, Some(OrderBy::Recent));
648        assert_eq!(criteria.search_term, "martians and men");
649
650        let http_request = request
651            .try_into_http_request::<Vec<u8>>(
652                "http://localhost",
653                SendAccessToken::IfRequired("access_token"),
654                Cow::Owned(SupportedVersions::from_parts(&["v1.4".to_owned()], &BTreeMap::new())),
655            )
656            .unwrap();
657        assert_eq!(from_json_slice::<JsonValue>(http_request.body()).unwrap(), body);
658    }
659
660    #[test]
661    fn response_roundtrip() {
662        let body = json!({
663            "search_categories": {
664                "room_events": {
665                    "count": 1224,
666                    "groups": {
667                        "room_id": {
668                            "!qPewotXpIctQySfjSy:localhost": {
669                                "next_batch": "BdgFsdfHSf-dsFD",
670                                "order": 1,
671                                "results": ["$144429830826TWwbB:localhost"],
672                            },
673                        },
674                    },
675                    "highlights": [
676                        "martians",
677                        "men",
678                    ],
679                    "next_batch": "5FdgFsd234dfgsdfFD",
680                    "results": [
681                        {
682                            "rank": 0.004_248_66,
683                            "result": {
684                                "content": {
685                                    "body": "This is an example text message",
686                                    "format": "org.matrix.custom.html",
687                                    "formatted_body": "<b>This is an example text message</b>",
688                                    "msgtype": "m.text",
689                                },
690                                "event_id": "$144429830826TWwbB:localhost",
691                                "origin_server_ts": 1_735_824_653,
692                                "room_id": "!qPewotXpIctQySfjSy:localhost",
693                                "sender": "@example:example.org",
694                                "type": "m.room.message",
695                                "unsigned": {
696                                    "age": 1234,
697                                    "membership": "join",
698                                }
699                            }
700                        }
701                    ]
702                }
703            }
704        });
705        let result_event_id = event_id!("$144429830826TWwbB:localhost");
706
707        let http_request = http::Response::new(to_json_vec(&body).unwrap());
708        let response = Response::try_from_http_response(http_request).unwrap();
709
710        let results = &response.search_categories.room_events;
711        assert_eq!(results.count, Some(uint!(1224)));
712        assert_eq!(results.groups.len(), 1);
713        assert_matches!(
714            results.groups.get(&GroupingKey::RoomId),
715            Some(ResultGroupMap::RoomId(room_id_group_map))
716        );
717        assert_eq!(room_id_group_map.len(), 1);
718        let room_id_group =
719            room_id_group_map.get(room_id!("!qPewotXpIctQySfjSy:localhost")).unwrap();
720        assert_eq!(room_id_group.results, &[result_event_id]);
721        assert_eq!(results.highlights, &["martians", "men"]);
722        assert_eq!(results.next_batch.as_deref(), Some("5FdgFsd234dfgsdfFD"));
723        assert_eq!(results.results.len(), 1);
724        assert_eq!(results.results[0].rank, Some(0.004_248_66));
725        let result = results.results[0].result.as_ref().unwrap().deserialize().unwrap();
726        assert_eq!(result.event_id(), result_event_id);
727
728        let http_response = response.try_into_http_response::<Vec<u8>>().unwrap();
729        assert_eq!(from_json_slice::<JsonValue>(http_response.body()).unwrap(), body);
730    }
731}