servant-util-0.3: tests/Test/Servant/Filtering/ImplSpec.hs
{-# LANGUAGE OverloadedLists #-}
module Test.Servant.Filtering.ImplSpec where
import Universum
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.TH as Aeson
import qualified Data.OpenApi as O (ToSchema)
import qualified Data.Swagger as S (ToSchema)
import Data.Time.Calendar.OrdinalDate (fromOrdinalDate)
import Data.Time.Clock (UTCTime (..))
import Servant.API (Get, JSON, (:>))
import Servant.API.Generic (ToServantApi, (:-))
import Servant.Server.Generic (AsServer)
import Servant.OpenApi (toOpenApi)
import Servant.Swagger (toSwagger)
import Test.Hspec (Spec, aroundAll, describe, it)
import Test.Hspec.Expectations (shouldBe)
import Servant.Util
import Servant.Util.Dummy
import Test.Servant.Helpers
-- Data model
---------------------------------------------------------------------------
newtype Isbn = Isbn Word64
deriving (Show, Eq, Ord, Generic)
instance O.ToSchema Isbn
instance S.ToSchema Isbn
Aeson.deriveJSON Aeson.defaultOptions ''Isbn
data Book = Book
{ isbn :: Isbn
, name :: Text
, rating :: Word8
, createdAt :: UTCTime
} deriving (Show, Eq, Generic)
instance O.ToSchema Book
instance S.ToSchema Book
Aeson.deriveJSON Aeson.defaultOptions ''Book
-- | Some strictly monotonic function from integers to time.
toUTCTime :: Integer -> UTCTime
toUTCTime idx = UTCTime (fromOrdinalDate idx 0) 0
allBooks :: [Book]
allBooks =
[ Book
{ isbn = Isbn 1
, name = "Delving into memes"
, rating = 5
, createdAt = toUTCTime 3
}
, Book
{ isbn = Isbn 2
, name = "How to cook tofu, the bright way"
, rating = 5
, createdAt = toUTCTime 1
}
, Book
{ isbn = Isbn 3
, name = "Book of something"
, rating = 4
, createdAt = toUTCTime 8
}
]
-- Server
---------------------------------------------------------------------------
newtype ApiMethods route = ApiMethods
{ amSimpleFilter
:: route
:- "simple"
:> FilteringParams
[ "name" ?: 'AutoFilter Text
, "rating" ?: 'AutoFilter Word8
, "createdAt" ?: 'AutoFilter UTCTime
, "isLegendary" ?: 'ManualFilter Bool
]
:> Get '[JSON] [Book]
} deriving Generic
apiHandlers :: ApiMethods AsServer
apiHandlers = ApiMethods
{ amSimpleFilter = \filterSpec -> do
let filterApp Book{..} =
filterOn @"name" name .*.
filterOn @"rating" rating .*.
filterOn @"createdAt" createdAt .*.
manualFilter @"isLegendary"
(\isLegendary -> (rating == 5) == isLegendary) .*.
HNil
return $ filterBySpec filterSpec filterApp allBooks
}
-- Swagger
---------------------------------------------------------------------------
-- Normally such generic types are not used in API,
-- but in these tests we do not define newtype wrappers.
type instance ParamDescription Text = "Some text"
type instance ParamDescription Word8 = "Rating"
type instance ParamDescription UTCTime = "Creation time"
type instance ParamDescription Bool = "Some flag"
-- You can try this with ghci
printFilterSwagger :: IO ()
printFilterSwagger =
writeFile "filter-test-swagger.json" $
decodeUtf8 . Aeson.encode $
toSwagger $ Proxy @(ToServantApi ApiMethods)
-- OpenApi
---------------------------------------------------------------------------
-- You can try this with ghci
printFilterOpenApi :: IO ()
printFilterOpenApi =
writeFile "filter-test-openapi.json" $
decodeUtf8 . Aeson.encode $
toOpenApi $ Proxy @(ToServantApi ApiMethods)
-- Tests
---------------------------------------------------------------------------
spec :: Spec
spec =
aroundAll (runTestServer apiHandlers) $ do
describe "Simple filter" $ do
it "No filtering" $
\ApiMethods{..} -> do
res <- amSimpleFilter noFilters
map isbn res `shouldBe` [Isbn 1, Isbn 2, Isbn 3]
it "Filtering with exact match" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#rating ?/= 4]
map isbn res `shouldBe` [Isbn 3]
it "Filtering with comparison" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#createdAt ?/>= toUTCTime 5]
map isbn res `shouldBe` [Isbn 3]
it "Multiple filters" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#rating ?/= 5, #createdAt ?/< toUTCTime 2]
map isbn res `shouldBe` [Isbn 2]
it "Manual filter" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#isLegendary ?/~ True]
map isbn res `shouldBe` [Isbn 1, Isbn 2]
describe "Regex filters (dummy implementation)" $ do
it "Contains match" $
\ApiMethods{..} -> do
do
res <- amSimpleFilter [#name `textContains` "tofu"]
map isbn res `shouldBe` [Isbn 2]
do
res <- amSimpleFilter [#name `textContains` "h"]
map isbn res `shouldBe` [Isbn 2, Isbn 3]
it "Contains case-insensitive match" $
\ApiMethods{..} -> do
do
res <- amSimpleFilter [#name `textIContains` "b"]
map isbn res `shouldBe` [Isbn 2, Isbn 3]
do
res <- amSimpleFilter [#name `textIContains` "B"]
map isbn res `shouldBe` [Isbn 2, Isbn 3]
it "Like: basic behaviour" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#name `textLike` "*ook *"]
map isbn res `shouldBe` [Isbn 2, Isbn 3]
it "Like: sticks to edges by default" $
\ApiMethods{..} -> do
res <- amSimpleFilter [#name `textLike` "*g"]
map isbn res `shouldBe` [Isbn 3]
-- ↑ May also return Isbn 2 since it contains @g@ in the middle,
-- but that would be not according to the spec.