diff --git a/changelog.d/2-features/pr-2895 b/changelog.d/2-features/pr-2895 new file mode 100644 index 00000000000..61009f6497b --- /dev/null +++ b/changelog.d/2-features/pr-2895 @@ -0,0 +1 @@ +Team search endpoint now supports pagination diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 5a2999282e0..e7f9a2d7a7a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -68,7 +68,7 @@ import Wire.API.User.Client.Prekey import Wire.API.User.Handle import Wire.API.User.Password (CompletePasswordReset, NewPasswordReset, PasswordReset, PasswordResetKey) import Wire.API.User.RichInfo (RichInfoAssocList) -import Wire.API.User.Search (Contact, RoleFilter, SearchResult, TeamContact, TeamUserSearchSortBy, TeamUserSearchSortOrder) +import Wire.API.User.Search (Contact, PagingState, RoleFilter, SearchResult, TeamContact, TeamUserSearchSortBy, TeamUserSearchSortOrder) import Wire.API.UserMap type BrigAPI = @@ -1159,6 +1159,13 @@ type SearchAPI = ] "size" (Range 1 500 Int32) + :> QueryParam' + [ Optional, + Strict, + Description "Paging state for the next page of results" + ] + "pagingState" + PagingState :> MultiVerb 'GET '[JSON] diff --git a/libs/wire-api/src/Wire/API/User/Search.hs b/libs/wire-api/src/Wire/API/User/Search.hs index 86732ebc52b..db169456c24 100644 --- a/libs/wire-api/src/Wire/API/User/Search.hs +++ b/libs/wire-api/src/Wire/API/User/Search.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} @@ -28,6 +29,7 @@ module Wire.API.User.Search TeamUserSearchSortOrder (..), TeamUserSearchSortBy (..), FederatedUserSearchPolicy (..), + PagingState (..), -- * Swagger modelSearchResult, @@ -43,22 +45,51 @@ import qualified Data.Aeson as Aeson import Data.Attoparsec.ByteString (sepBy) import Data.Attoparsec.ByteString.Char8 (char, string) import Data.ByteString.Conversion (FromByteString (..), ToByteString (..)) +import Data.Either.Combinators (mapLeft) import Data.Id (TeamId, UserId) import Data.Json.Util (UTCTimeMillis) import Data.Proxy import Data.Qualified import Data.Schema +import Data.String.Conversions (cs) +import Data.Swagger (ToParamSchema (..)) import qualified Data.Swagger as S import qualified Data.Swagger.Build.Api as Doc import qualified Data.Text as T +import Data.Text.Ascii (AsciiBase64Url, toText, validateBase64Url) import Imports -import Servant.API (FromHttpApiData) +import Servant.API (FromHttpApiData, ToHttpApiData (..)) import Web.Internal.HttpApiData (parseQueryParam) import Wire.API.Team.Role (Role) import Wire.API.User (ManagedBy) import Wire.API.User.Identity (Email (..)) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) +------------------------------------------------------------------------------- +-- PagingState + +newtype PagingState = PagingState {unPagingState :: AsciiBase64Url} + deriving newtype (Eq, Show, Arbitrary) + deriving (ToJSON, FromJSON, S.ToSchema) via Schema PagingState + +instance ToSchema PagingState where + schema = (toText . unPagingState) .= parsedText "PagingState" (fmap PagingState . validateBase64Url) + +instance ToParamSchema PagingState where + toParamSchema _ = toParamSchema (Proxy @Text) + +instance FromHttpApiData PagingState where + parseQueryParam s = mapLeft cs $ PagingState <$> validateBase64Url s + +instance ToHttpApiData PagingState where + toQueryParam = toText . unPagingState + +instance ToByteString PagingState where + builder = builder . unPagingState + +instance FromByteString PagingState where + parser = fmap PagingState parser + -------------------------------------------------------------------------------- -- SearchResult @@ -67,7 +98,8 @@ data SearchResult a = SearchResult searchReturned :: Int, searchTook :: Int, searchResults :: [a], - searchPolicy :: FederatedUserSearchPolicy + searchPolicy :: FederatedUserSearchPolicy, + searchPagingState :: Maybe PagingState } deriving stock (Eq, Show, Generic, Functor) deriving (Arbitrary) via (GenericUniform (SearchResult a)) @@ -89,6 +121,7 @@ instance ToSchema a => ToSchema (SearchResult a) where <*> searchTook .= fieldWithDocModifier "took" (S.description ?~ "Search time in ms") schema <*> searchResults .= fieldWithDocModifier "documents" (S.description ?~ "List of contacts found") (array schema) <*> searchPolicy .= fieldWithDocModifier "search_policy" (S.description ?~ "Search policy that was applied when searching for users") schema + <*> searchPagingState .= maybe_ (optFieldWithDocModifier "paging_state" (S.description ?~ "Paging state for the next page of results") schema) deriving via (Schema (SearchResult Contact)) instance ToJSON (SearchResult Contact) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20Contact_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20Contact_user.hs index c90c81dc253..f05a1f39975 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20Contact_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20Contact_user.hs @@ -24,15 +24,29 @@ import Data.Id (Id (Id)) import Data.Qualified (Qualified (Qualified, qDomain, qUnqualified)) import qualified Data.UUID as UUID (fromString) import Imports (Maybe (Just, Nothing), fromJust) -import Wire.API.User.Search (Contact (..), FederatedUserSearchPolicy (ExactHandleSearch, FullSearch), SearchResult (..)) +import Wire.API.User.Search (Contact (..), FederatedUserSearchPolicy (ExactHandleSearch, FullSearch), PagingState (..), SearchResult (..)) testObject_SearchResult_20Contact_user_1 :: SearchResult Contact testObject_SearchResult_20Contact_user_1 = - SearchResult {searchFound = -6, searchReturned = 0, searchTook = 1, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -6, + searchReturned = 0, + searchTook = 1, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Just (PagingState "WzE2Njk5OTQ5MzIyNjdd") + } testObject_SearchResult_20Contact_user_2 :: SearchResult Contact testObject_SearchResult_20Contact_user_2 = - SearchResult {searchFound = -4, searchReturned = 6, searchTook = -5, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -4, + searchReturned = 6, + searchTook = -5, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_3 :: SearchResult Contact testObject_SearchResult_20Contact_user_3 = @@ -53,7 +67,8 @@ testObject_SearchResult_20Contact_user_3 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000100000000"))) } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_4 :: SearchResult Contact @@ -130,7 +145,8 @@ testObject_SearchResult_20Contact_user_4 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000000000000"))) } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_5 :: SearchResult Contact @@ -152,12 +168,20 @@ testObject_SearchResult_20Contact_user_5 = contactTeam = Just (Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000100000001"))) } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_6 :: SearchResult Contact testObject_SearchResult_20Contact_user_6 = - SearchResult {searchFound = -5, searchReturned = -4, searchTook = 5, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -5, + searchReturned = -4, + searchTook = 5, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_7 :: SearchResult Contact testObject_SearchResult_20Contact_user_7 = @@ -189,7 +213,8 @@ testObject_SearchResult_20Contact_user_7 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000100000000"))) } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_8 :: SearchResult Contact @@ -211,16 +236,31 @@ testObject_SearchResult_20Contact_user_8 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000100000000"))) } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_9 :: SearchResult Contact testObject_SearchResult_20Contact_user_9 = - SearchResult {searchFound = -5, searchReturned = -6, searchTook = 3, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -5, + searchReturned = -6, + searchTook = 3, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_10 :: SearchResult Contact testObject_SearchResult_20Contact_user_10 = - SearchResult {searchFound = 0, searchReturned = -7, searchTook = -5, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = 0, + searchReturned = -7, + searchTook = -5, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_11 :: SearchResult Contact testObject_SearchResult_20Contact_user_11 = @@ -252,12 +292,20 @@ testObject_SearchResult_20Contact_user_11 = contactTeam = Nothing } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_12 :: SearchResult Contact testObject_SearchResult_20Contact_user_12 = - SearchResult {searchFound = 7, searchReturned = 5, searchTook = 3, searchResults = [], searchPolicy = ExactHandleSearch} + SearchResult + { searchFound = 7, + searchReturned = 5, + searchTook = 3, + searchResults = [], + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_13 :: SearchResult Contact testObject_SearchResult_20Contact_user_13 = @@ -311,7 +359,8 @@ testObject_SearchResult_20Contact_user_13 = contactTeam = Nothing } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_14 :: SearchResult Contact @@ -344,24 +393,53 @@ testObject_SearchResult_20Contact_user_14 = contactTeam = Nothing } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_15 :: SearchResult Contact testObject_SearchResult_20Contact_user_15 = - SearchResult {searchFound = 3, searchReturned = 2, searchTook = 4, searchResults = [], searchPolicy = ExactHandleSearch} + SearchResult + { searchFound = 3, + searchReturned = 2, + searchTook = 4, + searchResults = [], + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_16 :: SearchResult Contact testObject_SearchResult_20Contact_user_16 = - SearchResult {searchFound = -4, searchReturned = 4, searchTook = -7, searchResults = [], searchPolicy = ExactHandleSearch} + SearchResult + { searchFound = -4, + searchReturned = 4, + searchTook = -7, + searchResults = [], + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_17 :: SearchResult Contact testObject_SearchResult_20Contact_user_17 = - SearchResult {searchFound = 6, searchReturned = -1, searchTook = -1, searchResults = [], searchPolicy = ExactHandleSearch} + SearchResult + { searchFound = 6, + searchReturned = -1, + searchTook = -1, + searchResults = [], + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_18 :: SearchResult Contact testObject_SearchResult_20Contact_user_18 = - SearchResult {searchFound = -4, searchReturned = 0, searchTook = -5, searchResults = [], searchPolicy = ExactHandleSearch} + SearchResult + { searchFound = -4, + searchReturned = 0, + searchTook = -5, + searchResults = [], + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing + } testObject_SearchResult_20Contact_user_19 :: SearchResult Contact testObject_SearchResult_20Contact_user_19 = @@ -393,7 +471,8 @@ testObject_SearchResult_20Contact_user_19 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000000000001"))) } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } testObject_SearchResult_20Contact_user_20 :: SearchResult Contact @@ -547,5 +626,6 @@ testObject_SearchResult_20Contact_user_20 = contactTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000100000000"))) } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20TeamContact_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20TeamContact_user.hs index 572ad3b5b20..3a1d5d9172a 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20TeamContact_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/SearchResult_20TeamContact_user.hs @@ -25,7 +25,7 @@ import qualified Data.UUID as UUID (fromString) import Imports (Maybe (Just, Nothing), fromJust) import Wire.API.Team.Role (Role (RoleAdmin, RoleExternalPartner, RoleMember, RoleOwner)) import Wire.API.User (Email (Email, emailDomain, emailLocal), ManagedBy (ManagedByScim, ManagedByWire)) -import Wire.API.User.Search (FederatedUserSearchPolicy (ExactHandleSearch, FullSearch), SearchResult (..), Sso (..), TeamContact (..)) +import Wire.API.User.Search (FederatedUserSearchPolicy (ExactHandleSearch, FullSearch), PagingState (..), SearchResult (..), Sso (..), TeamContact (..)) testObject_SearchResult_20TeamContact_user_1 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_1 = @@ -65,12 +65,20 @@ testObject_SearchResult_20TeamContact_user_1 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Just (PagingState "WzE2Njk5OTQ5MzIyNjdd") } testObject_SearchResult_20TeamContact_user_2 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_2 = - SearchResult {searchFound = -5, searchReturned = 4, searchTook = 6, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -5, + searchReturned = 4, + searchTook = 6, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20TeamContact_user_3 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_3 = @@ -125,7 +133,8 @@ testObject_SearchResult_20TeamContact_user_3 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_4 :: SearchResult TeamContact @@ -196,7 +205,8 @@ testObject_SearchResult_20TeamContact_user_4 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_5 :: SearchResult TeamContact @@ -222,7 +232,8 @@ testObject_SearchResult_20TeamContact_user_5 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_6 :: SearchResult TeamContact @@ -428,7 +439,8 @@ testObject_SearchResult_20TeamContact_user_6 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_7 :: SearchResult TeamContact @@ -529,7 +541,8 @@ testObject_SearchResult_20TeamContact_user_7 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_8 :: SearchResult TeamContact @@ -600,7 +613,8 @@ testObject_SearchResult_20TeamContact_user_8 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_9 :: SearchResult TeamContact @@ -671,12 +685,20 @@ testObject_SearchResult_20TeamContact_user_9 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_10 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_10 = - SearchResult {searchFound = -3, searchReturned = -3, searchTook = -4, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -3, + searchReturned = -3, + searchTook = -4, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20TeamContact_user_11 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_11 = @@ -806,7 +828,8 @@ testObject_SearchResult_20TeamContact_user_11 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_12 :: SearchResult TeamContact @@ -832,7 +855,8 @@ testObject_SearchResult_20TeamContact_user_12 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_13 :: SearchResult TeamContact @@ -888,7 +912,8 @@ testObject_SearchResult_20TeamContact_user_13 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_14 :: SearchResult TeamContact @@ -914,7 +939,8 @@ testObject_SearchResult_20TeamContact_user_14 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_15 :: SearchResult TeamContact @@ -940,7 +966,8 @@ testObject_SearchResult_20TeamContact_user_15 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_16 :: SearchResult TeamContact @@ -996,7 +1023,8 @@ testObject_SearchResult_20TeamContact_user_16 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_17 :: SearchResult TeamContact @@ -1022,7 +1050,8 @@ testObject_SearchResult_20TeamContact_user_17 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_18 :: SearchResult TeamContact @@ -1048,13 +1077,28 @@ testObject_SearchResult_20TeamContact_user_18 = teamContactEmailUnvalidated = Nothing } ], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } testObject_SearchResult_20TeamContact_user_19 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_19 = - SearchResult {searchFound = -6, searchReturned = -1, searchTook = -2, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -6, + searchReturned = -1, + searchTook = -2, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } testObject_SearchResult_20TeamContact_user_20 :: SearchResult TeamContact testObject_SearchResult_20TeamContact_user_20 = - SearchResult {searchFound = -6, searchReturned = -5, searchTook = 1, searchResults = [], searchPolicy = FullSearch} + SearchResult + { searchFound = -6, + searchReturned = -5, + searchTook = 1, + searchResults = [], + searchPolicy = FullSearch, + searchPagingState = Nothing + } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index 02ddb5d146a..eddafd7649e 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -125,7 +125,9 @@ tests = ], testGroup "SearchResult Contact" $ testObjects - [(testObject_SearchResultContact_1, "testObject_SearchResultContact_1.json")], + [ (testObject_SearchResultContact_1, "testObject_SearchResultContact_1.json"), + (testObject_SearchResultContact_2, "testObject_SearchResultContact_2.json") + ], testGroup "GroupId" $ testObjects [(testObject_GroupId_1, "testObject_GroupId_1.json")], diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SearchResultContact.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SearchResultContact.hs index e1c58013e6d..146d1ee7d9d 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SearchResultContact.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SearchResultContact.hs @@ -17,8 +17,9 @@ module Test.Wire.API.Golden.Manual.SearchResultContact where +import Imports import Test.Wire.API.Golden.Manual.Contact (testObject_Contact_1, testObject_Contact_2) -import Wire.API.User.Search (Contact (..), FederatedUserSearchPolicy (FullSearch), SearchResult (..)) +import Wire.API.User.Search (Contact (..), FederatedUserSearchPolicy (FullSearch), PagingState (..), SearchResult (..)) testObject_SearchResultContact_1 :: SearchResult Contact testObject_SearchResultContact_1 = @@ -27,5 +28,17 @@ testObject_SearchResultContact_1 = searchReturned = 2, searchTook = 100, searchResults = [testObject_Contact_1, testObject_Contact_2], - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing + } + +testObject_SearchResultContact_2 :: SearchResult Contact +testObject_SearchResultContact_2 = + SearchResult + { searchFound = 2, + searchReturned = 2, + searchTook = 100, + searchResults = [testObject_Contact_1, testObject_Contact_2], + searchPolicy = FullSearch, + searchPagingState = Just $ PagingState "WzE2Njk5OTQ5MzIyNjdd" } diff --git a/libs/wire-api/test/golden/testObject_SearchResultContact_2.json b/libs/wire-api/test/golden/testObject_SearchResultContact_2.json new file mode 100644 index 00000000000..a10b4134d2c --- /dev/null +++ b/libs/wire-api/test/golden/testObject_SearchResultContact_2.json @@ -0,0 +1,31 @@ +{ + "documents": [ + { + "accent_id": 1, + "handle": "foobar1", + "id": "00000018-0000-0020-0000-000e00000002", + "name": "Foobar", + "qualified_id": { + "domain": "example.com", + "id": "00000018-0000-0020-0000-000e00000002" + }, + "team": "00000018-0000-0020-0000-000e00000002" + }, + { + "accent_id": null, + "handle": null, + "id": "00000018-0000-0020-0000-000e00000003", + "name": "Foobar2", + "qualified_id": { + "domain": "another.example.com", + "id": "00000018-0000-0020-0000-000e00000003" + }, + "team": null + } + ], + "found": 2, + "paging_state": "WzE2Njk5OTQ5MzIyNjdd", + "returned": 2, + "search_policy": "full_search", + "took": 100 +} diff --git a/libs/wire-api/test/golden/testObject_SearchResult_20Contact_user_1.json b/libs/wire-api/test/golden/testObject_SearchResult_20Contact_user_1.json index a64f3d5ef87..27167e8fe58 100644 --- a/libs/wire-api/test/golden/testObject_SearchResult_20Contact_user_1.json +++ b/libs/wire-api/test/golden/testObject_SearchResult_20Contact_user_1.json @@ -1,6 +1,7 @@ { "documents": [], "found": -6, + "paging_state": "WzE2Njk5OTQ5MzIyNjdd", "returned": 0, "search_policy": "full_search", "took": 1 diff --git a/libs/wire-api/test/golden/testObject_SearchResult_20TeamContact_user_1.json b/libs/wire-api/test/golden/testObject_SearchResult_20TeamContact_user_1.json index 2859de52451..539ec4765c9 100644 --- a/libs/wire-api/test/golden/testObject_SearchResult_20TeamContact_user_1.json +++ b/libs/wire-api/test/golden/testObject_SearchResult_20TeamContact_user_1.json @@ -35,6 +35,7 @@ } ], "found": -4, + "paging_state": "WzE2Njk5OTQ5MzIyNjdd", "returned": 2, "search_policy": "full_search", "took": 0 diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index fbe3550fa37..0066d10a0dc 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -308,6 +308,7 @@ tests = testRoundTrip @User.RichInfo.RichInfoMapAndList, testRoundTrip @User.RichInfo.RichInfo, testRoundTrip @(User.Search.SearchResult User.Search.TeamContact), + testRoundTrip @User.Search.PagingState, testRoundTrip @User.Search.TeamContact, testRoundTrip @(Wrapped.Wrapped "some_int" Int), testRoundTrip @Conversation.Action.SomeConversationAction diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs index 6240b9c361b..467a837b58c 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/ByteString.hs @@ -77,6 +77,7 @@ tests = testRoundTrip @User.Profile.ManagedBy, testRoundTrip @User.Profile.Name, testRoundTrip @Team.Role.Role, + testRoundTrip @User.Search.PagingState, testRoundTrip @User.Search.TeamUserSearchSortBy, testRoundTrip @User.Search.TeamUserSearchSortOrder, testRoundTrip @User.Search.RoleFilter, diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/HttpApiData.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/HttpApiData.hs index f910ad0a78d..5ebf76a08e7 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/HttpApiData.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/HttpApiData.hs @@ -22,13 +22,15 @@ import Servant.API import qualified Test.Tasty as T import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (===)) import Type.Reflection (typeRep) -import qualified Wire.API.User as User +import qualified Wire.API.User +import qualified Wire.API.User.Search import qualified Wire.Arbitrary as Arbitrary () tests :: T.TestTree tests = T.localOption (T.Timeout (60 * 1000000) "60s") . T.testGroup "HttpApiData roundtrip tests" $ - [ testRoundTrip @User.InvitationCode + [ testRoundTrip @Wire.API.User.InvitationCode, + testRoundTrip @Wire.API.User.Search.PagingState ] testRoundTrip :: diff --git a/services/brig/src/Brig/User/API/Search.hs b/services/brig/src/Brig/User/API/Search.hs index 33cc30c3b08..30ace8adbd1 100644 --- a/services/brig/src/Brig/User/API/Search.hs +++ b/services/brig/src/Brig/User/API/Search.hs @@ -113,7 +113,8 @@ searchRemotely domain searchTerm = do searchFound = count, searchReturned = count, searchTook = 0, - searchPolicy = S.searchPolicy searchResponse + searchPolicy = S.searchPolicy searchResponse, + searchPagingState = Nothing } searchLocally :: @@ -136,7 +137,7 @@ searchLocally searcherId searchTerm maybeMaxResults = do esResult <- if esMaxResults > 0 then Q.searchIndex (Q.LocalSearch searcherId searcherTeamId teamSearchInfo) searchTerm esMaxResults - else pure $ SearchResult 0 0 0 [] FullSearch + else pure $ SearchResult 0 0 0 [] FullSearch Nothing -- Prepend results matching exact handle and results from ES. pure $ @@ -181,7 +182,8 @@ teamUserSearch :: Maybe TeamUserSearchSortBy -> Maybe TeamUserSearchSortOrder -> Maybe (Range 1 500 Int32) -> + Maybe PagingState -> (Handler r) (Public.SearchResult Public.TeamContact) -teamUserSearch uid tid mQuery mRoleFilter mSortBy mSortOrder size = do +teamUserSearch uid tid mQuery mRoleFilter mSortBy mSortOrder size mPagingState = do ensurePermissions uid tid [Public.AddTeamMember] -- limit this to team admins to reduce risk of involuntary DOS attacks. (also, this way we don't need to worry about revealing confidential user data to other team members.) - Q.teamUserSearch tid mQuery mRoleFilter mSortBy mSortOrder $ fromMaybe (unsafeRange 15) size + Q.teamUserSearch tid mQuery mRoleFilter mSortBy mSortOrder (fromMaybe (unsafeRange 15) size) mPagingState diff --git a/services/brig/src/Brig/User/Search/SearchIndex.hs b/services/brig/src/Brig/User/Search/SearchIndex.hs index 570c4877667..49a7d3c6909 100644 --- a/services/brig/src/Brig/User/Search/SearchIndex.hs +++ b/services/brig/src/Brig/User/Search/SearchIndex.hs @@ -87,7 +87,8 @@ queryIndex (IndexQuery q f _) s = do searchReturned = length results, searchTook = ES.took es, searchResults = results, - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = Nothing } userDocToContact :: MonadThrow m => Domain -> UserDoc -> m Contact diff --git a/services/brig/src/Brig/User/Search/TeamUserSearch.hs b/services/brig/src/Brig/User/Search/TeamUserSearch.hs index 3d2e8eb17d9..83c6d04e51a 100644 --- a/services/brig/src/Brig/User/Search/TeamUserSearch.hs +++ b/services/brig/src/Brig/User/Search/TeamUserSearch.hs @@ -29,9 +29,13 @@ where import Brig.Data.Instances () import Brig.User.Search.Index +import Control.Error (lastMay) import Control.Monad.Catch (MonadThrow (throwM)) +import Data.Aeson (decode', encode) import Data.Id (TeamId, idToText) import Data.Range (Range (..)) +import Data.String.Conversions (cs) +import Data.Text.Ascii (decodeBase64Url, encodeBase64Url) import qualified Database.Bloodhound as ES import Imports hiding (log, searchable) import Wire.API.User.Search @@ -44,28 +48,39 @@ teamUserSearch :: Maybe TeamUserSearchSortBy -> Maybe TeamUserSearchSortOrder -> Range 1 500 Int32 -> + Maybe PagingState -> m (SearchResult TeamContact) -teamUserSearch tid mbSearchText mRoleFilter mSortBy mSortOrder (fromRange -> s) = liftIndexIO $ do +teamUserSearch tid mbSearchText mRoleFilter mSortBy mSortOrder (fromRange -> s) mPagingState = liftIndexIO $ do let (IndexQuery q f sortSpecs) = teamUserSearchQuery tid mbSearchText mRoleFilter mSortBy mSortOrder idx <- asks idxName let search = (ES.mkSearch (Just q) (Just f)) { ES.size = ES.Size (fromIntegral s), - ES.sortBody = Just (fmap ES.DefaultSortSpec sortSpecs) + ES.sortBody = Just (fmap ES.DefaultSortSpec sortSpecs), + ES.searchAfterKey = toSearchAfterKey =<< mPagingState } r <- ES.searchByType idx mappingName search >>= ES.parseEsResponse either (throwM . IndexLookupError) (pure . mkResult) r where + toSearchAfterKey :: PagingState -> Maybe ES.SearchAfterKey + toSearchAfterKey ps = decode' . cs =<< (decodeBase64Url . unPagingState $ ps) + + fromSearchAfterKey :: ES.SearchAfterKey -> PagingState + fromSearchAfterKey = PagingState . encodeBase64Url . cs . encode + mkResult es = - let results = mapMaybe ES.hitSource . ES.hits . ES.searchHits $ es + let hits = ES.hits . ES.searchHits $ es + mps = fmap fromSearchAfterKey . ES.hitSort =<< lastMay hits + results = mapMaybe ES.hitSource hits in SearchResult { searchFound = ES.hitsTotal . ES.searchHits $ es, searchReturned = length results, searchTook = ES.took es, searchResults = results, - searchPolicy = FullSearch + searchPolicy = FullSearch, + searchPagingState = mps } -- FUTURWORK: Implement role filter (needs galley data) diff --git a/services/brig/test/integration/API/Search.hs b/services/brig/test/integration/API/Search.hs index 2f414ddc812..c893fd8e3fa 100644 --- a/services/brig/test/integration/API/Search.hs +++ b/services/brig/test/integration/API/Search.hs @@ -557,7 +557,8 @@ testSearchOtherDomain opts brig = do searchFound = length otherSearchResult, searchReturned = length otherSearchResult, searchTook = 0, - searchPolicy = ExactHandleSearch + searchPolicy = ExactHandleSearch, + searchPagingState = Nothing } liftIO $ do assertEqual "The search request should get its result from federator" expectedResult searchResult diff --git a/services/brig/test/integration/API/Search/Util.hs b/services/brig/test/integration/API/Search/Util.hs index a8b09b01916..48cdead2c1b 100644 --- a/services/brig/test/integration/API/Search/Util.hs +++ b/services/brig/test/integration/API/Search/Util.hs @@ -25,6 +25,7 @@ import Data.ByteString.Conversion.To (toByteString) import Data.Domain (Domain) import Data.Id import Data.Qualified (Qualified (..)) +import Data.Range (Range) import Data.String.Conversions (cs) import Data.Text.Encoding (encodeUtf8) import Imports @@ -116,7 +117,22 @@ executeTeamUserSearch :: Maybe TeamUserSearchSortBy -> Maybe TeamUserSearchSortOrder -> m (SearchResult TeamContact) -executeTeamUserSearch brig teamid self mbSearchText mRoleFilter mSortBy mSortOrder = do +executeTeamUserSearch brig teamid self mbSearchText mRoleFilter mSortBy mSortOrder = + executeTeamUserSearchWithMaybeState brig teamid self mbSearchText mRoleFilter mSortBy mSortOrder Nothing Nothing + +executeTeamUserSearchWithMaybeState :: + (MonadCatch m, MonadIO m, MonadHttp m, HasCallStack) => + Brig -> + TeamId -> + UserId -> + Maybe Text -> + Maybe RoleFilter -> + Maybe TeamUserSearchSortBy -> + Maybe TeamUserSearchSortOrder -> + Maybe (Range 1 500 Int32) -> + Maybe PagingState -> + m (SearchResult TeamContact) +executeTeamUserSearchWithMaybeState brig teamid self mbSearchText mRoleFilter mSortBy mSortOrder mSize mPagingState = do r <- get ( brig @@ -126,6 +142,8 @@ executeTeamUserSearch brig teamid self mbSearchText mRoleFilter mSortBy mSortOrd . maybe id (queryItem "frole" . cs . toByteString) mRoleFilter . maybe id (queryItem "sortby" . cs . toByteString) mSortBy . maybe id (queryItem "sortorder" . cs . toByteString) mSortOrder + . maybe id (queryItem "pagingState" . cs . toByteString) mPagingState + . maybe id (queryItem "size" . cs . toByteString) mSize ) do r <- searchResults <$> executeTeamUserSearch brig tid ownerId Nothing Nothing (Just tuSortBy) (Just SortOrderAsc) liftIO $ assertEqual ("length of users sorted by " <> cs (toByteString tuSortBy)) n (length r) + +testEmptyQuerySortedWithPagination :: TestConstraints m => Brig -> m () +testEmptyQuerySortedWithPagination brig = do + (tid, userId -> ownerId, _) <- createPopulatedBindingTeamWithNamesAndHandles brig 20 + refreshIndex brig + searchResultFirst10 <- executeTeamUserSearchWithMaybeState brig tid ownerId (Just "") Nothing Nothing Nothing (Just $ unsafeRange 10) Nothing + searchResultLast11 <- executeTeamUserSearchWithMaybeState brig tid ownerId (Just "") Nothing Nothing Nothing Nothing (searchPagingState searchResultFirst10) + liftIO $ do + searchReturned searchResultFirst10 @?= 10 + searchFound searchResultFirst10 @?= 21 + searchReturned searchResultLast11 @?= 11 + searchFound searchResultLast11 @?= 21