Skip to content

Commit

Permalink
Servantify /teams/notifications (#3020)
Browse files Browse the repository at this point in the history
Co-authored-by: Leif Battermann <[email protected]>
Co-authored-by: Leif Battermann <[email protected]>
  • Loading branch information
supersven and battermann authored Feb 8, 2023
1 parent 8051138 commit e8d5492
Show file tree
Hide file tree
Showing 12 changed files with 160 additions and 95 deletions.
1 change: 1 addition & 0 deletions changelog.d/5-internal/servantify-teams-notifications
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Migrate `/teams/notifications` to use the Servant library.
4 changes: 4 additions & 0 deletions libs/wire-api/src/Wire/API/Error/Galley.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
39 changes: 33 additions & 6 deletions libs/wire-api/src/Wire/API/Notification.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions libs/wire-api/src/Wire/API/Routes/Public/Galley.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +48,7 @@ type ServantAPI =
:<|> CustomBackendAPI
:<|> LegalHoldAPI
:<|> TeamMemberAPI
:<|> TeamNotificationAPI

swaggerDoc :: Swagger.Swagger
swaggerDoc = toSwagger (Proxy @ServantAPI)
Original file line number Diff line number Diff line change
@@ -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'."
1 change: 1 addition & 0 deletions libs/wire-api/wire-api.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/galley/galley.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions services/galley/src/Galley/API/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 0 additions & 48 deletions services/galley/src/Galley/API/Public.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)) $
Expand Down
2 changes: 2 additions & 0 deletions services/galley/src/Galley/API/Public/Servant.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,3 +44,4 @@ servantSitemap =
<@> customBackendAPI
<@> legalHoldAPI
<@> teamMemberAPI
<@> teamNotificationAPI
56 changes: 56 additions & 0 deletions services/galley/src/Galley/API/Public/TeamNotification.hs
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit e8d5492

Please sign in to comment.