Skip to content

Commit 6f6238f

Browse files
committed
WaiMultiSession
Like WaiSession but with as many Applications as you want :) Useful for integration tests between distinct services, but in the lightweight hspec-wai style.
1 parent e72b3c2 commit 6f6238f

File tree

8 files changed

+296
-32
lines changed

8 files changed

+296
-32
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
dist/*
22
result/*
3+
result

default.nix

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@ let
55
inherit (nixpkgs) pkgs;
66

77
f = { mkDerivation, base, bytestring, bytestring-conversion
8-
, case-insensitive, hspec, hspec-wai, http-media, http-types, mtl
9-
, servant, servant-server, stdenv, text, wai, wai-extra
8+
, case-insensitive, hspec, hspec-core, hspec-wai, http-media
9+
, http-types, mtl, servant, servant-server, stdenv, text, wai
10+
, wai-extra
1011
}:
1112
mkDerivation {
1213
pname = "hspec-wai-servant";
1314
version = "0.1.0.0";
1415
src = ./.;
1516
libraryHaskellDepends = [
16-
base bytestring bytestring-conversion case-insensitive hspec-wai
17-
http-media http-types servant text wai-extra
17+
base bytestring bytestring-conversion case-insensitive hspec
18+
hspec-core hspec-wai http-media http-types mtl servant text wai
19+
wai-extra
1820
];
1921
testHaskellDepends = [
20-
base bytestring hspec mtl servant servant-server text wai
22+
base bytestring hspec hspec-wai mtl servant servant-server text wai
2123
];
2224
homepage = "https://github.com/ramirez7/hspec-wai-servant";
2325
description = "servant-client generation for hspec-wai";

hspec-wai-servant.cabal

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,25 @@ cabal-version: >=1.10
1717
library
1818
exposed-modules:
1919
Test.Hspec.Wai.Servant
20+
Test.Hspec.Wai.WaiMultiSession
2021
Test.Hspec.Wai.Servant.Assertions
2122
Test.Hspec.Wai.Servant.Client
2223
Test.Hspec.Wai.Servant.Types
2324
ghc-options: -Wall
2425
build-depends:
2526
base >=4.9 && <4.10
27+
, hspec
2628
, hspec-wai
29+
, hspec-core
30+
, wai
2731
, wai-extra
2832
, bytestring
2933
, bytestring-conversion
3034
, case-insensitive
3135
, http-types
3236
, http-media
3337
, text
38+
, mtl
3439
, servant ==0.9.*
3540
hs-source-dirs: src
3641
default-language: Haskell2010
@@ -44,6 +49,7 @@ test-suite spec
4449
build-depends:
4550
base >=4.9 && <4.10
4651
, hspec
52+
, hspec-wai
4753
, servant ==0.9.*
4854
, servant-server ==0.9.*
4955
, bytestring

src/Test/Hspec/Wai/Servant/Assertions.hs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,43 @@ module Test.Hspec.Wai.Servant.Assertions
88
, succeed
99
) where
1010

11-
import Data.Functor (void)
12-
import GHC.Stack (HasCallStack)
13-
import qualified Test.Hspec.Wai as W
11+
import Control.Monad.IO.Class (MonadIO(..))
12+
import Data.Foldable (for_)
13+
import Data.Functor (void)
14+
import GHC.Stack (HasCallStack)
15+
import Test.Hspec (expectationFailure)
16+
import qualified Test.Hspec.Wai as W
17+
import qualified Test.Hspec.Wai.Matcher as W
1418

1519
import Test.Hspec.Wai.Servant.Types
20+
import Test.Hspec.Wai.WaiMultiSession
21+
22+
class Monad m => MonadHspecWai m where
23+
-- | Like @shouldRespondWith@ from 'Test.Hspec.Wai', but ...
24+
-- 1) operates on @m (TestResponse a)@ instead of @(WaiSession SResponse)@
25+
-- 2) returns the response for later use
26+
shouldRespondWith :: HasCallStack => m (TestResponse a) -> W.ResponseMatcher -> m (TestResponse a)
27+
28+
instance MonadHspecWai W.WaiSession where
29+
shouldRespondWith action matcher = do
30+
tresp@(TestResponse _ sresp) <- action
31+
pure sresp `W.shouldRespondWith` matcher
32+
pure tresp
33+
34+
instance MonadHspecWai (WaiMultiSession tags) where
35+
shouldRespondWith action matcher = do
36+
tresp@(TestResponse _ sresp) <- action
37+
for_ (W.match sresp matcher) (liftIO . expectationFailure)
38+
pure tresp
1639

17-
-- | Like @shouldRespondWith@ from 'Test.Hspec.Wai', but ...
18-
-- 1) operates on @WaiSession (TestResponse a)@ instead of @(WaiSession SResponse)@
19-
-- 2) returns the response for later use
20-
shouldRespondWith :: HasCallStack => W.WaiSession (TestResponse a) -> W.ResponseMatcher -> W.WaiSession (TestResponse a)
21-
shouldRespondWith action matcher = do
22-
tresp@(TestResponse _ sresp) <- action
23-
pure sresp `W.shouldRespondWith` matcher
24-
pure tresp
2540

2641
-- | Like 'shouldRespondWith', but doesn't return the response
27-
shouldRespondWith_ :: HasCallStack => W.WaiSession (TestResponse a) -> W.ResponseMatcher -> W.WaiExpectation
42+
shouldRespondWith_ :: (MonadHspecWai m, HasCallStack) => m (TestResponse a) -> W.ResponseMatcher -> m ()
2843
shouldRespondWith_ = (void .) . shouldRespondWith
2944

3045
-- | Checks if the provided @action@ returns 200. If so, attempts to decode
31-
-- the response
32-
succeed :: HasCallStack => W.WaiSession (TestResponse a) -> W.WaiSession a
46+
-- the response, throwing on failure
47+
succeed :: (HasCallStack, MonadIO m, MonadHspecWai m) => m (TestResponse a) -> m a
3348
succeed action = do
3449
tresp <- action `shouldRespondWith` 200
3550
getTestResponse tresp

src/Test/Hspec/Wai/Servant/Client.hs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,13 @@ module Test.Hspec.Wai.Servant.Client
2222
import Network.Wai.Test (SResponse (..))
2323
import Test.Hspec.Wai
2424

25-
import Control.Exception (Exception, throwIO)
25+
import Control.Arrow (left)
2626
import qualified Data.ByteString.Char8 as BC
2727
import qualified Data.ByteString.Lazy as BL
2828
import qualified Data.CaseInsensitive as CI
2929
import Data.Monoid ((<>))
3030
import Data.Proxy
31-
import Data.Typeable (Typeable)
3231
import GHC.TypeLits
33-
import qualified Network.HTTP.Media.MediaType as HT
3432
import qualified Network.HTTP.Media.RenderHeader as HT
3533
import qualified Network.HTTP.Types as HT
3634
import Servant.API
@@ -55,16 +53,12 @@ performTestRequestCT ctP methodP req@TestRequest{..} =
5553
in TestResponse (decodeResponse ctP) <$> performTestRequest method reqWithCt
5654

5755
-- | Will throw and fail the test if a fails to parse
58-
decodeResponse :: MimeUnrender ctype a => Proxy ctype -> SResponse -> WaiSession a
59-
decodeResponse ctProxy resp = liftIO $ either (throwIO . mkError) pure $ mimeUnrender ctProxy (simpleBody resp)
56+
decodeResponse :: MimeUnrender ctype a => Proxy ctype -> SResponse -> Either TestErr a
57+
decodeResponse ctProxy resp = left mkError $ mimeUnrender ctProxy (simpleBody resp)
6058
where
6159
ct = contentType ctProxy
6260
mkError err = DecodeError err ct (BL.toStrict $ simpleBody resp)
6361

64-
data Err = DecodeError !String !HT.MediaType !BC.ByteString deriving (Show, Typeable)
65-
66-
instance Exception Err
67-
6862
-- | Type class to generate 'WaiSession'-based client handlers. Compare to
6963
-- 'HasClient' from 'Servant.Client'
7064
class HasTestClient api where

src/Test/Hspec/Wai/Servant/Types.hs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
module Test.Hspec.Wai.Servant.Types where
66

77
import Network.Wai.Test (SResponse (..))
8-
import Test.Hspec.Wai
98

9+
import Control.Exception (Exception, throwIO)
10+
import Control.Monad.IO.Class (MonadIO (..))
1011
import Data.ByteString as B
1112
import Data.ByteString.Char8 as BC
1213
import Data.ByteString.Conversion.To (toByteString')
1314
import Data.ByteString.Lazy as BL
1415
import Data.Monoid ((<>))
1516
import Data.Proxy (Proxy (..))
17+
import Data.Typeable (Typeable)
18+
import qualified Network.HTTP.Media.MediaType as HT
1619
import Network.HTTP.Media.RenderHeader as HT
1720
import qualified Network.HTTP.Types as HT
1821
import Servant.API (MimeRender (..),
@@ -42,7 +45,11 @@ setReqBody :: (MimeRender ct a) => Proxy ct -> a -> TestRequest -> TestRequest
4245
setReqBody ctP a req = req { testBody = mimeRender ctP a, testHeaders = ("content-type", HT.renderHeader (contentType ctP)) : testHeaders req }
4346

4447
-- | A raw SResponse along with a function to decode @a@
45-
data TestResponse a = TestResponse (SResponse -> WaiSession a) SResponse
48+
data TestResponse a = TestResponse (SResponse -> Either TestErr a) SResponse
4649

47-
getTestResponse :: TestResponse a -> WaiSession a
48-
getTestResponse (TestResponse k sresp) = k sresp
50+
getTestResponse :: MonadIO m => TestResponse a -> m a
51+
getTestResponse (TestResponse k sresp) = either (liftIO . throwIO) pure $ k sresp
52+
53+
data TestErr = DecodeError !String !HT.MediaType !BC.ByteString deriving (Show, Typeable)
54+
55+
instance Exception TestErr
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE FlexibleContexts #-}
3+
{-# LANGUAGE FlexibleInstances #-}
4+
{-# LANGUAGE GADTs #-}
5+
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
6+
{-# LANGUAGE InstanceSigs #-}
7+
{-# LANGUAGE KindSignatures #-}
8+
{-# LANGUAGE MultiParamTypeClasses #-}
9+
{-# LANGUAGE RankNTypes #-}
10+
{-# LANGUAGE ScopedTypeVariables #-}
11+
{-# LANGUAGE TypeFamilies #-}
12+
{-# LANGUAGE TypeOperators #-}
13+
14+
-- | 'WaiSession' is limited because its only a 'Reader' of a single 'Application'
15+
-- This is a pain if you want to test multiple 'Application's that call each other
16+
-- 'WaiMultiSession' is very similar to 'WaiSession', but it is a 'Reader' of
17+
-- multiple 'Application's, each of which is tagged at the type level with a 'Symbol'
18+
--
19+
-- This module actually has nothing to do with 'servant', but it is a pain to
20+
-- use multiple 'Application's without 'hspec-wai-servant's auto client generation
21+
-- so that's why it's in this library.
22+
module Test.Hspec.Wai.WaiMultiSession where
23+
24+
import Network.Wai (Application)
25+
import Network.Wai.Test (runSession)
26+
import Test.Hspec.Core.Spec
27+
import Test.Hspec.Wai.Internal (WaiSession (..))
28+
29+
import Control.Monad.IO.Class (MonadIO)
30+
import Control.Monad.Reader (ReaderT (..))
31+
import Data.Proxy
32+
import qualified GHC.Exts as GHCX
33+
import GHC.TypeLits
34+
35+
-- | Run a given 'WaiSession' with the 'Application' tagged with @s@ in the
36+
-- 'WaiMultiSession'
37+
--
38+
-- NOTE: 'ClientState' is NOT threaded through! It is just a wrapper around
39+
-- Cookies, so if you aren't using Cookies, you're good.
40+
-- FIXME: If @wai-extra@ exported 'ClientState', this would be able to be fixed.
41+
sendWaiSession :: forall (s :: Symbol) (tags :: [Symbol]) a
42+
. (GetApplication s tags)
43+
=> Proxy s
44+
-> Proxy tags
45+
-> WaiSession a
46+
-> WaiMultiSession tags a
47+
sendWaiSession p _ (WaiSession session) =
48+
WaiMultiSession $ ReaderT $ \ma ->
49+
runSession session (getApplication p ma)
50+
51+
newtype WaiMultiSession (tags :: [Symbol]) a =
52+
WaiMultiSession { unWaiMultiSession :: (ReaderT (MultiApplication tags) IO a) }
53+
deriving (Functor, Applicative, Monad, MonadIO)
54+
55+
runWaiMultiSession :: WaiMultiSession tags a -> MultiApplication tags -> IO a
56+
runWaiMultiSession session app = runReaderT (unWaiMultiSession session) app
57+
58+
type WaiMultiExpectation tags = WaiMultiSession tags ()
59+
60+
instance Example (WaiMultiExpectation tags) where
61+
type Arg (WaiMultiExpectation tags) = MultiApplication tags
62+
evaluateExample e p action = evaluateExample (action $ runWaiMultiSession e) p ($ ())
63+
64+
data MultiApplication (tags :: [Symbol]) where
65+
OneApp :: Application -> MultiApplication '[s]
66+
ManyApps :: Application -> MultiApplication xs -> MultiApplication (s ': xs)
67+
68+
class GetApplication (s :: Symbol) (tags :: [Symbol]) where
69+
getApplication :: Proxy s -> MultiApplication tags -> Application
70+
71+
instance forall (s :: Symbol). GetApplication s '[s] where
72+
getApplication _ (ManyApps _ _) = error "impossible"
73+
getApplication _ (OneApp app) = app
74+
75+
instance {-# OVERLAPPABLE #-} forall (s :: Symbol) (xs :: [Symbol]). GetApplication s (s ': xs) where
76+
getApplication _ (ManyApps app _) = app
77+
getApplication _ (OneApp _) = error "impossible"
78+
79+
instance {-# OVERLAPPABLE #-} forall (s :: Symbol) (xs :: [Symbol]) (x :: Symbol)
80+
. (GetApplication s xs)
81+
=> GetApplication s (x ': xs) where
82+
getApplication p (ManyApps _ xs) = getApplication p xs
83+
getApplication _ (OneApp _) = error "impossible"
84+
85+
instance KnownSymbol s => Show (MultiApplication '[s]) where
86+
show (OneApp _) = show $ symbolVal (Proxy :: Proxy s)
87+
show (ManyApps _ _) = error "impossible"
88+
89+
instance {-# OVERLAPPABLE #-} forall (s :: Symbol) (xs :: [Symbol])
90+
. (KnownSymbol s, Show (MultiApplication xs))
91+
=> Show (MultiApplication (s ': xs)) where
92+
show (ManyApps _ xs) = show (symbolVal (Proxy :: Proxy s)) ++ " : " ++ show xs
93+
show (OneApp _) = error "impossible"
94+
95+
instance forall (s :: Symbol). GHCX.IsList (MultiApplication '[s]) where
96+
type Item (MultiApplication '[s]) = Application
97+
98+
fromList [app] = OneApp app
99+
fromList (_ : _ : _) = error "malformed (too long) MultiApplication OverloadedList!"
100+
fromList [] = error "malformed (too short) MultiApplication OverloadedList!"
101+
102+
toList (OneApp app) = [app]
103+
toList (ManyApps _ _) = error "impossible"
104+
105+
instance {-# OVERLAPPABLE #-} forall (s :: Symbol) (xs :: [Symbol])
106+
. ( GHCX.IsList (MultiApplication xs)
107+
, GHCX.Item (MultiApplication xs) ~ Application)
108+
=> GHCX.IsList (MultiApplication (s ': xs)) where
109+
type Item (MultiApplication (s ': xs)) = Application
110+
111+
fromList (app : xs) = ManyApps app (GHCX.fromList xs :: MultiApplication xs)
112+
fromList [] = error "malformed (too short) MultiApplication OverloadedList!"
113+
114+
toList (ManyApps app xs) = app : GHCX.toList xs
115+
toList _ = error "impossible"

0 commit comments

Comments
 (0)