diff --git a/CHANGELOG-draft.md b/CHANGELOG-draft.md index 0e11a2cb81..3726574367 100644 --- a/CHANGELOG-draft.md +++ b/CHANGELOG-draft.md @@ -27,6 +27,9 @@ THIS FILE ACCUMULATES THE RELEASE NOTES FOR THE UPCOMING RELEASE. ## API Changes +* Add `POST /conversations/list/v2` (#1703) +* Deprecate `POST /list-conversations` (#1703) + ## Features ## Bug fixes and other updates @@ -40,3 +43,4 @@ THIS FILE ACCUMULATES THE RELEASE NOTES FOR THE UPCOMING RELEASE. ## Internal changes * The update conversation membership federation endpoint takes OriginDomainHeader (#1719) +* Added new endpoint to allow fetching conversation metadata by qualified ids (#1703) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Client.hs b/libs/wire-api-federation/src/Wire/API/Federation/Client.hs index 50b7172637..ee39c5ed59 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Client.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Client.hs @@ -123,6 +123,7 @@ data FederationError | FederationNotImplemented | FederationNotConfigured | FederationCallFailure FederationClientFailure + deriving (Show, Eq) data FederationClientFailure = FederationClientFailure { fedFailDomain :: Domain, diff --git a/libs/wire-api/package.yaml b/libs/wire-api/package.yaml index 82cc58a66f..d32a04a4e6 100644 --- a/libs/wire-api/package.yaml +++ b/libs/wire-api/package.yaml @@ -66,6 +66,7 @@ library: - servant-server - servant-swagger - schema-profunctor + - singletons - sop-core - string-conversions - swagger >=0.1 diff --git a/libs/wire-api/src/Wire/API/Conversation.hs b/libs/wire-api/src/Wire/API/Conversation.hs index 55e9c50fa7..9cc597a479 100644 --- a/libs/wire-api/src/Wire/API/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Conversation.hs @@ -27,10 +27,12 @@ module Wire.API.Conversation ConversationCoverView (..), ConversationList (..), ListConversations (..), + ListConversationsV2 (..), GetPaginatedConversationIds (..), ConversationPagingState (..), ConversationPagingTable (..), ConvIdsPage (..), + ConversationsResponse (..), -- * Conversation properties Access (..), @@ -87,9 +89,10 @@ import Data.List1 import Data.Misc import Data.Proxy (Proxy (Proxy)) import Data.Qualified (Qualified (qUnqualified), deprecatedSchema) -import Data.Range (Range, toRange) +import Data.Range (Range, fromRange, rangedSchema, toRange) import Data.Schema import qualified Data.Set as Set +import Data.Singletons (sing) import Data.String.Conversions (cs) import qualified Data.Swagger as S import qualified Data.Swagger.Build.Api as Doc @@ -342,8 +345,6 @@ instance ToSchema GetPaginatedConversationIds where <$> gpciPagingState .= optFieldWithDocModifier "paging_state" Nothing addPagingStateDoc schema <*> gpciSize .= (fieldWithDocModifier "size" addSizeDoc schema <|> pure (toRange (Proxy @1000))) --- | Used on the POST /list-conversations endpoint --- FUTUREWORK: add to golden tests (how to generate them?) data ListConversations = ListConversations { lQualifiedIds :: Maybe (NonEmpty (Qualified ConvId)), lStartId :: Maybe (Qualified ConvId), @@ -362,6 +363,41 @@ instance ToSchema ListConversations where <*> lStartId .= optField "start_id" Nothing schema <*> lSize .= optField "size" Nothing schema +-- | Used on the POST /conversations/list/v2 endpoint +newtype ListConversationsV2 = ListConversationsV2 + { lcQualifiedIds :: Range 1 1000 [Qualified ConvId] + } + deriving stock (Eq, Show, Generic) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema ListConversationsV2 + +instance ToSchema ListConversationsV2 where + schema = + objectWithDocModifier + "ListConversations" + (description ?~ "A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs") + $ ListConversationsV2 + <$> (fromRange . lcQualifiedIds) .= field "qualified_ids" (rangedSchema sing sing (array schema)) + +data ConversationsResponse = ConversationsResponse + { crFound :: [Conversation], + crNotFound :: [Qualified ConvId], + crFailed :: [Qualified ConvId] + } + deriving stock (Eq, Show) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema ConversationsResponse + +instance ToSchema ConversationsResponse where + schema = + let notFoundDoc = description ?~ "These conversations either don't exist or are deleted." + failedDoc = description ?~ "The server failed to fetch these conversations, most likely due to network issues while contacting a remote server" + in objectWithDocModifier + "ConversationsResponse" + (description ?~ "Response object for getting metadata of a list of conversations") + $ ConversationsResponse + <$> crFound .= field "found" (array schema) + <*> crNotFound .= fieldWithDocModifier "not_found" notFoundDoc (array schema) + <*> crFailed .= fieldWithDocModifier "failed" failedDoc (array schema) + -------------------------------------------------------------------------------- -- Conversation properties diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs index 53780ca0b8..3e8fa9921b 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -188,12 +188,25 @@ data Api routes = Api :> Get '[Servant.JSON] (Public.ConversationList Public.Conversation), listConversations :: routes - :- Summary "Get all conversations (also returns remote conversations)" - :> Description "Like GET /conversations, but allows specifying a list of remote conversations in its request body. Will return all or the requested qualified conversations, including remote ones. WIP: Size parameter is not yet honoured for remote conversations." + :- Summary "[deprecated] Get all conversations (also returns remote conversations)" + :> Description + "Like GET /conversations, but allows specifying a list of remote conversations in its request body. \ + \Will return all or the requested qualified conversations, including remote ones. \ + \Size parameter is not yet honoured for remote conversations.\n\ + \**NOTE** This endpoint will soon be removed." :> ZUser :> "list-conversations" :> ReqBody '[Servant.JSON] Public.ListConversations :> Post '[Servant.JSON] (Public.ConversationList Public.Conversation), + listConversationsV2 :: + routes + :- Summary "Get conversation metadata for a list of conversation ids" + :> ZUser + :> "conversations" + :> "list" + :> "v2" + :> ReqBody '[Servant.JSON] Public.ListConversationsV2 + :> Post '[Servant.JSON] Public.ConversationsResponse, -- This endpoint can lead to the following events being sent: -- - ConvCreate event to members getConversationByReusableCode :: diff --git a/libs/wire-api/test/golden/fromJSON/testObject_ListConversations_1.json b/libs/wire-api/test/golden/fromJSON/testObject_ListConversations_1.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/libs/wire-api/test/golden/fromJSON/testObject_ListConversations_1.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json b/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json new file mode 100644 index 0000000000..70bd669d4c --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json @@ -0,0 +1,107 @@ +{ + "found": [ + { + "access": [], + "creator": "00000001-0000-0001-0000-000200000001", + "access_role": "private", + "members": { + "self": { + "hidden_ref": "", + "status": 0, + "service": null, + "otr_muted_ref": null, + "conversation_role": "rhhdzf0j0njilixx0g0vzrp06b_5us", + "status_time": "1970-01-01T00:00:00.000Z", + "hidden": false, + "status_ref": "0.0", + "id": "00000001-0000-0001-0000-000100000000", + "otr_archived": false, + "otr_muted_status": null, + "otr_muted": true, + "otr_archived_ref": "" + }, + "others": [] + }, + "qualified_id": { + "domain": "golden.example.com", + "id": "00000001-0000-0000-0000-000000000000" + }, + "name": " 0", + "team": "00000001-0000-0001-0000-000100000002", + "id": "00000001-0000-0000-0000-000000000000", + "type": 2, + "receipt_mode": -2, + "last_event_time": "1970-01-01T00:00:00.000Z", + "message_timer": null, + "last_event": "0.0" + }, + { + "access": [ + "invite", + "invite", + "code", + "link", + "invite", + "private", + "link", + "code", + "code", + "link", + "private", + "invite" + ], + "creator": "00000000-0000-0000-0000-000200000001", + "access_role": "non_activated", + "members": { + "self": { + "hidden_ref": "", + "status": 0, + "service": null, + "otr_muted_ref": null, + "conversation_role": "9b2d3thyqh4ptkwtq2n2v9qsni_ln1ca66et_z8dlhfs9oamp328knl3rj9kcj", + "status_time": "1970-01-01T00:00:00.000Z", + "hidden": true, + "status_ref": "0.0", + "id": "00000000-0000-0001-0000-000100000001", + "otr_archived": false, + "otr_muted_status": -1, + "otr_muted": true, + "otr_archived_ref": null + }, + "others": [] + }, + "qualified_id": { + "domain": "golden.example.com", + "id": "00000000-0000-0000-0000-000000000002" + }, + "name": "", + "team": "00000000-0000-0001-0000-000200000000", + "id": "00000000-0000-0000-0000-000000000002", + "type": 1, + "receipt_mode": 2, + "last_event_time": "1970-01-01T00:00:00.000Z", + "message_timer": 1319272593797015, + "last_event": "0.0" + } + ], + "not_found": [ + { + "domain": "golden.example.com", + "id": "00000018-0000-0020-0000-000e00000002" + }, + { + "domain": "golden2.example.com", + "id": "00000018-0000-0020-0000-111111111112" + } + ], + "failed": [ + { + "domain": "golden.example.com", + "id": "00000018-4444-0020-0000-000e00000002" + }, + { + "domain": "golden3.example.com", + "id": "99999999-0000-0020-0000-111111111112" + } + ] +} \ No newline at end of file diff --git a/libs/wire-api/test/golden/testObject_ListConversationsV2_1.json b/libs/wire-api/test/golden/testObject_ListConversationsV2_1.json new file mode 100644 index 0000000000..e2b47c2fc1 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ListConversationsV2_1.json @@ -0,0 +1,12 @@ +{ + "qualified_ids": [ + { + "domain": "domain.example.com", + "id": "00000018-0000-0020-0000-000e00000002" + }, + { + "domain": "domain2.example.com", + "id": "00000018-0000-0020-0000-111111111112" + } + ] +} \ No newline at end of file diff --git a/libs/wire-api/test/unit/Test/Wire/API/Golden/FromJSON.hs b/libs/wire-api/test/unit/Test/Wire/API/Golden/FromJSON.hs index c3ed87b42b..c13aa0a5e9 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Golden/FromJSON.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Golden/FromJSON.hs @@ -24,7 +24,6 @@ import Test.Wire.API.Golden.Generated.NewConvUnmanaged_user import Test.Wire.API.Golden.Generated.NewOtrMessage_user import Test.Wire.API.Golden.Generated.RmClient_user import Test.Wire.API.Golden.Generated.SimpleMember_user -import Test.Wire.API.Golden.Manual.ListConversations import Test.Wire.API.Golden.Runner import Wire.API.Conversation (Conversation) import Wire.API.User.Client (RmClient) @@ -49,9 +48,6 @@ tests = [(testObject_RmClient_user_4, "testObject_RmClient_user_4.json")], testCase "RmClient failure" $ testFromJSONFailure @RmClient "testObject_RmClient_failure.json", - testCase "ListConversations" $ - testFromJSONObjects - [(testObject_ListConversations_1, "testObject_ListConversations_1.json")], testCase "QualifiedConversationId" $ testFromJSONFailure @Conversation "testObject_Conversation_qualifiedId.json" ] diff --git a/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual.hs index e13083c4dc..c5d09a6c73 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual.hs @@ -25,8 +25,10 @@ import Test.Wire.API.Golden.Manual.ClientCapabilityList import Test.Wire.API.Golden.Manual.ConvIdsPage import Test.Wire.API.Golden.Manual.ConversationCoverView import Test.Wire.API.Golden.Manual.ConversationPagingState +import Test.Wire.API.Golden.Manual.ConversationsResponse import Test.Wire.API.Golden.Manual.FeatureConfigEvent import Test.Wire.API.Golden.Manual.GetPaginatedConversationIds +import Test.Wire.API.Golden.Manual.ListConversationsV2 import Test.Wire.API.Golden.Manual.QualifiedUserClientPrekeyMap import Test.Wire.API.Golden.Manual.UserClientPrekeyMap import Test.Wire.API.Golden.Manual.UserIdList @@ -94,5 +96,10 @@ tests = testObjects [ (testObject_UserIdList_1, "testObject_UserIdList_1.json"), (testObject_UserIdList_2, "testObject_UserIdList_2.json") - ] + ], + testCase "ListConversationsV2" $ + testObjects + [(testObject_ListConversationsV2_1, "testObject_ListConversationsV2_1.json")], + testCase "ConversationsResponse" $ + testObjects [(testObject_ConversationsResponse_1, "testObject_ConversationsResponse_1.json")] ] diff --git a/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ConversationsResponse.hs b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ConversationsResponse.hs new file mode 100644 index 0000000000..d4733ff41a --- /dev/null +++ b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ConversationsResponse.hs @@ -0,0 +1,103 @@ +module Test.Wire.API.Golden.Manual.ConversationsResponse + ( testObject_ConversationsResponse_1, + ) +where + +import Data.Domain +import Data.Id (Id (Id)) +import Data.Misc +import Data.Qualified +import qualified Data.UUID as UUID +import Imports +import Wire.API.Conversation +import Wire.API.Conversation.Role + +testObject_ConversationsResponse_1 :: ConversationsResponse +testObject_ConversationsResponse_1 = + ConversationsResponse + { crFound = [conv1, conv2], + crNotFound = + [ Qualified (Id (fromJust (UUID.fromString "00000018-0000-0020-0000-000e00000002"))) (Domain "golden.example.com"), + Qualified (Id (fromJust (UUID.fromString "00000018-0000-0020-0000-111111111112"))) (Domain "golden2.example.com") + ], + crFailed = + [ Qualified (Id (fromJust (UUID.fromString "00000018-4444-0020-0000-000e00000002"))) (Domain "golden.example.com"), + Qualified (Id (fromJust (UUID.fromString "99999999-0000-0020-0000-111111111112"))) (Domain "golden3.example.com") + ] + } + +conv1 :: Conversation +conv1 = + Conversation + { cnvQualifiedId = Qualified (Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000000"))) (Domain "golden.example.com"), + cnvType = One2OneConv, + cnvCreator = Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000200000001")), + cnvAccess = [], + cnvAccessRole = PrivateAccessRole, + cnvName = Just " 0", + cnvMembers = + ConvMembers + { cmSelf = + Member + { memId = Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000100000000")), + memService = Nothing, + memOtrMuted = True, + memOtrMutedStatus = Nothing, + memOtrMutedRef = Nothing, + memOtrArchived = False, + memOtrArchivedRef = Just "", + memHidden = False, + memHiddenRef = Just "", + memConvRoleName = fromJust (parseRoleName "rhhdzf0j0njilixx0g0vzrp06b_5us") + }, + cmOthers = [] + }, + cnvTeam = Just (Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000100000002"))), + cnvMessageTimer = Nothing, + cnvReceiptMode = Just (ReceiptMode {unReceiptMode = -2}) + } + +conv2 :: Conversation +conv2 = + Conversation + { cnvQualifiedId = Qualified (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000000000002"))) (Domain "golden.example.com"), + cnvType = SelfConv, + cnvCreator = Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001")), + cnvAccess = + [ InviteAccess, + InviteAccess, + CodeAccess, + LinkAccess, + InviteAccess, + PrivateAccess, + LinkAccess, + CodeAccess, + CodeAccess, + LinkAccess, + PrivateAccess, + InviteAccess + ], + cnvAccessRole = NonActivatedAccessRole, + cnvName = Just "", + cnvMembers = + ConvMembers + { cmSelf = + Member + { memId = Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000100000001")), + memService = Nothing, + memOtrMuted = True, + memOtrMutedStatus = Just (MutedStatus {fromMutedStatus = -1}), + memOtrMutedRef = Nothing, + memOtrArchived = False, + memOtrArchivedRef = Nothing, + memHidden = True, + memHiddenRef = Just "", + memConvRoleName = + fromJust (parseRoleName "9b2d3thyqh4ptkwtq2n2v9qsni_ln1ca66et_z8dlhfs9oamp328knl3rj9kcj") + }, + cmOthers = [] + }, + cnvTeam = Just (Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000200000000"))), + cnvMessageTimer = Just (Ms {ms = 1319272593797015}), + cnvReceiptMode = Just (ReceiptMode {unReceiptMode = 2}) + } diff --git a/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversations.hs b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversationsV2.hs similarity index 53% rename from libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversations.hs rename to libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversationsV2.hs index cb4580d6c2..6cac2a29ca 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversations.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Golden/Manual/ListConversationsV2.hs @@ -15,10 +15,21 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Test.Wire.API.Golden.Manual.ListConversations where +module Test.Wire.API.Golden.Manual.ListConversationsV2 where +import Data.Domain (Domain (Domain)) +import Data.Id (Id (Id)) +import Data.Qualified (Qualified (Qualified)) +import Data.Range (unsafeRange) +import qualified Data.UUID as UUID import Imports -import Wire.API.Conversation (ListConversations (..)) +import Wire.API.Conversation (ListConversationsV2 (..)) -testObject_ListConversations_1 :: ListConversations -testObject_ListConversations_1 = ListConversations Nothing Nothing Nothing +testObject_ListConversationsV2_1 :: ListConversationsV2 +testObject_ListConversationsV2_1 = + ListConversationsV2 + ( unsafeRange + [ Qualified (Id (fromJust (UUID.fromString "00000018-0000-0020-0000-000e00000002"))) (Domain "domain.example.com"), + Qualified (Id (fromJust (UUID.fromString "00000018-0000-0020-0000-111111111112"))) (Domain "domain2.example.com") + ] + ) diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index a70062305e..dfa774db99 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -4,7 +4,7 @@ cabal-version: 1.12 -- -- see: https://github.com/sol/hpack -- --- hash: 2e4bca56fcdff432834ff4ef0e7678b73cafc509219dd2b1b0f6308dca6a2588 +-- hash: efca28ca2d2ca3ccfcaf3b543293e01d05d18802dd38283d7658ec50939231d9 name: wire-api version: 0.1.0 @@ -148,6 +148,7 @@ library , servant-multipart , servant-server , servant-swagger + , singletons , sop-core , string-conversions , swagger >=0.1 @@ -401,10 +402,11 @@ test-suite wire-api-tests Test.Wire.API.Golden.Manual.ClientCapabilityList Test.Wire.API.Golden.Manual.ConversationCoverView Test.Wire.API.Golden.Manual.ConversationPagingState + Test.Wire.API.Golden.Manual.ConversationsResponse Test.Wire.API.Golden.Manual.ConvIdsPage Test.Wire.API.Golden.Manual.FeatureConfigEvent Test.Wire.API.Golden.Manual.GetPaginatedConversationIds - Test.Wire.API.Golden.Manual.ListConversations + Test.Wire.API.Golden.Manual.ListConversationsV2 Test.Wire.API.Golden.Manual.QualifiedUserClientPrekeyMap Test.Wire.API.Golden.Manual.UserClientPrekeyMap Test.Wire.API.Golden.Manual.UserIdList diff --git a/services/galley/src/Galley/API/Public.hs b/services/galley/src/Galley/API/Public.hs index cba48a48c7..7f93785ce3 100644 --- a/services/galley/src/Galley/API/Public.hs +++ b/services/galley/src/Galley/API/Public.hs @@ -85,6 +85,7 @@ servantSitemap = GalleyAPI.getConversations = Query.getConversations, GalleyAPI.getConversationByReusableCode = Query.getConversationByReusableCode, GalleyAPI.listConversations = Query.listConversations, + GalleyAPI.listConversationsV2 = Query.listConversationsV2, GalleyAPI.createGroupConversation = Create.createGroupConversation, GalleyAPI.createSelfConversation = Create.createSelfConversation, GalleyAPI.createOne2OneConversation = Create.createOne2OneConversation, diff --git a/services/galley/src/Galley/API/Query.hs b/services/galley/src/Galley/API/Query.hs index d401ed4730..d084787854 100644 --- a/services/galley/src/Galley/API/Query.hs +++ b/services/galley/src/Galley/API/Query.hs @@ -25,6 +25,7 @@ module Galley.API.Query conversationIdsPageFrom, getConversations, listConversations, + listConversationsV2, iterateConversations, getSelfH, internalGetMemberH, @@ -35,6 +36,7 @@ where import qualified Cassandra as C import Control.Monad.Catch (throwM) +import Control.Monad.Except (ExceptT, runExceptT) import qualified Data.ByteString.Lazy as LBS import Data.Code import Data.CommaSeparatedList @@ -43,6 +45,8 @@ import Data.Id as Id import Data.Proxy import Data.Qualified (Qualified (..), Remote, partitionRemote, partitionRemoteOrLocalIds', toRemote) import Data.Range +import qualified Data.Set as Set +import Data.Tagged (unTagged) import Galley.API.Error import qualified Galley.API.Mapping as Mapping import Galley.API.Util @@ -56,6 +60,7 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Predicate hiding (result, setStatus) import Network.Wai.Utilities +import qualified System.Logger.Class as Logger import UnliftIO (pooledForConcurrentlyN) import Wire.API.Conversation (ConversationCoverView (..)) import qualified Wire.API.Conversation as Public @@ -63,6 +68,7 @@ import qualified Wire.API.Conversation.Role as Public import Wire.API.ErrorDescription (convNotFound) import Wire.API.Federation.API.Galley (gcresConvs) import qualified Wire.API.Federation.API.Galley as FederatedGalley +import Wire.API.Federation.Client (FederationError, executeFederated) import Wire.API.Federation.Error import qualified Wire.API.Provider.Bot as Public @@ -115,6 +121,30 @@ getRemoteConversations zusr remoteConvs = do gcresConvs <$> runFederatedGalley remoteDomain rpc pure $ concat convs +getRemoteConversationsWithFailures :: UserId -> [Remote ConvId] -> Galley ([Qualified ConvId], [Public.Conversation]) +getRemoteConversationsWithFailures zusr remoteConvs = do + localDomain <- viewFederationDomain + let qualifiedZUser = Qualified zusr localDomain + let convsByDomain = partitionRemote remoteConvs + convs <- pooledForConcurrentlyN 8 convsByDomain $ \(remoteDomain, convIds) -> handleFailures remoteDomain convIds $ do + let req = FederatedGalley.GetConversationsRequest qualifiedZUser convIds + rpc = FederatedGalley.getConversations FederatedGalley.clientRoutes req + gcresConvs <$> executeFederated remoteDomain rpc + pure $ concatEithers convs + where + handleFailures :: Domain -> [ConvId] -> ExceptT FederationError Galley a -> Galley (Either [Qualified ConvId] a) + handleFailures domain convIds action = do + res <- runExceptT action + case res of + Right a -> pure $ Right a + Left e -> do + Logger.warn $ + Logger.msg ("Error occurred while fetching remote conversations" :: ByteString) + . Logger.field "error" (show e) + pure . Left $ map (`Qualified` domain) convIds + concatEithers :: (Monoid a, Monoid b) => [Either a b] -> (a, b) + concatEithers = bimap mconcat mconcat . partitionEithers + getConversationRoles :: UserId -> ConvId -> Galley Public.ConversationRolesList getConversationRoles zusr cnv = do void $ getConversationAndCheckMembership zusr cnv @@ -196,7 +226,7 @@ getConversationsInternal user mids mstart msize = do -- get ids and has_more flag getIds (Just ids) = (False,) - <$> Data.conversationIdsOf + <$> Data.localConversationIdsOf user (fromCommaSeparatedList (fromRange ids)) getIds Nothing = do @@ -208,8 +238,7 @@ getConversationsInternal user mids mstart msize = do | Data.isConvDeleted c = Data.deleteConversation (Data.convId c) >> pure False | otherwise = pure True --- FUTUREWORK: pagination support for remote conversations, or should *all* of them be returned always? --- FUTUREWORK: optimize cassandra requests when retrieving conversations (avoid large IN queries, prefer parallel/chunked requests) +-- | Deprecated. FUTUREWORK(federation): Delete this endpoint listConversations :: UserId -> Public.ListConversations -> Galley (Public.ConversationList Public.Conversation) listConversations user (Public.ListConversations mIds qstart msize) = do localDomain <- viewFederationDomain @@ -242,7 +271,7 @@ listConversations user (Public.ListConversations mIds qstart msize) = do size = fromMaybe (toRange (Proxy @32)) msize getIdsAndMore :: [ConvId] -> Galley (Bool, [ConvId]) - getIdsAndMore ids = (False,) <$> Data.conversationIdsOf user ids + getIdsAndMore ids = (False,) <$> Data.localConversationIdsOf user ids getAll :: Maybe ConvId -> Galley (Bool, [ConvId]) getAll mstart = do @@ -255,6 +284,52 @@ listConversations user (Public.ListConversations mIds qstart msize) = do | Data.isConvDeleted c = Data.deleteConversation (Data.convId c) >> pure False | otherwise = pure True +listConversationsV2 :: UserId -> Public.ListConversationsV2 -> Galley Public.ConversationsResponse +listConversationsV2 user (Public.ListConversationsV2 ids) = do + localDomain <- viewFederationDomain + + let (remoteIds, localIds) = partitionRemoteOrLocalIds' localDomain (fromRange ids) + (foundLocalIds, notFoundLocalIds) <- foundsAndNotFounds (Data.localConversationIdsOf user) localIds + (foundRemoteIds, locallyNotFoundRemoteIds) <- foundsAndNotFounds (Data.remoteConversationIdOf user) remoteIds + + localInternalConversations <- + Data.conversations foundLocalIds + >>= filterM removeDeleted + >>= filterM (pure . isMember user . Data.convLocalMembers) + localConversations <- mapM (Mapping.conversationView user) localInternalConversations + + (remoteFailures, remoteConversations) <- getRemoteConversationsWithFailures user foundRemoteIds + let fetchedOrFailedRemoteIds = Set.fromList $ map Public.cnvQualifiedId remoteConversations <> remoteFailures + remoteNotFoundRemoteIds = filter (`Set.notMember` fetchedOrFailedRemoteIds) $ map unTagged foundRemoteIds + unless (null remoteNotFoundRemoteIds) $ + -- FUTUREWORK: This implies that the backends are out of sync. Maybe the + -- current user should be considered removed from this conversation at this + -- point. + Logger.warn $ + Logger.msg ("Some locally found conversation ids were not returned by remotes" :: ByteString) + . Logger.field "convIds" (show remoteNotFoundRemoteIds) + + let allConvs = localConversations <> remoteConversations + pure $ + Public.ConversationsResponse + { crFound = allConvs, + crNotFound = + map unTagged locallyNotFoundRemoteIds + <> remoteNotFoundRemoteIds + <> map (`Qualified` localDomain) notFoundLocalIds, + crFailed = remoteFailures + } + where + removeDeleted :: Data.Conversation -> Galley Bool + removeDeleted c + | Data.isConvDeleted c = Data.deleteConversation (Data.convId c) >> pure False + | otherwise = pure True + foundsAndNotFounds :: (Monad m, Eq a) => ([a] -> m [a]) -> [a] -> m ([a], [a]) + foundsAndNotFounds f xs = do + founds <- f xs + let notFounds = xs \\ founds + pure (founds, notFounds) + iterateConversations :: forall a. UserId -> Range 1 500 Int32 -> ([Data.Conversation] -> Galley a) -> Galley [a] iterateConversations uid pageSize handleConvs = go Nothing where diff --git a/services/galley/src/Galley/Data.hs b/services/galley/src/Galley/Data.hs index 32e5797d3f..217a6416d3 100644 --- a/services/galley/src/Galley/Data.hs +++ b/services/galley/src/Galley/Data.hs @@ -58,9 +58,10 @@ module Galley.Data acceptConnect, conversation, conversationIdsFrom, + localConversationIdsOf, + remoteConversationIdOf, localConversationIdsPageFrom, conversationIdRowsForPagination, - conversationIdsOf, conversationMeta, conversations, conversationsRemote, @@ -575,12 +576,22 @@ conversationIdRowsForPagination usr start (fromRange -> max) = Just c -> paginate Cql.selectUserConvsFrom (paramsP Quorum (usr, c) max) Nothing -> paginate Cql.selectUserConvs (paramsP Quorum (Identity usr) max) -conversationIdsOf :: - (MonadClient m, Log.MonadLogger m, MonadThrow m) => - UserId -> - [ConvId] -> - m [ConvId] -conversationIdsOf usr cids = runIdentity <$$> retry x1 (query Cql.selectUserConvsIn (params Quorum (usr, cids))) +-- | Takes a list of conversation ids and returns those found for the given +-- user. +localConversationIdsOf :: forall m. (MonadClient m, MonadUnliftIO m) => UserId -> [ConvId] -> m [ConvId] +localConversationIdsOf usr cids = do + runIdentity <$$> retry x1 (query Cql.selectUserConvsIn (params Quorum (usr, cids))) + +-- | Takes a list of remote conversation ids and splits them by those found for +-- the given user +remoteConversationIdOf :: forall m. (MonadClient m, MonadLogger m, MonadUnliftIO m) => UserId -> [Remote ConvId] -> m [Remote ConvId] +remoteConversationIdOf usr cnvs = do + concat <$$> pooledMapConcurrentlyN 8 findRemoteConvs . Map.assocs . partitionQualified . map unTagged $ cnvs + where + findRemoteConvs :: (Domain, [ConvId]) -> m [Remote ConvId] + findRemoteConvs (domain, remoteConvIds) = do + foundCnvs <- runIdentity <$$> query Cql.selectRemoteConvMembershipIn (params Quorum (usr, domain, remoteConvIds)) + pure $ toRemote . (`Qualified` domain) <$> foundCnvs conversationsRemote :: (MonadClient m) => UserId -> m [Remote ConvId] conversationsRemote usr = do diff --git a/services/galley/src/Galley/Data/Queries.hs b/services/galley/src/Galley/Data/Queries.hs index cdc2bc3128..7bf9ad8c92 100644 --- a/services/galley/src/Galley/Data/Queries.hs +++ b/services/galley/src/Galley/Data/Queries.hs @@ -310,6 +310,9 @@ selectUserRemoteConvs = "select conv_remote_domain, conv_remote_id from user_rem selectRemoteConvMembership :: PrepQuery R (UserId, Domain, ConvId) (Identity UserId) selectRemoteConvMembership = "select user from user_remote_conv where user = ? and conv_remote_domain = ? and conv_remote_id = ?" +selectRemoteConvMembershipIn :: PrepQuery R (UserId, Domain, [ConvId]) (Identity ConvId) +selectRemoteConvMembershipIn = "select conv_remote_id from user_remote_conv where user = ? and conv_remote_domain = ? and conv_remote_id in ?" + deleteUserRemoteConv :: PrepQuery W (UserId, Domain, ConvId) () deleteUserRemoteConv = "delete from user_remote_conv where user = ? and conv_remote_domain = ? and conv_remote_id = ?" diff --git a/services/galley/test/integration/API.hs b/services/galley/test/integration/API.hs index 05423fa558..f9502042fc 100644 --- a/services/galley/test/integration/API.hs +++ b/services/galley/test/integration/API.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} -- This file is part of the Wire Server implementation. @@ -35,7 +36,6 @@ import API.Util import Bilge hiding (timeout) import Bilge.Assert import Brig.Types -import qualified Cassandra as Cql import qualified Control.Concurrent.Async as Async import Control.Lens (at, ix, preview, view, (.~), (?~), (^.)) import Control.Monad.Except (MonadError (throwError)) @@ -59,7 +59,6 @@ import Data.String.Conversions (cs) import qualified Data.Text as T import qualified Data.Text.Ascii as Ascii import Data.Time.Clock (getCurrentTime) -import qualified Galley.Data as Cql import Galley.Options (Opts, optFederator) import Galley.Types hiding (InternalMember (..)) import Galley.Types.Conversations.Roles @@ -153,7 +152,10 @@ tests s = test s "fail to add members when not connected" postMembersFail, test s "fail to add too many members" postTooManyMembersFail, test s "add remote members" testAddRemoteMember, - test s "get and list remote conversations" testGetRemoteConversations, + test s "get conversations/:domain/:cnv - local" testGetQualifiedLocalConv, + test s "get conversations/:domain/:cnv - remote" testGetQualifiedRemoteConv, + test s "post list-conversations" testListRemoteConvs, + test s "post conversations/list/v2" testBulkGetQualifiedConvs, test s "add non-existing remote members" testAddRemoteMemberFailure, test s "add deleted remote members" testAddDeletedRemoteUser, test s "add remote members on invalid domain" testAddRemoteMemberInvalidDomain, @@ -1829,8 +1831,60 @@ testAddRemoteMember = do toJSON [mkProfile bob (Name "bob")] | otherwise = toJSON () -testGetRemoteConversations :: TestM () -testGetRemoteConversations = do +testGetQualifiedLocalConv :: TestM () +testGetQualifiedLocalConv = do + alice <- randomUser + convId <- decodeQualifiedConvId <$> postConv alice [] (Just "gossip") [] Nothing Nothing + conv :: Conversation <- fmap responseJsonUnsafe $ getConvQualified alice convId (pure respAll (pure respOne (pure respAll (pure respAll actual) - assertEqual - "self member mismatch" - (Just . cmSelf $ cnvMembers expected) - (cmSelf . cnvMembers <$> actual) - assertEqual - "other members mismatch" - (Just []) - ((\c -> cmOthers (cnvMembers c) \\ cmOthers (cnvMembers expected)) <$> actual) + assertEqual "conversations" (Just expected) actual assertEqual "expecting two conversation: Alice's self conversation and remote one with Bob" 2 (length (convList convs)) +-- | Tests getting many converations given their ids. +-- +-- In this test, Alice is a local user, who will be asking for metadata of these +-- conversations: +-- +-- - A local conversation which she is part of +-- +-- - A remote conv on a.far-away.example.com (with Bob) +-- +-- - A remote conv on b.far-away.example.com (with Carl) +-- +-- - A remote conv on a.far-away.example.com, which is not found in the local DB +-- +-- - A remote conv on b.far-away.example.com, it is found in the local DB but +-- the remote does not return it +-- +-- - A remote conv on c.far-away.example.com, for which the federated call fails +-- +-- - A local conversation which doesn't exist +-- +-- - A local conversation which they're not part of +testBulkGetQualifiedConvs :: TestM () +testBulkGetQualifiedConvs = do + localDomain <- viewFederationDomain + aliceQ <- randomQualifiedUser + let alice = qUnqualified aliceQ + bobId <- randomId + carlId <- randomId + let remoteDomainA = Domain "a.far-away.example.com" + remoteDomainB = Domain "b.far-away.example.com" + remoteDomainC = Domain "c.far-away.example.com" + bobQ = Qualified bobId remoteDomainA + carlQ = Qualified carlId remoteDomainB + + localConv <- responseJsonUnsafe <$> postConv alice [] (Just "gossip") [] Nothing Nothing + let localConvId = cnvQualifiedId localConv + + remoteConvIdA <- randomQualifiedId remoteDomainA + remoteConvIdB <- randomQualifiedId remoteDomainB + remoteConvIdALocallyNotFound <- randomQualifiedId remoteDomainA + remoteConvIdBNotFoundOnRemote <- randomQualifiedId remoteDomainB + localConvIdNotFound <- randomQualifiedId localDomain + remoteConvIdCFailure <- randomQualifiedId remoteDomainC + + eve <- randomQualifiedUser + localConvIdNotParticipating <- decodeQualifiedConvId <$> postConv (qUnqualified eve) [] (Just "gossip about alice!") [] Nothing Nothing + + let aliceAsOtherMember = OtherMember aliceQ Nothing roleNameWireAdmin + registerRemoteConv remoteConvIdA bobQ Nothing (Set.fromList [aliceAsOtherMember]) + registerRemoteConv remoteConvIdB carlQ Nothing (Set.fromList [aliceAsOtherMember]) + registerRemoteConv remoteConvIdBNotFoundOnRemote carlQ Nothing (Set.fromList [aliceAsOtherMember]) + registerRemoteConv remoteConvIdCFailure carlQ Nothing (Set.fromList [aliceAsOtherMember]) + + let aliceAsSelfMember = Member (qUnqualified aliceQ) Nothing False Nothing Nothing False Nothing False Nothing roleNameWireAdmin + bobAsOtherMember = OtherMember bobQ Nothing roleNameWireAdmin + carlAsOtherMember = OtherMember carlQ Nothing roleNameWireAdmin + mockConversationA = mkConv remoteConvIdA bobId aliceAsSelfMember [bobAsOtherMember] + mockConversationB = mkConv remoteConvIdB carlId aliceAsSelfMember [carlAsOtherMember] + req = + ListConversationsV2 . unsafeRange $ + [ localConvId, + remoteConvIdA, + remoteConvIdB, + remoteConvIdALocallyNotFound, + localConvIdNotFound, + localConvIdNotParticipating, + remoteConvIdBNotFoundOnRemote, + remoteConvIdCFailure + ] + opts <- view tsGConf + (respAll, receivedRequests) <- + withTempMockFederator' + opts + remoteDomainA + ( \fedReq -> do + let success = pure . F.OutwardResponseBody . LBS.toStrict . encode + case F.domain fedReq of + d | d == domainText remoteDomainA -> success $ GetConversationsResponse [mockConversationA] + d | d == domainText remoteDomainB -> success $ GetConversationsResponse [mockConversationB] + d | d == domainText remoteDomainC -> pure . F.OutwardResponseError $ F.OutwardError F.DiscoveryFailed Nothing + _ -> assertFailure $ "Unrecognized domain: " <> show fedReq + ) + (listConvsV2 alice req) + convs <- responseJsonUnsafe <$> (pure respAll UserId -> ListConversationsV2 -> m ResponseLBS +listConvsV2 u req = do + g <- viewGalley + post $ + g + . path "/conversations/list/v2" + . zUser u + . zConn "conn" + . zType "access" + . json req + getConv :: (MonadIO m, MonadHttp m, HasGalley m, HasCallStack) => UserId -> ConvId -> m ResponseLBS getConv u c = do g <- viewGalley @@ -1128,6 +1141,26 @@ getTeamQueue' zusr msince msize onlyLast = do ] ) +registerRemoteConv :: Qualified ConvId -> Qualified UserId -> Maybe Text -> Set OtherMember -> TestM () +registerRemoteConv convId originUser name othMembers = do + fedGalleyClient <- view tsFedGalleyClient + now <- liftIO getCurrentTime + FederatedGalley.registerConversation + fedGalleyClient + ( FederatedGalley.MkRegisterConversation + { rcTime = now, + rcOrigUserId = originUser, + rcCnvId = convId, + rcCnvType = RegularConv, + rcCnvAccess = [], + rcCnvAccessRole = ActivatedAccessRole, + rcCnvName = name, + rcMembers = othMembers, + rcMessageTimer = Nothing, + rcReceiptMode = Nothing + } + ) + ------------------------------------------------------------------------------- -- Common Assertions @@ -1355,7 +1388,10 @@ decodeConvCodeEvent r = case responseJsonUnsafe r of _ -> error "Failed to parse ConversationCode from Event" decodeConvId :: HasCallStack => Response (Maybe Lazy.ByteString) -> ConvId -decodeConvId = qUnqualified . cnvQualifiedId . responseJsonUnsafe +decodeConvId = qUnqualified . decodeQualifiedConvId + +decodeQualifiedConvId :: HasCallStack => Response (Maybe Lazy.ByteString) -> Qualified ConvId +decodeQualifiedConvId = cnvQualifiedId . responseJsonUnsafe decodeConvList :: Response (Maybe Lazy.ByteString) -> [Conversation] decodeConvList = convList . responseJsonUnsafeWithMsg "conversations" @@ -1511,6 +1547,9 @@ randomUser = qUnqualified <$> randomUser' False True True randomQualifiedUser :: HasCallStack => TestM (Qualified UserId) randomQualifiedUser = randomUser' False True True +randomQualifiedId :: MonadIO m => Domain -> m (Qualified (Id a)) +randomQualifiedId domain = flip Qualified domain <$> randomId + randomTeamCreator :: HasCallStack => TestM UserId randomTeamCreator = qUnqualified <$> randomUser' True True True @@ -1811,6 +1850,21 @@ someLastPrekeys = lastPrekey "pQABARn//wKhAFgg1rZEY6vbAnEz+Ern5kRny/uKiIrXTb/usQxGnceV2HADoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==" ] +mkConv :: Qualified ConvId -> UserId -> Member -> [OtherMember] -> Conversation +mkConv cnvId creator selfMember otherMembers = + Conversation + { cnvQualifiedId = cnvId, + cnvType = RegularConv, + cnvCreator = creator, + cnvAccess = [], + cnvAccessRole = ActivatedAccessRole, + cnvName = Just "federated gossip", + cnvMembers = ConvMembers selfMember otherMembers, + cnvTeam = Nothing, + cnvMessageTimer = Nothing, + cnvReceiptMode = Nothing + } + -- | ES is only refreshed occasionally; we don't want to wait for that in tests. refreshIndex :: TestM () refreshIndex = do