packages feed

mig-0.2.0.1: test/Test/Server/RouteArgs.hs

{-# LANGUAGE DeriveAnyClass #-}

-- | Tests for various inputs for requests
module Test.Server.RouteArgs (spec) where

import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson qualified as Json
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as BL
import Data.Map.Strict qualified as Map
import Data.OpenApi (ToParamSchema, ToSchema)
import Data.String
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)
import Mig.Core
import Mig.Core qualified as Request (Request (..))
import Mig.Core qualified as Response (Response (..))
import Network.HTTP.Types.Method (Method, methodGet, methodPost)
import Network.HTTP.Types.Status (badRequest400, status201)
import Test.Hspec
import Test.Server.Common
import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))

-------------------------------------------------------------------------------------
-- server

server :: Server IO
server =
  "api"
    /. [ "succ"
          /. [ "query" /. handleSuccQuery
             , "header" /. handleSuccHeader
             , "optional" /. handleSuccOpt
             , "optional-header" /. handleSuccHeaderOpt
             ]
       , -- several query params
         "add" /. handleAdd
       , -- query flag
         "add-if" /. handleAddIf
       , -- capture
         "mul" /. handleMul
       , -- json body as input
         "add-json" /. handleAddJson
       , -- return error
         "square-root" /. handleSquareRoot
       , "response"
          /. [ "status" /. handleStatus
             , "header" /. handleHeader
             , "error1" /. handleError1
             , "error2" /. handleError2
             ]
       ]

{-| Using several inputs: header argument and required query
and using conditional output status
-}
handleSuccQuery :: Query "value" Int -> Get IO (Resp Json Int)
handleSuccQuery (Query n) =
  pure $ ok (succ n)

newtype Value = Value Int
  deriving newtype (FromHttpApiData, ToHttpApiData, ToText, ToParamSchema)

{-| Using several inputs: header argument and required query
and using conditional output status
-}
handleSuccHeader :: Header "value" Value -> Get IO (Resp Json Int)
handleSuccHeader (Header (Value n)) = do
  pure $ ok (succ n)

-- | Using optional query parameters and error as RespOr.
handleSuccOpt :: Optional "value" Int -> Get IO (RespOr Json Text Int)
handleSuccOpt (Optional n) = do
  pure $ ok $ maybe 0 succ n

-- | Using optional header parameters and error as RespOr.
handleSuccHeaderOpt :: OptionalHeader "value" Int -> Get IO (RespOr Json Text Int)
handleSuccHeaderOpt (OptionalHeader n) = do
  pure $ ok $ maybe 0 succ n

{-| Using custom headers in response and several input query parameters.
Note that function can have any number of arguments.
We encode the input type with proper type-wrapper.
-}
handleAdd :: Query "a" Int -> Query "b" Int -> Get IO (Resp Json Text)
handleAdd (Query a) (Query b) = do
  pure $ ok $ toAddResult a b

toAddResult :: Int -> Int -> Text
toAddResult a b =
  Text.unwords ["Addition of", intToText a, "and", intToText b, "is", intToText $ a + b]

intToText :: Int -> Text
intToText = Text.pack . show

-- | Using query flag if flag is false returns 0
handleAddIf :: Query "a" Int -> Query "b" Int -> QueryFlag "perform" -> Get IO (Resp Json Int)
handleAddIf (Query a) (Query b) (QueryFlag addFlag) = do
  pure $
    ok $
      if addFlag
        then (a + b)
        else 0

{-| Using capture as arguments. This route expects two arguments
captured in URL. For example:

> http://localhost:8085/hello/api/mul/3/100
-}
handleMul :: Capture "a" Int -> Capture "b" Int -> Get IO (Resp Json Text)
handleMul (Capture a) (Capture b) = do
  pure $ ok $ toMulResult a b

toMulResult :: Int -> Int -> Text
toMulResult a b =
  Text.unwords
    ["Multiplication of", intToText a, "and", intToText b, "is", intToText $ a * b]

data AddInput = AddInput
  { a :: Int
  , b :: Int
  }
  deriving (Generic, ToJSON, FromJSON, ToSchema)

-- | Using JSON as input
handleAddJson :: Body Json AddInput -> Post IO (Resp Json Int)
handleAddJson (Body (AddInput a b)) = do
  pure $ ok $ a + b

handleSquareRoot :: Body Json Float -> Post IO (RespOr Json Text Float)
handleSquareRoot (Body arg) =
  pure $
    if arg >= 0
      then ok (sqrt arg)
      else bad badRequest400 sqrtError

