diff --git a/changelog.d/5-internal/servantify-teams-notifications b/changelog.d/5-internal/servantify-teams-notifications new file mode 100644 index 0000000000..3d96c6c6cf --- /dev/null +++ b/changelog.d/5-internal/servantify-teams-notifications @@ -0,0 +1 @@ +Migrate `/teams/notifications` to use the Servant library. diff --git a/libs/wire-api/src/Wire/API/Error/Galley.hs b/libs/wire-api/src/Wire/API/Error/Galley.hs index 782c02e023..5019282d33 100644 --- a/libs/wire-api/src/Wire/API/Error/Galley.hs +++ b/libs/wire-api/src/Wire/API/Error/Galley.hs @@ -117,6 +117,8 @@ data GalleyError | UserLegalHoldNotPending | -- Team Member errors BulkGetMemberLimitExceeded + | -- Team Notification errors + InvalidTeamNotificationId deriving (Show, Eq, Generic) deriving (FromJSON, ToJSON) via (CustomEncoded GalleyError) @@ -178,6 +180,8 @@ type instance MapError 'ConvNotFound = 'StaticError 404 "no-conversation" "Conve type instance MapError 'ConvAccessDenied = 'StaticError 403 "access-denied" "Conversation access denied" +type instance MapError 'InvalidTeamNotificationId = 'StaticError 400 "invalid-notification-id" "Could not parse notification id (must be UUIDv1)." + type instance MapError 'MLSNotEnabled = 'StaticError diff --git a/libs/wire-api/src/Wire/API/Notification.hs b/libs/wire-api/src/Wire/API/Notification.hs index 954cc65d78..e9be75a5d3 100644 --- a/libs/wire-api/src/Wire/API/Notification.hs +++ b/libs/wire-api/src/Wire/API/Notification.hs @@ -42,9 +42,11 @@ module Wire.API.Notification ) where -import Control.Lens (makeLenses) +import Control.Lens (makeLenses, (.~)) +import Control.Lens.Operators ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson.Types as Aeson +import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap import Data.Id import Data.Json.Util import Data.List.NonEmpty (NonEmpty) @@ -73,6 +75,24 @@ modelEvent = Doc.defineModel "NotificationEvent" $ do Doc.property "type" Doc.string' $ Doc.description "Event type" +-- | Schema for an `Event` object. +-- +-- This is basically a schema for a JSON object with some pre-defined structure. +eventSchema :: ValueSchema NamedSwaggerDoc Event +eventSchema = mkSchema sdoc Aeson.parseJSON (Just . Aeson.toJSON) + where + sdoc :: NamedSwaggerDoc + sdoc = + swaggerDoc @Aeson.Object + & S.schema . S.title ?~ "Event" + & S.schema . S.description ?~ "A single notification event" + & S.schema . S.properties + .~ InsOrdHashMap.fromList + [ ( "type", + S.Inline (S.toSchema (Proxy @Text) & S.description ?~ "Event type") + ) + ] + -------------------------------------------------------------------------------- -- QueuedNotification @@ -89,12 +109,15 @@ queuedNotification = QueuedNotification instance ToSchema QueuedNotification where schema = - object "QueuedNotification" $ + objectWithDocModifier "QueuedNotification" queuedNotificationDoc $ QueuedNotification <$> _queuedNotificationId .= field "id" schema <*> _queuedNotificationPayload - .= field "payload" (nonEmptyArray jsonObject) + .= fieldWithDocModifier "payload" payloadDoc (nonEmptyArray eventSchema) + where + queuedNotificationDoc = description ?~ "A single notification" + payloadDoc d = d & description ?~ "List of events" makeLenses ''QueuedNotification @@ -128,14 +151,18 @@ modelNotificationList = Doc.defineModel "NotificationList" $ do instance ToSchema QueuedNotificationList where schema = - object "QueuedNotificationList" $ + objectWithDocModifier "QueuedNotificationList" queuedNotificationListDoc $ QueuedNotificationList <$> _queuedNotifications - .= field "notifications" (array schema) + .= fieldWithDocModifier "notifications" notificationsDoc (array schema) <*> _queuedHasMore - .= fmap (fromMaybe False) (optField "has_more" schema) + .= fmap (fromMaybe False) (optFieldWithDocModifier "has_more" hasMoreDoc schema) <*> _queuedTime .= maybe_ (optField "time" utcTimeSchema) + where + queuedNotificationListDoc = description ?~ "Zero or more notifications" + notificationsDoc = description ?~ "Notifications" + hasMoreDoc = description ?~ "Whether there are still more notifications." makeLenses ''QueuedNotificationList 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 a1d786c15a..2be633e5e7 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -35,6 +35,7 @@ import Wire.API.Routes.Public.Galley.Messaging import Wire.API.Routes.Public.Galley.Team import Wire.API.Routes.Public.Galley.TeamConversation import Wire.API.Routes.Public.Galley.TeamMember +import Wire.API.Routes.Public.Galley.TeamNotification (TeamNotificationAPI) type ServantAPI = ConversationAPI @@ -47,6 +48,7 @@ type ServantAPI = :<|> CustomBackendAPI :<|> LegalHoldAPI :<|> TeamMemberAPI + :<|> TeamNotificationAPI swaggerDoc :: Swagger.Swagger swaggerDoc = toSwagger (Proxy @ServantAPI) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamNotification.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamNotification.hs new file mode 100644 index 0000000000..c5e7f7eeb5 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamNotification.hs @@ -0,0 +1,60 @@ +module Wire.API.Routes.Public.Galley.TeamNotification where + +import Data.Range +import Imports +import Servant +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.Notification +import Wire.API.Routes.Named +import Wire.API.Routes.Public + +type TeamNotificationAPI = + Named + "get-team-notifications" + ( Summary "Read recently added team members from team queue" + :> Description GetTeamNotificationsDescription + :> "teams" + :> "notifications" + :> ZUser + :> CanThrow 'TeamNotFound + :> CanThrow 'InvalidTeamNotificationId + :> QueryParam' + [ Optional, + Strict, + Description "Notification id to start with in the response (UUIDv1)" + ] + "since" + NotificationId + :> QueryParam' + [ Optional, + Strict, + Description "Maximum number of events to return (1..10000; default: 1000)" + ] + "size" + (Range 1 10000 Int32) + :> Get '[Servant.JSON] QueuedNotificationList + ) + +type GetTeamNotificationsDescription = + "This is a work-around for scalability issues with gundeck user event fan-out. \ + \It does not track all team-wide events, but only `member-join`.\ + \\n\ + \Note that `/teams/notifications` behaves differently from `/notifications`:\ + \\n\ + \- If there is a gap between the notification id requested with `since` and the \ + \available data, team queues respond with 200 and the data that could be found. \ + \They do NOT respond with status 404, but valid data in the body.\ + \\n\ + \- The notification with the id given via `since` is included in the \ + \response if it exists. You should remove this and only use it to decide whether \ + \there was a gap between your last request and this one.\ + \\n\ + \- If the notification id does *not* exist, you get the more recent events from the queue \ + \(instead of all of them). This can be done because a notification id is a UUIDv1, which \ + \is essentially a time stamp.\ + \\n\ + \- There is no corresponding `/last` end-point to get only the most recent event. \ + \That end-point was only useful to avoid having to pull the entire queue. In team \ + \queues, if you have never requested the queue before and \ + \have no prior notification id, just pull with timestamp 'now'." diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index e4edd396aa..9a637ce055 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -104,6 +104,7 @@ library Wire.API.Routes.Public.Galley.Team Wire.API.Routes.Public.Galley.TeamConversation Wire.API.Routes.Public.Galley.TeamMember + Wire.API.Routes.Public.Galley.TeamNotification Wire.API.Routes.Public.Gundeck Wire.API.Routes.Public.Proxy Wire.API.Routes.Public.Spar diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 04be5d47a9..1e9e253167 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -54,6 +54,7 @@ library Galley.API.Public.Team Galley.API.Public.TeamConversation Galley.API.Public.TeamMember + Galley.API.Public.TeamNotification Galley.API.Push Galley.API.Query Galley.API.Teams diff --git a/services/galley/src/Galley/API/Error.hs b/services/galley/src/Galley/API/Error.hs index 0beb260031..9f145a482b 100644 --- a/services/galley/src/Galley/API/Error.hs +++ b/services/galley/src/Galley/API/Error.hs @@ -62,14 +62,12 @@ data InvalidInput | InvalidRange LText | InvalidUUID4 | InvalidPayload LText - | InvalidTeamNotificationId instance APIError InvalidInput where toWai CustomRolesNotSupported = badRequest "Custom roles not supported" toWai (InvalidRange t) = invalidRange t toWai InvalidUUID4 = invalidUUID4 toWai (InvalidPayload t) = invalidPayload t - toWai InvalidTeamNotificationId = invalidTeamNotificationId ---------------------------------------------------------------------------- -- Other errors diff --git a/services/galley/src/Galley/API/Public.hs b/services/galley/src/Galley/API/Public.hs index d4e84f32fe..f1183bde27 100644 --- a/services/galley/src/Galley/API/Public.hs +++ b/services/galley/src/Galley/API/Public.hs @@ -28,14 +28,10 @@ import Data.ByteString.Conversion (fromByteString, fromList) import Data.Id import qualified Data.Predicate as P import Data.Qualified -import Data.Range import qualified Data.Set as Set import Data.Swagger.Build.Api hiding (Response, def, min) -import qualified Data.Swagger.Build.Api as Swagger import Data.Text.Encoding (decodeLatin1) -import qualified Galley.API.Error as Error import qualified Galley.API.Query as Query -import qualified Galley.API.Teams as Teams import qualified Galley.API.Teams.Features as Features import Galley.App import Galley.Cassandra.TeamFeatures @@ -62,7 +58,6 @@ import Wire.API.Error import Wire.API.Error.Galley import qualified Wire.API.Event.Team as Public () import qualified Wire.API.Message as Public -import qualified Wire.API.Notification as Public import Wire.API.Routes.API import qualified Wire.API.Swagger as Public.Swagger (models) import Wire.API.Team.Feature @@ -108,51 +103,8 @@ continueE :: Sem r ResponseReceived continueE h = continue (interpretServerEffects @ErrorEffects . h) -errorSResponse :: forall e. KnownError (MapError e) => OperationBuilder -errorSResponse = errorResponse (toWai (dynError @(MapError e))) - sitemap :: Routes ApiBuilder (Sem GalleyEffects) () sitemap = do - get "/teams/notifications" (continueE Teams.getTeamNotificationsH) $ - zauthUserId - .&. opt (query "since") - .&. def (unsafeRange 1000) (query "size") - .&. accept "application" "json" - document "GET" "getTeamNotifications" $ do - summary "Read recently added team members from team queue" - notes - "This is a work-around for scalability issues with gundeck user event fan-out. \ - \It does not track all team-wide events, but only `member-join`.\ - \\n\ - \Note that `/teams/notifications` behaves different from `/notifications`:\ - \\n\ - \- If there is a gap between the notification id requested with `since` and the \ - \available data, team queues respond with 200 and the data that could be found. \ - \The do NOT respond with status 404, but valid data in the body.\ - \\n\ - \- The notification with the id given via `since` is included in the \ - \response if it exists. You should remove this and only use it to decide whether \ - \there was a gap between your last request and this one.\ - \\n\ - \- If the notification id does *not* exist, you get the more recent events from the queue \ - \(instead of all of them). This can be done because a notification id is a UUIDv1, which \ - \is essentially a time stamp.\ - \\n\ - \- There is no corresponding `/last` end-point to get only the most recent event. \ - \That end-point was only useful to avoid having to pull the entire queue. In team \ - \queues, if you have never requested the queue before and \ - \have no prior notification id, just pull with timestamp 'now'." - parameter Query "since" bytes' $ do - optional - description "Notification id to start with in the response (UUIDv1)" - parameter Query "size" (int32 (Swagger.def 1000)) $ do - optional - description "Maximum number of events to return (1..10000; default: 1000)" - returns (ref Public.modelNotificationList) - response 200 "List of team notifications" end - errorSResponse @'TeamNotFound - errorResponse Error.invalidTeamNotificationId - -- Bot API ------------------------------------------------------------ get "/bot/conversation" (continueE (getBotConversationH @Cassandra)) $ diff --git a/services/galley/src/Galley/API/Public/Servant.hs b/services/galley/src/Galley/API/Public/Servant.hs index e7eae6adde..86e223c522 100644 --- a/services/galley/src/Galley/API/Public/Servant.hs +++ b/services/galley/src/Galley/API/Public/Servant.hs @@ -27,6 +27,7 @@ import Galley.API.Public.Messaging import Galley.API.Public.Team import Galley.API.Public.TeamConversation import Galley.API.Public.TeamMember +import Galley.API.Public.TeamNotification import Galley.App import Wire.API.Routes.API import Wire.API.Routes.Public.Galley @@ -43,3 +44,4 @@ servantSitemap = <@> customBackendAPI <@> legalHoldAPI <@> teamMemberAPI + <@> teamNotificationAPI diff --git a/services/galley/src/Galley/API/Public/TeamNotification.hs b/services/galley/src/Galley/API/Public/TeamNotification.hs new file mode 100644 index 0000000000..5cdade2c06 --- /dev/null +++ b/services/galley/src/Galley/API/Public/TeamNotification.hs @@ -0,0 +1,56 @@ +module Galley.API.Public.TeamNotification where + +import Data.Id +import Data.Range +import qualified Data.UUID.Util as UUID +import qualified Galley.API.Teams.Notifications as APITeamQueue +import Galley.App +import Galley.Effects +import Imports +import Polysemy +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.Internal.Notification +import Wire.API.Routes.API +import Wire.API.Routes.Public.Galley.TeamNotification + +teamNotificationAPI :: API TeamNotificationAPI GalleyEffects +teamNotificationAPI = + mkNamedAPI @"get-team-notifications" getTeamNotifications + +type SizeRange = Range 1 10000 Int32 + +-- | See also: 'Gundeck.API.Public.paginateH', but the semantics of this end-point is slightly +-- less warped. This is a work-around because we cannot send events to all of a large team. +-- See haddocks of module "Galley.API.TeamNotifications" for details. +getTeamNotifications :: + Members + '[ BrigAccess, + ErrorS 'TeamNotFound, + ErrorS 'InvalidTeamNotificationId, + TeamNotificationStore + ] + r => + UserId -> + Maybe NotificationId -> + Maybe SizeRange -> + Sem r QueuedNotificationList +getTeamNotifications uid since size = do + since' <- checkSince since + APITeamQueue.getTeamNotifications + uid + since' + (fromMaybe defaultSize size) + where + checkSince :: + Member (ErrorS 'InvalidTeamNotificationId) r => + Maybe NotificationId -> + Sem r (Maybe NotificationId) + checkSince Nothing = pure Nothing + checkSince (Just nid) + | (UUID.version . toUUID) nid == 1 = + (pure . Just) nid + checkSince (Just _) = throwS @'InvalidTeamNotificationId + + defaultSize :: SizeRange + defaultSize = unsafeRange 1000 diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index fcad2b51e5..89a8ca7481 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -30,7 +30,6 @@ module Galley.API.Teams deleteTeam, uncheckedDeleteTeam, addTeamMember, - getTeamNotificationsH, getTeamConversationRoles, getTeamMembers, getTeamMembersCSV, @@ -83,8 +82,6 @@ import Data.Qualified import Data.Range as Range import qualified Data.Set as Set import Data.Time.Clock (UTCTime) -import qualified Data.UUID as UUID -import qualified Data.UUID.Util as UUID import Galley.API.Error as Galley import Galley.API.LegalHold import qualified Galley.API.Teams.Notifications as APITeamQueue @@ -116,7 +113,6 @@ import Galley.Types.Teams.Intra import Galley.Types.UserList import Imports hiding (forkIO) import Network.Wai -import Network.Wai.Predicate hiding (Error, or, result, setStatus) import Network.Wai.Utilities hiding (Error) import Polysemy import Polysemy.Error @@ -135,7 +131,6 @@ import Wire.API.Event.Team import Wire.API.Federation.API import Wire.API.Federation.Error import qualified Wire.API.Message as Conv -import qualified Wire.API.Notification as Public import Wire.API.Routes.MultiTablePaging (MultiTablePage (MultiTablePage), MultiTablePagingState (mtpsState)) import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team @@ -1334,40 +1329,6 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) memList = (userRecipient (n ^. userId)) (membersToRecipients Nothing (memList ^. teamMembers)) --- | See also: 'Gundeck.API.Public.paginateH', but the semantics of this end-point is slightly --- less warped. This is a work-around because we cannot send events to all of a large team. --- See haddocks of module "Galley.API.TeamNotifications" for details. -getTeamNotificationsH :: - Members - '[ BrigAccess, - ErrorS 'TeamNotFound, - Error InvalidInput, - TeamNotificationStore - ] - r => - UserId - ::: Maybe ByteString {- NotificationId -} - ::: Range 1 10000 Int32 - ::: JSON -> - Sem r Response -getTeamNotificationsH (zusr ::: sinceRaw ::: size ::: _) = do - since <- parseSince - json @Public.QueuedNotificationList - <$> APITeamQueue.getTeamNotifications zusr since size - where - parseSince :: Member (Error InvalidInput) r => Sem r (Maybe Public.NotificationId) - parseSince = maybe (pure Nothing) (fmap Just . parseUUID) sinceRaw - - parseUUID :: Member (Error InvalidInput) r => ByteString -> Sem r Public.NotificationId - parseUUID raw = - maybe - (throw InvalidTeamNotificationId) - (pure . Id) - ((UUID.fromASCIIBytes >=> isV1UUID) raw) - - isV1UUID :: UUID.UUID -> Maybe UUID.UUID - isV1UUID u = if UUID.version u == 1 then Just u else Nothing - finishCreateTeam :: Members '[GundeckAccess, Input UTCTime, TeamStore] r => Team ->