Skip to content

Commit 37b543a

Browse files
committed
Unify Verb/NoContentVerb HasClient instances via MultiVerb delegation
HasClient instances become thin wrappers around the MultiVerb one. Introduced a NPToHList class as a compatiblity layer between Verb / MultiVerb for header handling. Breaking change: Response header handling is now stricter in clients, matching MultiVerb behavior. Before: Missing/malformed headers returned MissingHeader/UndecodableHeader constructors - callers could inspect and handle gracefully. After: Missing/malformed headers fail the request immediately with "Failed to parse headers". In a way, this enforces the API contract: if a header is declared in the type, it must be present and valid. Use Optional headers if truly optional.
1 parent 9206689 commit 37b543a

File tree

2 files changed

+76
-118
lines changed

2 files changed

+76
-118
lines changed

servant-client-core/src/Servant/Client/Core/HasClient.hs

Lines changed: 48 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import qualified Data.Text as T
3535
import qualified Data.Text as Text
3636
import Data.Text.Encoding (encodeUtf8)
3737
import Data.Typeable
38-
import GHC.TypeLits (KnownNat, KnownSymbol, TypeError, symbolVal)
38+
import GHC.TypeLits (KnownSymbol, TypeError, symbolVal)
3939
import Network.HTTP.Media (MediaType, matches, parseAccept)
4040
import qualified Network.HTTP.Media as M
4141
import qualified Network.HTTP.Media as Media
@@ -98,7 +98,6 @@ import Servant.API.ContentTypes
9898
( AllMime (allMime)
9999
, AllMimeUnrender (allMimeUnrender)
100100
, EventStream
101-
, contentTypes
102101
)
103102
import Servant.API.Generic
104103
( GenericMode (..)
@@ -119,7 +118,7 @@ import Servant.API.ServerSentEvents
119118
( EventKind (JsonEvent, RawEvent)
120119
, ServerSentEvents'
121120
)
122-
import Servant.API.Status (statusFromNat)
121+
import Servant.API.Status (KnownStatus)
123122
import Servant.API.Stream (NoFraming)
124123
import Servant.API.TypeErrors
125124
import Servant.API.TypeLevel (AtMostOneFragment, FragmentUnique)
@@ -299,129 +298,65 @@ instance
299298
hoistClientMonad pm _ f cl = hoistClientMonad pm (Proxy :: Proxy sublayout) f . cl
300299

301300
instance
302-
-- Note [Non-Empty Content Types]
303-
304301
{-# OVERLAPPABLE #-}
305-
( KnownNat status
306-
, MimeUnrender ct a
302+
( HasClientContentCheck ctypes
307303
, ReflectMethod method
304+
, ResponseListUnrender ctypes '[Respond status "" a]
308305
, RunClient m
309-
, cts' ~ (ct ': cts)
310306
)
311-
=> HasClient m (Verb method status cts' a)
307+
=> HasClient m (Verb method status ctypes a)
312308
where
313-
type Client m (Verb method status cts' a) = m a
314-
clientWithRoute _pm Proxy req = do
315-
response <-
316-
runRequestAcceptStatus
317-
(Just [status])
318-
req
319-
{ requestAccept = fromList $ toList accept
320-
, requestMethod = method
321-
}
322-
response `decodedAs` (Proxy :: Proxy ct)
323-
where
324-
accept = contentTypes (Proxy :: Proxy ct)
325-
method = reflectMethod (Proxy :: Proxy method)
326-
status = statusFromNat (Proxy :: Proxy status)
327-
309+
type Client m (Verb method status ctypes a) = m a
310+
clientWithRoute pm Proxy = clientWithRoute pm (Proxy @(MultiVerb method ctypes '[Respond status "" a] a))
328311
hoistClientMonad _ _ f = f
329312

330313
instance
331314
{-# OVERLAPPING #-}
332-
( KnownNat status
315+
( KnownStatus status
333316
, ReflectMethod method
334317
, RunClient m
335318
)
336-
=> HasClient m (Verb method status cts NoContent)
319+
=> HasClient m (Verb method status ctypes NoContent)
337320
where
338-
type
339-
Client m (Verb method status cts NoContent) =
340-
m NoContent
341-
clientWithRoute _pm Proxy req = do
342-
_response <- runRequestAcceptStatus (Just [status]) req{requestMethod = method}
343-
pure NoContent
344-
where
345-
method = reflectMethod (Proxy :: Proxy method)
346-
status = statusFromNat (Proxy :: Proxy status)
347-
348-
hoistClientMonad _ _ f = f
349-
350-
instance
351-
(ReflectMethod method, RunClient m)
352-
=> HasClient m (NoContentVerb method)
353-
where
354-
type
355-
Client m (NoContentVerb method) =
356-
m NoContent
357-
clientWithRoute _pm Proxy req = do
358-
_response <- runRequest req{requestMethod = method}
359-
pure NoContent
360-
where
361-
method = reflectMethod (Proxy :: Proxy method)
362-
321+
type Client m (Verb method status ctypes NoContent) = m NoContent
322+
clientWithRoute pm Proxy req =
323+
NoContent
324+
<$ clientWithRoute pm (Proxy @(MultiVerb method '() '[RespondAs '() status "" ()] ())) req
363325
hoistClientMonad _ _ f = f
364326

365327
instance
366-
-- Note [Non-Empty Content Types]
367-
368328
{-# OVERLAPPING #-}
369-
( BuildHeadersTo ls
370-
, KnownNat status
371-
, MimeUnrender ct a
329+
( HasClientContentCheck ctypes
372330
, ReflectMethod method
331+
, ResponseListUnrender ctypes '[WithHeaders h (Headers h a) (Respond status "" a)]
373332
, RunClient m
374-
, cts' ~ (ct ': cts)
375333
)
376-
=> HasClient m (Verb method status cts' (Headers ls a))
334+
=> HasClient m (Verb method status ctypes (Headers h a))
377335
where
378-
type
379-
Client m (Verb method status cts' (Headers ls a)) =
380-
m (Headers ls a)
381-
clientWithRoute _pm Proxy req = do
382-
response <-
383-
runRequestAcceptStatus
384-
(Just [status])
385-
req
386-
{ requestMethod = method
387-
, requestAccept = fromList $ toList accept
388-
}
389-
val <- response `decodedAs` (Proxy :: Proxy ct)
390-
pure $
391-
Headers
392-
{ getResponse = val
393-
, getHeadersHList = buildHeadersTo . toList $ responseHeaders response
394-
}
395-
where
396-
method = reflectMethod (Proxy :: Proxy method)
397-
accept = contentTypes (Proxy :: Proxy ct)
398-
status = statusFromNat (Proxy :: Proxy status)
399-
336+
type Client m (Verb method status ctypes (Headers h a)) = m (Headers h a)
337+
clientWithRoute pm Proxy = clientWithRoute pm (Proxy @(MultiVerb method ctypes '[WithHeaders h (Headers h a) (Respond status "" a)] (Headers h a)))
400338
hoistClientMonad _ _ f = f
401339

402340
instance
403341
{-# OVERLAPPING #-}
404-
( BuildHeadersTo ls
405-
, KnownNat status
406-
, ReflectMethod method
342+
( ReflectMethod method
343+
, ResponseListUnrender '() '[WithHeaders h (Headers h NoContent) (RespondAs '() status "" ())]
407344
, RunClient m
408345
)
409-
=> HasClient m (Verb method status cts (Headers ls NoContent))
346+
=> HasClient m (Verb method status ctypes (Headers h NoContent))
410347
where
411-
type
412-
Client m (Verb method status cts (Headers ls NoContent)) =
413-
m (Headers ls NoContent)
414-
clientWithRoute _pm Proxy req = do
415-
response <- runRequestAcceptStatus (Just [status]) req{requestMethod = method}
416-
pure $
417-
Headers
418-
{ getResponse = NoContent
419-
, getHeadersHList = buildHeadersTo . toList $ responseHeaders response
420-
}
421-
where
422-
method = reflectMethod (Proxy :: Proxy method)
423-
status = statusFromNat (Proxy :: Proxy status)
348+
type Client m (Verb method status ctypes (Headers h NoContent)) = m (Headers h NoContent)
349+
clientWithRoute pm Proxy = clientWithRoute pm (Proxy @(MultiVerb method '() '[WithHeaders h (Headers h NoContent) (RespondAs '() status "" ())] (Headers h NoContent)))
350+
hoistClientMonad _ _ f = f
424351

352+
instance
353+
(ReflectMethod method, RunClient m)
354+
=> HasClient m (NoContentVerb method)
355+
where
356+
type Client m (NoContentVerb method) = m NoContent
357+
clientWithRoute pm Proxy req =
358+
NoContent
359+
<$ clientWithRoute pm (Proxy @(MultiVerb method '() '[RespondAs '() 204 "" ()] ())) req
425360
hoistClientMonad _ _ f = f
426361

427362
data ClientParseError = ClientParseError MediaType String | ClientStatusMismatch | ClientNoMatchingStatus
@@ -1226,9 +1161,21 @@ x // f = f x
12261161
(/:) :: (a -> b -> c) -> b -> a -> c
12271162
(/:) = flip
12281163

1164+
class HasClientContentCheck cs where
1165+
clientAcceptList :: Proxy cs -> [M.MediaType]
1166+
clientContentTypeOk :: Proxy cs -> M.MediaType -> Bool
1167+
1168+
instance AllMime cs => HasClientContentCheck cs where
1169+
clientAcceptList = allMime
1170+
clientContentTypeOk p c = any (M.matches c) (allMime p)
1171+
1172+
instance HasClientContentCheck '() where
1173+
clientAcceptList _ = []
1174+
clientContentTypeOk _ _ = True
1175+
12291176
instance
1230-
( AllMime cs
1231-
, AsUnion as r
1177+
( AsUnion as r
1178+
, HasClientContentCheck cs
12321179
, ReflectMethod method
12331180
, ResponseListUnrender cs as
12341181
, RunClient m
@@ -1247,7 +1194,7 @@ instance
12471194
}
12481195

12491196
c <- getResponseContentType response
1250-
unless (any (M.matches c) accept) $ do
1197+
unless (clientContentTypeOk (Proxy @cs) c) $ do
12511198
throwClientError $ UnsupportedContentType c response
12521199

12531200
-- NOTE: support streaming in the future
@@ -1260,7 +1207,7 @@ instance
12601207
UnrenderError e -> throwClientError (DecodeFailure (Text.pack e) response)
12611208
UnrenderSuccess x -> pure (fromUnion @as x)
12621209
where
1263-
accept = allMime (Proxy @cs)
1210+
accept = clientAcceptList (Proxy @cs)
12641211
method = reflectMethod (Proxy @method)
12651212

12661213
hoistClientMonad _ _ f = f
@@ -1301,23 +1248,6 @@ checkContentTypeHeader response =
13011248
Nothing -> throwClientError $ InvalidContentTypeHeader response
13021249
Just t' -> pure t'
13031250

1304-
decodedAs
1305-
:: forall ct a m
1306-
. (MimeUnrender ct a, RunClient m)
1307-
=> Response
1308-
-> Proxy ct
1309-
-> m a
1310-
decodedAs response@Response{responseBody = body} ct = do
1311-
responseContentType <- checkContentTypeHeader response
1312-
unless (any (matches responseContentType) accept) $
1313-
throwClientError $
1314-
UnsupportedContentType responseContentType response
1315-
case mimeUnrender ct body of
1316-
Left err -> throwClientError $ DecodeFailure (T.pack err) response
1317-
Right val -> pure val
1318-
where
1319-
accept = toList $ contentTypes ct
1320-
13211251
-------------------------------------------------------------------------------
13221252
-- Custom type errors
13231253
-------------------------------------------------------------------------------

servant/src/Servant/API/MultiVerb.hs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ import Generics.SOP as GSOP
5555
import Network.HTTP.Types as HTTP
5656
import Web.HttpApiData (FromHttpApiData, ToHttpApiData, parseHeader, toHeader)
5757

58+
import Servant.API.ContentTypes (NoContent (..))
5859
import Servant.API.Header (Header')
60+
import Servant.API.ResponseHeaders (HList (..), Headers (..), ResponseHeader (..))
5961
import Servant.API.Stream (SourceIO)
6062
import Servant.API.TypeLevel.List
6163
import Servant.API.UVerb.Union (Union)
@@ -155,6 +157,32 @@ instance AsHeaders '[a, b] () (a, b) where
155157
toHeaders (h1, h2) = (I h1 :* I h2 :* Nil, ())
156158
fromHeaders (I h1 :* I h2 :* Nil, ()) = (h1, h2)
157159

160+
-- | Convert between NP I xs (n-ary product of values) and HList hs (ResponseHeader-wrapped values)
161+
-- The functional dependency hs -> xs means: given a header spec list, we know the value types
162+
class NPToHList xs hs | hs -> xs where
163+
npToHList :: NP I xs -> HList hs
164+
hlistToNP :: HList hs -> NP I xs
165+
166+
instance NPToHList '[] '[] where
167+
npToHList Nil = HNil
168+
hlistToNP HNil = Nil
169+
170+
instance NPToHList xs hs => NPToHList (x ': xs) (Header' mods name x ': hs) where
171+
npToHList (I x :* rest) = Header x `HCons` npToHList rest
172+
hlistToNP (Header x `HCons` rest) = I x :* hlistToNP rest
173+
hlistToNP (MissingHeader `HCons` _) = error "NPToHList: MissingHeader (should not happen)"
174+
hlistToNP (UndecodableHeader _ `HCons` _) = error "NPToHList: UndecodableHeader (should not happen)"
175+
176+
-- | Headers from Servant.API.ResponseHeaders, for backward compatibility with Verb
177+
instance {-# OVERLAPPABLE #-} NPToHList xs hs => AsHeaders xs a (Headers hs a) where
178+
fromHeaders (np, body) = Headers{getResponse = body, getHeadersHList = npToHList np}
179+
toHeaders (Headers body hlist) = (hlistToNP hlist, body)
180+
181+
-- | Special case for NoContent body - the underlying response is () but we return NoContent
182+
instance {-# OVERLAPPING #-} NPToHList xs hs => AsHeaders xs () (Headers hs NoContent) where
183+
fromHeaders (np, ()) = Headers{getResponse = NoContent, getHeadersHList = npToHList np}
184+
toHeaders (Headers NoContent hlist) = (hlistToNP hlist, ())
185+
158186
data DescHeader (name :: Symbol) (description :: Symbol) (a :: Type)
159187

160188
-- | A wrapper to turn a response header into an optional one.

0 commit comments

Comments
 (0)