diff --git a/CHANGELOG.md b/CHANGELOG.md index 54cc5d482f0..7d99b410e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ Upgrade nginz (#1658) ## Documentation +* Improved Swagger documentation for endpoints with multiple responses (#1649). + ## Internal changes * The servant-swagger dependency now points to the current upstream master (#1656). @@ -103,6 +105,7 @@ This release requires a manual change in your galley configuration: `settings.co * [Federation] Adjust scripts under ./hack/federation to work with recent changes to the federation API (#1632). * Refactored Proteus endpoint to work with qualified users (#1634). * Refactored Federator InternalServer (#1637) +* Introduced `MultiVerb` endpoints in Servant API (#1649). ### Internal Federation API changes diff --git a/libs/metrics-wai/src/Data/Metrics/Servant.hs b/libs/metrics-wai/src/Data/Metrics/Servant.hs index e0825e369c2..b1398b9cb4c 100644 --- a/libs/metrics-wai/src/Data/Metrics/Servant.hs +++ b/libs/metrics-wai/src/Data/Metrics/Servant.hs @@ -97,7 +97,7 @@ instance where getRoutes = [Node (Left (cs (":" <> symbolVal (Proxy @capture)))) (getRoutes @segs)] --- route :> routes +-- route :<|> routes instance {-# OVERLAPPING #-} ( RoutesToPaths route, @@ -107,75 +107,6 @@ instance where getRoutes = getRoutes @route <> getRoutes @routes --- stuff to ignore -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'HEAD status ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'GET status ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'POST status ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'PUT status ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'DELETE status ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (Verb 'PATCH status ctypes content) where - getRoutes = [] - -instance RoutesToPaths (NoContentVerb 'DELETE) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'HEAD ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'GET ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'POST ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'PUT ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'DELETE ctypes content) where - getRoutes = [] - -instance {-# OVERLAPPING #-} RoutesToPaths (UVerb 'PATCH ctypes content) where - getRoutes = [] - -instance RoutesToPaths Raw where - getRoutes = [] - -instance - {-# OVERLAPPING #-} - ( RoutesToPaths segs - ) => - RoutesToPaths (ReqBody ctypes content :> segs) - where - getRoutes = getRoutes @segs - -instance - {-# OVERLAPPING #-} - ( KnownSymbol sym, - RoutesToPaths segs - ) => - RoutesToPaths (Header sym content :> segs) - where - getRoutes = getRoutes @segs - -instance - {-# OVERLAPPING #-} - ( KnownSymbol sym, - RoutesToPaths segs - ) => - RoutesToPaths (QueryParam sym content :> segs) - where - getRoutes = getRoutes @segs - instance {-# OVERLAPPABLE #-} ( RoutesToPaths segs @@ -183,3 +114,6 @@ instance RoutesToPaths (anything :> segs) where getRoutes = getRoutes @segs + +instance {-# OVERLAPPABLE #-} RoutesToPaths anything where + getRoutes = [] diff --git a/libs/wire-api/package.yaml b/libs/wire-api/package.yaml index 24196663ab0..53dd3dbfecb 100644 --- a/libs/wire-api/package.yaml +++ b/libs/wire-api/package.yaml @@ -56,8 +56,11 @@ library: - proto-lens - QuickCheck >=2.14 - quickcheck-instances >=0.3.16 + - resourcet - saml2-web-sso - servant + - servant-client + - servant-client-core - servant-multipart - servant-server - servant-swagger diff --git a/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs b/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs new file mode 100644 index 00000000000..09e6ba60313 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs @@ -0,0 +1,493 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2021 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.MultiVerb + ( -- * MultiVerb types + MultiVerb, + Respond, + RespondEmpty, + WithHeaders, + DescHeader, + AsHeaders (..), + AsUnion (..), + IsResponse (..), + IsResponseList (..), + ) +where + +import Control.Applicative +import Control.Lens hiding (Context) +import qualified Data.ByteString.Lazy as LBS +import Data.Containers.ListUtils +import Data.HashMap.Strict.InsOrd (InsOrdHashMap) +import Data.Proxy +import Data.SOP +import qualified Data.Sequence as Seq +import qualified Data.Swagger as S +import qualified Data.Swagger.Declare as S +import qualified Data.Text as Text +import GHC.TypeLits +import Imports +import qualified Network.HTTP.Media as M +import Network.HTTP.Types (HeaderName, hContentType) +import Network.HTTP.Types.Status +import qualified Network.Wai as Wai +import Servant.API +import Servant.API.ContentTypes +import Servant.API.ResponseHeaders +import Servant.API.Status (KnownStatus (..)) +import Servant.Client +import Servant.Client.Core +import Servant.Server +import Servant.Server.Internal +import Servant.Swagger as S +import Servant.Swagger.Internal as S + +type Declare = S.Declare (S.Definitions S.Schema) + +-- | A type to describe a 'MultiVerb' response. +-- +-- Includes status code, description, and return type. +data Respond (s :: Nat) (desc :: Symbol) (a :: *) + +-- | A type to describe a 'MultiVerb' response with an empty body. +-- +-- Includes status code and description. +data RespondEmpty (s :: Nat) (desc :: Symbol) + +data ResponseSwagger = ResponseSwagger + { rsDescription :: Text, + rsStatus :: Status, + rsHeaders :: InsOrdHashMap S.HeaderName S.Header, + rsSchema :: Maybe (S.Referenced S.Schema) + } + +data RenderOutput = RenderOutput + { roStatus :: Status, + roBody :: LByteString, + roHeaders :: [(HeaderName, ByteString)] + } + +-- | The result of parsing a response as a union alternative of type 'a'. +-- +-- 'StatusMismatch' indicates that the response does not refer to the given +-- alternative, because the status code does not match the one produced by that +-- alternative. +-- +-- 'UnrenderError' and 'UnrenderSuccess' represent respectively a failing and +-- successful parse of the response body as a value of type 'a'. +-- +-- The 'UnrenderResult' type constructor has monad and alternative instances +-- corresponding to those of 'Either (Maybe (Last String)) a'. +data UnrenderResult a = StatusMismatch | UnrenderError String | UnrenderSuccess a + deriving (Eq, Show, Functor) + +instance Applicative UnrenderResult where + pure = UnrenderSuccess + (<*>) = ap + +instance Monad UnrenderResult where + return = pure + StatusMismatch >>= _ = StatusMismatch + UnrenderError e >>= _ = UnrenderError e + UnrenderSuccess x >>= f = f x + +instance Alternative UnrenderResult where + empty = mzero + (<|>) = mplus + +instance MonadPlus UnrenderResult where + mzero = StatusMismatch + mplus StatusMismatch m = m + mplus (UnrenderError e) StatusMismatch = UnrenderError e + mplus (UnrenderError _) m = m + mplus m@(UnrenderSuccess _) _ = m + +class IsSwaggerResponse a where + responseSwagger :: Declare ResponseSwagger + +class IsResponse cs a where + type ResponseType a :: * + type ResponseStatus a :: Nat + + responseRender :: AcceptHeader -> ResponseType a -> Maybe RenderOutput + responseUnrender :: M.MediaType -> RenderOutput -> UnrenderResult (ResponseType a) + +instance (AllMimeRender cs a, AllMimeUnrender cs a, KnownStatus s) => IsResponse cs (Respond s desc a) where + type ResponseType (Respond s desc a) = a + type ResponseStatus (Respond s desc a) = s + + -- Note: here it seems like we are rendering for all possible content types, + -- only to choose the correct one afterwards. However, render results besides the + -- one picked by 'M.mapAcceptMedia' are not evaluated, and therefore nor are the + -- corresponding rendering functions. + responseRender (AcceptHeader acc) x = + M.mapAcceptMedia (map (uncurry mkRenderOutput) (allMimeRender (Proxy @cs) x)) acc + where + mkRenderOutput :: M.MediaType -> LByteString -> (M.MediaType, RenderOutput) + mkRenderOutput c body = + (c,) . roAddContentType c $ + RenderOutput + { roStatus = statusVal (Proxy @s), + roBody = body, + roHeaders = [] + } + + responseUnrender c output = do + guard (roStatus output == statusVal (Proxy @s)) + let results = allMimeUnrender (Proxy @cs) + case lookup c results of + Nothing -> empty + Just f -> either UnrenderError UnrenderSuccess (f (roBody output)) + +instance + (KnownStatus s, KnownSymbol desc, S.ToSchema a) => + IsSwaggerResponse (Respond s desc a) + where + responseSwagger = + ResponseSwagger desc status mempty . Just + <$> S.declareSchemaRef (Proxy @a) + where + desc = Text.pack (symbolVal (Proxy @desc)) + status = statusVal (Proxy @s) + +instance KnownStatus s => IsResponse cs (RespondEmpty s desc) where + type ResponseType (RespondEmpty s desc) = () + type ResponseStatus (RespondEmpty s desc) = s + + responseRender _ _ = + Just + RenderOutput + { roStatus = statusVal (Proxy @s), + roBody = mempty, + roHeaders = [] + } + + responseUnrender _ output = + guard + ( roStatus output == statusVal (Proxy @s) + && LBS.null (roBody output) + ) + +instance (KnownStatus s, KnownSymbol desc) => IsSwaggerResponse (RespondEmpty s desc) where + responseSwagger = pure $ ResponseSwagger desc status mempty Nothing + where + desc = Text.pack (symbolVal (Proxy @desc)) + status = statusVal (Proxy @s) + +-- | This type adds response headers to a 'MultiVerb' response. +-- +-- Type variables: +-- * @hs@: type-level list of headers +-- * @a@: return type (with headers) +-- * @r@: underlying response (without headers) +data WithHeaders (hs :: [*]) (a :: *) (r :: *) + +-- | This is used to convert a response containing headers to a custom type +-- including the information in the headers. +class AsHeaders hs a b where + fromHeaders :: Headers hs a -> b + toHeaders :: b -> Headers hs a + +instance AsHeaders hs a (Headers hs a) where + fromHeaders = id + toHeaders = id + +data DescHeader (name :: Symbol) (desc :: Symbol) (a :: *) + +-- convert a list of 'Header's to a list of 'Servant.Header's +type family ServantHeaders (hs :: [*]) :: [*] + +type instance ServantHeaders '[] = '[] + +type instance + ServantHeaders (DescHeader name desc a ': hs) = + Header name a ': ServantHeaders hs + +type instance + ServantHeaders (Header name a ': hs) = + Header name a ': ServantHeaders hs + +instance + (KnownSymbol name, KnownSymbol desc, S.ToParamSchema a) => + ToResponseHeader (DescHeader name desc a) + where + toResponseHeader _ = (name, S.Header (Just desc) sch) + where + name = Text.pack (symbolVal (Proxy @name)) + desc = Text.pack (symbolVal (Proxy @desc)) + sch = S.toParamSchema (Proxy @a) + +instance + ( AsHeaders (ServantHeaders hs) (ResponseType r) a, + GetHeaders' (ServantHeaders hs), + BuildHeadersTo (ServantHeaders hs), + AllToResponseHeader hs, + IsResponse cs r + ) => + IsResponse cs (WithHeaders hs a r) + where + type ResponseType (WithHeaders hs a r) = a + type ResponseStatus (WithHeaders hs a r) = ResponseStatus r + + responseRender acc x = + fmap addHeaders + . responseRender @cs @r acc + . getResponse + $ h + where + h = toHeaders @(ServantHeaders hs) x + addHeaders r = r {roHeaders = roHeaders r ++ getHeaders h} + + responseUnrender c output = do + x <- responseUnrender @cs @r c output + let headers = Headers x (buildHeadersTo @(ServantHeaders hs) (roHeaders output)) + pure (fromHeaders headers) + +instance + (AllToResponseHeader hs, IsSwaggerResponse r) => + IsSwaggerResponse (WithHeaders hs a r) + where + responseSwagger = + fmap + (\rs -> rs {rsHeaders = toAllResponseHeaders (Proxy @hs)}) + (responseSwagger @r) + +class IsSwaggerResponseList as where + responseListSwagger :: Declare [ResponseSwagger] + +class IsResponseList cs as where + type ResponseTypes as :: [*] + + responseListRender :: AcceptHeader -> Union (ResponseTypes as) -> Maybe RenderOutput + responseListUnrender :: M.MediaType -> RenderOutput -> UnrenderResult (Union (ResponseTypes as)) + + responseListStatuses :: [Status] + +instance IsResponseList cs '[] where + type ResponseTypes '[] = '[] + + responseListRender _ x = case x of + responseListUnrender _ _ = empty + responseListStatuses = [] + +instance IsSwaggerResponseList '[] where + responseListSwagger = pure [] + +instance + ( IsResponse cs a, + IsResponseList cs as, + KnownStatus (ResponseStatus a) + ) => + IsResponseList cs (a ': as) + where + type ResponseTypes (a ': as) = ResponseType a ': ResponseTypes as + + responseListRender acc (Z (I x)) = responseRender @cs @a acc x + responseListRender acc (S x) = responseListRender @cs @as acc x + + responseListUnrender c output = + Z . I <$> responseUnrender @cs @a c output + <|> S <$> responseListUnrender @cs @as c output + + responseListStatuses = statusVal (Proxy @(ResponseStatus a)) : responseListStatuses @cs @as + +instance + (IsSwaggerResponse a, IsSwaggerResponseList as) => + IsSwaggerResponseList (a ': as) + where + responseListSwagger = (:) <$> responseSwagger @a <*> responseListSwagger @as + +-- | This type can be used in Servant to produce an endpoint which can return +-- multiple values with various content types and status codes. It is similar to +-- 'UVerb' and behaves similarly, but it has some important differences: +-- +-- * Descriptions and statuses can be attached to individual responses without +-- using wrapper types and without affecting the handler return type. +-- * The return type of the handler can be decoupled from the types of the +-- individual responses. One can use a 'Union' type just like for 'UVerb', +-- but 'MultiVerb' also supports using an arbitrary type with an 'AsUnion' +-- instance. +-- * Headers can be attached to individual responses, also without affecting +-- the handler return type. +data MultiVerb (method :: StdMethod) (cs :: [*]) (as :: [*]) (r :: *) + +-- | This class is used to convert a handler return type to a union type +-- including all possible responses of a 'MultiVerb' endpoint. +-- +-- Any glue code necessary to convert application types to and from the +-- canonical 'Union' type corresponding to a 'MultiVerb' endpoint should be +-- packaged into an 'AsUnion' instance. +class AsUnion (as :: [*]) (r :: *) where + toUnion :: r -> Union (ResponseTypes as) + fromUnion :: Union (ResponseTypes as) -> r + +-- | Unions can be used directly as handler return types using this trivial +-- instance. +instance rs ~ ResponseTypes as => AsUnion as (Union rs) where + toUnion = id + fromUnion = id + +-- | A handler for a pair of empty responses can be implemented simply by +-- returning a boolean value. The convention is that the "failure" case, normally +-- represented by 'False', corresponds to the /first/ response. +instance + AsUnion + '[ RespondEmpty s1 desc1, + RespondEmpty s2 desc2 + ] + Bool + where + toUnion False = Z (I ()) + toUnion True = S (Z (I ())) + + fromUnion (Z (I ())) = False + fromUnion (S (Z (I ()))) = True + fromUnion (S (S x)) = case x of + +-- | A handler for a pair of responses where the first is empty can be +-- implemented simply by returning a 'Maybe' value. The convention is that the +-- "failure" case, normally represented by 'Nothing', corresponds to the /first/ +-- response. +instance + (ResponseType r2 ~ a) => + AsUnion + '[RespondEmpty s1 desc1, r2] + (Maybe a) + where + toUnion Nothing = Z (I ()) + toUnion (Just x) = S (Z (I x)) + + fromUnion (Z (I ())) = Nothing + fromUnion (S (Z (I x))) = Just x + fromUnion (S (S x)) = case x of + +instance + (SwaggerMethod method, IsSwaggerResponseList as, AllMime cs) => + S.HasSwagger (MultiVerb method cs as r) + where + toSwagger _ = + mempty + & S.definitions <>~ defs + & S.paths + . at "/" + ?~ ( mempty + & method + ?~ ( mempty + & S.produces ?~ S.MimeList (nubOrd cs) + & S.responses .~ foldr addResponse mempty responses + ) + ) + where + method = S.swaggerMethod (Proxy @method) + cs = allMime (Proxy @cs) + (defs, responses) = S.runDeclare (responseListSwagger @as) mempty + addResponse :: ResponseSwagger -> S.Responses -> S.Responses + addResponse response = + at (statusCode (rsStatus response)) + .~ (Just . S.Inline) + ( mempty + & S.description .~ rsDescription response + & S.schema .~ rsSchema response + & S.headers .~ rsHeaders response + ) + +roResponse :: RenderOutput -> Wai.Response +roResponse ro = Wai.responseLBS (roStatus ro) (roHeaders ro) (roBody ro) + +roAddContentType :: M.MediaType -> RenderOutput -> RenderOutput +roAddContentType c ro = ro {roHeaders = (hContentType, M.renderHeader c) : roHeaders ro} + +instance + (AllMime cs, IsResponseList cs as, AsUnion as r, ReflectMethod method) => + HasServer (MultiVerb method cs as r) ctx + where + type ServerT (MultiVerb method cs as r) m = m r + + hoistServerWithContext _ _ f = f + + route :: + forall env. + Proxy (MultiVerb method cs as r) -> + Context ctx -> + Delayed env (Handler r) -> + Router env + route _ _ action = leafRouter $ \env req k -> do + let acc = getAcceptHeader req + action' = + action `addMethodCheck` methodCheck method req + `addMethodCheck` acceptCheck (Proxy @cs) acc + runAction action' env req k $ \output -> do + let mresp = responseListRender @cs @as acc (toUnion @as output) + resp' <- case mresp of + Nothing -> FailFatal err406 + Just resp + | allowedMethodHead method req -> pure $ resp {roBody = mempty} + | otherwise -> pure resp + pure (roResponse resp') + where + method = reflectMethod (Proxy @method) + +-- taken from Servant.Client.Core.HasClient +getResponseContentType :: RunClient m => Response -> m M.MediaType +getResponseContentType response = + case lookup "Content-Type" (toList (responseHeaders response)) of + Nothing -> return $ "application" M.// "octet-stream" + Just t -> case M.parseAccept t of + Nothing -> throwClientError $ InvalidContentTypeHeader response + Just t' -> return t' + +-- FUTUREWORK: add tests for client support +instance + ( IsResponseList cs as, + AllMime cs, + ReflectMethod method, + AsUnion as r, + RunClient m + ) => + HasClient m (MultiVerb method cs as r) + where + type Client m (MultiVerb method cs as r) = m r + + clientWithRoute _ _ req = do + response <- + runRequestAcceptStatus + (Just (responseListStatuses @cs @as)) + req + { requestMethod = method, + requestAccept = Seq.fromList accept + } + + c <- getResponseContentType response + let output = + RenderOutput + { roBody = responseBody response, + roHeaders = toList (responseHeaders response), + roStatus = responseStatusCode response + } + + unless (any (M.matches c) accept) $ do + throwClientError $ UnsupportedContentType c response + case responseListUnrender @cs @as c output of + StatusMismatch -> throwClientError (DecodeFailure "Status mismatch" response) + UnrenderError e -> throwClientError (DecodeFailure (Text.pack e) response) + UnrenderSuccess x -> pure (fromUnion @as x) + where + accept = allMime (Proxy @cs) + method = reflectMethod (Proxy @method) + + hoistClientMonad _ _ f = f 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 5717583382d..9615706913e 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -34,6 +34,7 @@ import Servant.API.Generic import Servant.Swagger (HasSwagger (toSwagger)) import Servant.Swagger.Internal.Orphans () import Wire.API.ErrorDescription (ClientNotFound) +import Wire.API.Routes.MultiVerb import Wire.API.Routes.Public (EmptyResult, ZConn, ZUser) import Wire.API.User import Wire.API.User.Client @@ -46,6 +47,15 @@ type MaxUsersForListClientsBulk = 500 type CheckUserExistsResponse = [EmptyResult 200, EmptyResult 404] +type UserExistVerb = + MultiVerb + 'HEAD + '[] + '[ RespondEmpty 404 "User not found", + RespondEmpty 200 "User exists" + ] + Bool + type CaptureUserId name = Capture' '[Description "User Id"] name UserId type CaptureClientId name = Capture' '[Description "ClientId"] name ClientId @@ -62,24 +72,14 @@ data Api routes = Api -- https://github.com/haskell-servant/servant/issues/1369 -- See Note [ephemeral user sideeffect] - -- - -- See Note [document responses] - -- The responses looked like this: - -- Doc.response 200 "User exists" Doc.end - -- Doc.errorResponse userNotFound checkUserExistsUnqualified :: routes :- Summary "Check if a user ID exists (deprecated)" :> ZUser :> "users" :> CaptureUserId "uid" - :> UVerb 'HEAD '[] CheckUserExistsResponse, + :> UserExistVerb, -- See Note [ephemeral user sideeffect] - -- - -- See Note [document responses] - -- The responses looked like this: - -- Doc.response 200 "User exists" Doc.end - -- Doc.errorResponse userNotFound checkUserExistsQualified :: routes :- Summary "Check if a user ID exists" @@ -87,7 +87,7 @@ data Api routes = Api :> "users" :> Capture "domain" Domain :> CaptureUserId "uid" - :> UVerb 'HEAD '[] CheckUserExistsResponse, + :> UserExistVerb, -- See Note [ephemeral user sideeffect] -- -- See Note [document responses] 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 907736f5844..c098cbb2547 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -24,29 +24,65 @@ import qualified Data.Code as Code import Data.CommaSeparatedList import Data.Domain import Data.Id (ConvId, TeamId) +import Data.Qualified (Qualified (..)) import Data.Range +import Data.SOP (I (..), NS (..)) import qualified Data.Swagger as Swagger import GHC.TypeLits (AppendSymbol) import Imports hiding (head) -import Servant hiding (Handler, JSON, addHeader, contentType, respond) -import qualified Servant +import Servant import Servant.API.Generic (ToServantApi, (:-)) import Servant.Swagger.Internal import Servant.Swagger.Internal.Orphans () -import qualified Wire.API.Conversation as Public +import Wire.API.Conversation as Public import qualified Wire.API.Conversation.Role as Public import Wire.API.ErrorDescription import qualified Wire.API.Event.Conversation as Public import qualified Wire.API.Message as Public +import Wire.API.Routes.MultiVerb import Wire.API.Routes.Public (EmptyResult, ZConn, ZUser) import Wire.API.ServantProto (Proto, RawProto) import qualified Wire.API.Team.Conversation as Public import Wire.API.Team.Feature -type ConversationResponses = - '[ WithStatus 200 (Headers '[Servant.Header "Location" ConvId] Public.Conversation), - WithStatus 201 (Headers '[Servant.Header "Location" ConvId] Public.Conversation) - ] +instance AsHeaders '[Header "Location" ConvId] Conversation Conversation where + -- FUTUREWORK: use addHeader + toHeaders c = Headers c (HCons (Header (qUnqualified (Public.cnvQualifiedId c))) HNil) + fromHeaders = getResponse + +instance + (ResponseType r1 ~ a, ResponseType r2 ~ a) => + AsUnion '[r1, r2] (ConversationResponseFor a) + where + toUnion (ConversationExisted x) = Z (I x) + toUnion (ConversationCreated x) = S (Z (I x)) + + fromUnion (Z (I x)) = ConversationExisted x + fromUnion (S (Z (I x))) = ConversationCreated x + fromUnion (S (S x)) = case x of + +data ConversationResponseFor a + = ConversationExisted !a + | ConversationCreated !a + +type ConversationResponse = ConversationResponseFor Conversation + +type ConversationHeaders = '[DescHeader "Location" "Conversation ID" ConvId] + +type ConversationVerb = + MultiVerb + 'POST + '[JSON] + '[ WithHeaders + ConversationHeaders + Conversation + (Respond 200 "Conversation existed" Conversation), + WithHeaders + ConversationHeaders + Conversation + (Respond 201 "Conversation created" Conversation) + ] + ConversationResponse type UpdateResponses = '[ WithStatus 200 Public.Event, @@ -181,14 +217,14 @@ data Api routes = Api :> ZConn :> "conversations" :> ReqBody '[Servant.JSON] Public.NewConvUnmanaged - :> UVerb 'POST '[Servant.JSON] ConversationResponses, + :> ConversationVerb, createSelfConversation :: routes :- Summary "Create a self-conversation" :> ZUser :> "conversations" :> "self" - :> UVerb 'POST '[Servant.JSON] ConversationResponses, + :> ConversationVerb, -- This endpoint can lead to the following events being sent: -- - ConvCreate event to members -- TODO: add note: "On 201, the conversation ID is the `Location` header" @@ -200,7 +236,7 @@ data Api routes = Api :> "conversations" :> "one2one" :> ReqBody '[Servant.JSON] Public.NewConvUnmanaged - :> UVerb 'POST '[Servant.JSON] ConversationResponses, + :> ConversationVerb, addMembersToConversationV2 :: routes :- Summary "Add qualified members to an existing conversation." diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index edbc868b298..20b469009c4 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: c12b754866b74213ac22c8738621201c6645b85c5afcf7535ff3bf8a86365e13 +-- hash: 3bc8e60f6ce4290c4b81de62079b21aa4f1e50e1cb64841b90fc6814b88f66f4 name: wire-api version: 0.1.0 @@ -48,6 +48,7 @@ library Wire.API.Provider.Service.Tag Wire.API.Push.Token Wire.API.Push.V2.Token + Wire.API.Routes.MultiVerb Wire.API.Routes.Public Wire.API.Routes.Public.Brig Wire.API.Routes.Public.Galley @@ -134,9 +135,12 @@ library , proto-lens , protobuf >=0.2 , quickcheck-instances >=0.3.16 + , resourcet , saml2-web-sso , schema-profunctor , servant + , servant-client + , servant-client-core , servant-multipart , servant-server , servant-swagger diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index e78a722a3ea..1d665df5618 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -944,17 +944,13 @@ createUser (Public.NewUserPublic new) = do Public.NewTeamMemberSSO _ -> Team.sendMemberWelcomeMail e t n l -checkUserExistsUnqualifiedH :: UserId -> UserId -> Handler (Union BrigAPI.CheckUserExistsResponse) +checkUserExistsUnqualifiedH :: UserId -> UserId -> Handler Bool checkUserExistsUnqualifiedH self uid = do domain <- viewFederationDomain checkUserExistsH self domain uid -checkUserExistsH :: UserId -> Domain -> UserId -> Handler (Union BrigAPI.CheckUserExistsResponse) -checkUserExistsH self domain uid = do - exists <- checkUserExists self (Qualified uid domain) - if exists - then Servant.respond (EmptyResult @200) - else Servant.respond (EmptyResult @404) +checkUserExistsH :: UserId -> Domain -> UserId -> Handler Bool +checkUserExistsH self domain uid = checkUserExists self (Qualified uid domain) checkUserExists :: UserId -> Qualified UserId -> Handler Bool checkUserExists self qualifiedUserId = diff --git a/services/galley/src/Galley/API/Create.hs b/services/galley/src/Galley/API/Create.hs index 6e8de499c9d..45dac5801c6 100644 --- a/services/galley/src/Galley/API/Create.hs +++ b/services/galley/src/Galley/API/Create.hs @@ -21,7 +21,6 @@ module Galley.API.Create createSelfConversation, createOne2OneConversation, createConnectConversationH, - ConversationResponses, ) where @@ -49,23 +48,13 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Predicate hiding (setStatus) import Network.Wai.Utilities -import Servant (WithStatus (..)) -import qualified Servant -import Servant.API (Union) import qualified Wire.API.Conversation as Public -import Wire.API.Routes.Public.Galley (ConversationResponses) +import Wire.API.Routes.Public.Galley + ( ConversationResponse, + ConversationResponseFor (..), + ) import Wire.API.Team.LegalHold (LegalholdProtectee (LegalholdPlusFederationNotImplemented)) --- Servant helpers ------------------------------------------------------ - -conversationResponse :: ConversationResponse -> Galley (Union ConversationResponses) -conversationResponse (ConversationExisted c) = - Servant.respond . WithStatus @200 . Servant.addHeader @"Location" (qUnqualified . cnvQualifiedId $ c) $ c -conversationResponse (ConversationCreated c) = - Servant.respond . WithStatus @201 . Servant.addHeader @"Location" (qUnqualified . cnvQualifiedId $ c) $ c - -------------------------------------------------------------------------- - ---------------------------------------------------------------------------- -- Group conversations @@ -76,9 +65,9 @@ createGroupConversation :: UserId -> ConnId -> Public.NewConvUnmanaged -> - Galley (Union ConversationResponses) + Galley ConversationResponse createGroupConversation user conn wrapped@(Public.NewConvUnmanaged body) = - conversationResponse =<< case newConvTeam body of + case newConvTeam body of Nothing -> createRegularGroupConv user conn wrapped Just tinfo -> createTeamGroupConv user conn tinfo body @@ -199,18 +188,17 @@ createTeamGroupConv zusr zcon tinfo body = do ---------------------------------------------------------------------------- -- Other kinds of conversations -createSelfConversation :: UserId -> Galley (Union ConversationResponses) +createSelfConversation :: UserId -> Galley ConversationResponse createSelfConversation zusr = do c <- Data.conversation (Id . toUUID $ zusr) - conversationResponse - =<< maybe create (conversationExisted zusr) c + maybe create (conversationExisted zusr) c where create = do localDomain <- viewFederationDomain c <- Data.createSelfConversation localDomain zusr Nothing conversationCreated zusr c -createOne2OneConversation :: UserId -> ConnId -> NewConvUnmanaged -> Galley (Union ConversationResponses) +createOne2OneConversation :: UserId -> ConnId -> NewConvUnmanaged -> Galley ConversationResponse createOne2OneConversation zusr zcon (NewConvUnmanaged j) = do otherUserId <- head . fromRange <$> (rangeChecked (newConvUsers j) :: Galley (Range 1 1 [UserId])) (x, y) <- toUUIDs zusr otherUserId @@ -226,8 +214,7 @@ createOne2OneConversation zusr zcon (NewConvUnmanaged j) = do ensureConnected zusr [otherUserId] n <- rangeCheckedMaybe (newConvName j) c <- Data.conversation (Data.one2OneConvId x y) - resp <- maybe (create x y n $ newConvTeam j) (conversationExisted zusr) c - conversationResponse resp + maybe (create x y n $ newConvTeam j) (conversationExisted zusr) c where verifyMembership tid u = do membership <- Data.teamMember tid u @@ -318,10 +305,6 @@ createConnectConversation usr conn j = do ------------------------------------------------------------------------------- -- Helpers -data ConversationResponse - = ConversationCreated !Public.Conversation - | ConversationExisted !Public.Conversation - conversationCreated :: UserId -> Data.Conversation -> Galley ConversationResponse conversationCreated usr cnv = ConversationCreated <$> conversationView usr cnv