handleStatus :: Get IO (Resp Json Text)
handleStatus = pure $ setStatus status201 $ ok "Status is 201"

handleHeader :: Capture "name" Text -> Capture "value" Text -> Get IO (Resp Json Text)
handleHeader (Capture name) (Capture value) =
  pure $ setHeader (fromString $ Text.unpack name) value $ ok "Set custom header"

handleError1 :: Get IO (Resp Json Text)
handleError1 = pure $ bad badRequest400 badRequestError

handleError2 :: Capture "value" Int -> Get IO (RespOr Json Text Int)
handleError2 (Capture n)
  | n > 0 = pure $ ok n
  | otherwise = pure $ bad badRequest400 badRequestError

badRequestError :: Text
badRequestError = "Error: bad request"

sqrtError :: Text
sqrtError = "Argument for square root should be non-negative"

-------------------------------------------------------------------------------------
-- test cases

-- we use low-level representation of server as a function: Request -> m (Maybe Response)
-- to check server properties without launching in a full server environment
spec :: Spec
spec = describe "route args server: check route inputs" $ do
  describe "plain route finder" $ specBy plainApiStrategy
  describe "tree route finder" $ specBy treeApiStrategy

specBy :: FindRoute normalForm IO -> Spec
specBy finder = do
  describe "request" $ do
    checkQuery
    checkOptionalQuery
    checkHeader
    checkOptionalHeader
    checkQueryFlag
    checkCapture
    checkBody
  describe "response" $ do
    checkStatus
    checkHeaders
    checkErrors
  where
    serverFun :: ServerFun IO
    serverFun = fromServer finder server

    shouldReq :: forall a. (Json.FromJSON a, Show a, Eq a) => Request -> Maybe a -> Expectation
    shouldReq req expected =
      fmap (parseResp @a =<<) (serverFun req) `shouldReturn` expected

    toQuery :: forall a. (Json.ToJSON a) => ByteString -> a -> QueryMap
    toQuery name val = Map.singleton name (Just $ BL.toStrict $ Json.encode @a val)

    jsonHeaders :: HeaderMap
    jsonHeaders = Map.fromList [("accept", "application/json"), ("content-type", "application/json")]

    -- queries

    checkQuery :: Spec
    checkQuery =
      describe "query" $ do
        it "one query" $ shouldReq @Int queryReq (Just 2)
        it "missing query" $ shouldReq @Int (queryReq{query = mempty}) Nothing
        it "two queries" $ shouldReq @Text (twoQueryReq 2 3) (Just $ toAddResult 2 3)

    queryReq :: Request
    queryReq =
      emptyReq
        { path = ["api", "succ", "query"]
        , query = toQuery @Int "value" 1
        }

    twoQueryReq :: Int -> Int -> Request
    twoQueryReq a b =
      emptyReq
        { path = ["api", "add"]
        , query = toQuery "a" a <> toQuery "b" b
        }

    -- optional query

    checkOptionalQuery :: Spec
    checkOptionalQuery =
      describe "optional query" $ do
        it "with query" $ shouldReq @Int optionalQueryReq (Just 2)
        it "no query (ok, default case)" $ shouldReq @Int (optionalQueryReq{query = mempty}) (Just 0)

    optionalQueryReq :: Request
    optionalQueryReq =
      emptyReq
        { path = ["api", "succ", "optional"]
        , query = toQuery @Int "value" 1
        }

    -- query flag

    checkQueryFlag :: Spec
    checkQueryFlag =
      describe "query flag" $ do
        it "flag true" $ shouldReq @Int (queryFlagReq (Just True) 2 3) (Just 5)
        it "flag false" $ shouldReq @Int (queryFlagReq (Just False) 2 3) (Just 0)
        it "flag missing" $ shouldReq @Int (queryFlagReq Nothing 2 3) (Just 0)

    queryFlagReq :: Maybe Bool -> Int -> Int -> Request
    queryFlagReq mFlag a b =
      emptyReq
        { path = ["api", "add-if"]
        , query = mconcat [toQuery "a" a, toQuery "b" b] <> maybe mempty (toQuery "perform") mFlag
        }

    -- headers

    checkHeader :: Spec
    checkHeader =
      describe "header" $ do
        describe "input header" $ do
          it "positive case" $ shouldReq @Int headerReq (Just 2)
          it "missing header" $ shouldReq @Int (headerReq{Request.headers = mempty}) Nothing

    headerReq :: Request
    headerReq =
      emptyReq
        { path = ["api", "succ", "header"]
        , Request.headers = Map.singleton "value" (BL.toStrict $ Json.encode @Int 1)
        }

    -- optional headers

    checkOptionalHeader :: Spec
    checkOptionalHeader =
      describe "optional header" $ do
        it "with header" $ shouldReq @Int optionalHeaderReq (Just 2)
        it "no header (ok, default case)" $ shouldReq @Int (optionalHeaderReq{Request.headers = mempty}) (Just 0)

    optionalHeaderReq :: Request
    optionalHeaderReq =
      emptyReq
        { path = ["api", "succ", "optional-header"]
        , Request.headers = Map.singleton "value" (BL.toStrict $ Json.encode @Int 1)
        }

    -- captures

    checkCapture :: Spec
    checkCapture =
      describe "capture" $ do
        it "positive case" $ shouldReq @Text (captureReq [2, 3]) (Just (toMulResult 2 3))
        it "not enough  captures" $ shouldReq @Text ((captureReq [2]){capture = mempty}) Nothing
        it "too many captures" $ shouldReq @Text ((captureReq [2, 3, 4]){capture = mempty}) Nothing
        it "missing captures" $ shouldReq @Text ((captureReq []){capture = mempty}) Nothing

    captureReq :: [Int] -> Request
    captureReq args =
      emptyReq
        { path = ["api", "mul"] <> fmap (Text.pack . show) args
        }

    -- body

    checkBody :: Spec
    checkBody =
      describe "body" $ do
        it "positive case 1" $ shouldReq @Int (bodyReq methodPost 2 3) (Just 5)
        it "positive case 2" $ shouldReq @Float (sqrtBodyReq 9) (Just 3)
        it "no body" $ shouldReq @Int noBodyReq Nothing
        it "wrong method" $ shouldReq @Int (bodyReq methodGet 2 2) Nothing
        it "bad argument" $ shouldReq @Text (sqrtBodyReq (-9)) (Just sqrtError)

    bodyReq :: Method -> Int -> Int -> Request
    bodyReq reqMethod a b =
      emptyReq
        { path = ["api", "add-json"]
        , method = reqMethod
        , readBody = pure $ Right $ Json.encode $ AddInput a b
        , Request.headers = jsonHeaders
        }

    noBodyReq :: Request
    noBodyReq =
      emptyReq
        { path = ["api", "add-json"]
        , method = methodPost
        , Request.headers = jsonHeaders
        }

    sqrtBodyReq :: Float -> Request
    sqrtBodyReq a =
      emptyReq
        { path = ["api", "square-root"]
        , method = methodPost
        , readBody = pure $ Right $ Json.encode a
        , Request.headers = jsonHeaders
        }

    -- response status

    checkStatus :: Spec
    checkStatus =
      describe "status" $ do
        it "can set result status" $
          (fmap (.status) <$> serverFun statusReq) `shouldReturn` Just status201
        it "can set error status" $
          (fmap (.status) <$> serverFun (sqrtBodyReq (-1))) `shouldReturn` Just badRequest400

    statusReq :: Request
    statusReq =
      emptyReq
        { path = ["api", "response", "status"]
        }

    -- response headers

    checkHeaders :: Spec
    checkHeaders =
      describe "headers" $
        it "can set headers" $
          shouldHeader "foo" "bar"
      where
        shouldHeader name value =
          fmap (any (== header) . Response.headers) <$> serverFun (customHeaderReq name value)
            `shouldReturn` Just True
          where
            header = (fromString $ Text.unpack name, fromString $ Text.unpack value)

    customHeaderReq :: Text -> Text -> Request
    customHeaderReq name value =
      emptyReq
        { path = ["api", "response", "header", name, value]
        }

    -- response errors

    checkErrors :: Spec
    checkErrors =
      describe "custom errors" $ do
        it "error has the same type as result" $
          shouldBadReq (customErrorReq ["error1"])
        it "error has different type" $
          shouldBadReq (customErrorReq ["error2", "0"])
        it "no error on positive input" $
          shouldReq @Int (customErrorReq ["error2", "1"]) (Just 1)
      where
        shouldBadReq req =
          fmap (maybe False isBadReq) (serverFun req) `shouldReturn` True

        isBadReq resp =
          resp.status == badRequest400
            && parseResp @Text resp == Just badRequestError

    customErrorReq :: [Text] -> Request
    customErrorReq args =
      emptyReq
        { path = ["api", "response"] <> args
        }