packages feed

servant-lint-0.1.1.0: test/Main.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE ExplicitNamespaces  #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# OPTIONS_GHC -Wno-unused-imports #-}
{-# OPTIONS_GHC -fconstraint-solver-iterations=0 #-}
{-# LANGUAGE DerivingVia         #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Main (main) where

import           Data.ByteString (ByteString)
import           Data.String     (fromString)
import           Data.Text       as Text (unpack)
import           Servant         (AuthProtect, Capture, CaptureAll, Get, JSON, NoContent,
                                  NoContentVerb, Post, QueryParam, QueryParams,
                                  ReqBody, StdMethod (GET, POST), Verb,
                                  type (:<|>), type (:>))
import           Servant.Multipart (MultipartForm, MultipartData, Mem)
import           Servant.Lint    (Ambiguity, Error (..), Lintable, lintAPI',
                                  unlinesChunks)
import           Test.Syd        (describe, expectationFailure, it, shouldBe,
                                  sydTest, xdescribe)
import           Text.Colour     (Chunk,
                                  TerminalCapabilities (With24BitColours), bold,
                                  chunk, fore, red, renderChunksText)
import Data.Kind (Type)

type DisambiguatedQuery
     = QueryParam "foo" Int :> Get '[JSON] ()
  :<|> QueryParam "bar" Int :> Get '[JSON] ()

type DisambiguatedMethod
     = Post '[JSON] ()
  :<|> Get '[JSON] ()

type DisambiguatedReturn
     = Get '[JSON] Bool
  :<|> Get '[JSON] ()

type DisambiguatedStatic
     = "foo" :> Get '[JSON] ()
  :<|> "bar" :> Get '[JSON] ()

type DisambiguatedNoContent
     = "foo" :> NoContentVerb 'GET

type DisambiguatedReqBody
     = ReqBody '[JSON] () :> Post '[JSON] ()
  :<|> "bar" :> ReqBody '[JSON] () :> Post '[JSON] ()

type AmbiguousRoot
     = Get '[JSON] ()
  :<|> Get '[JSON] ()

type AmbiguousStatic
     = "foo" :> Get '[JSON] ()
  :<|> "foo" :> Get '[JSON] ()

type AmbiguousCapture
     = "bar" :> Capture "gurf" Int :> Get '[JSON] ()
  :<|> "foo" :> Get '[JSON] ()
  :<|> "bar" :> Capture "wat" Int :> Get '[JSON] ()

type AmbiguousCaptureWithStatic
     = "bar" :> Capture "gurf" Int :> Get '[JSON] ()
  :<|> "bar" :> "5" :> Get '[JSON] ()
  :<|> "bar" :> "5" :> "wat" :> Get '[JSON] ()

type AmbiguousCaptureAllWithStatic
     = "bar" :> CaptureAll "murf"  [Int] :> Get '[JSON] ()
  :<|> "bar" :> "5" :> Get '[JSON] ()
  :<|> "bar" :> Capture "how" Int :> Get '[JSON] ()
  :<|> "bar" :> "3" :> "2" :> Get '[JSON] ()

type AmbiguousQuery
     = QueryParam "foo" Int :> Get '[JSON] ()
  :<|> QueryParam "foo" String :> Get '[JSON] ()

type AmbiguousQuerys
     = QueryParams "foo" Int :> Get '[JSON] ()
  :<|> QueryParams "foo" String :> Get '[JSON] ()

type AmbiguousReqBody
     = ReqBody '[JSON] Int :> Post '[JSON] ()
  :<|> ReqBody '[JSON] String :> Post '[JSON] ()

type Bad200NoContent
     = "foo" :> Get '[JSON] NoContent
  :<|> "bar" :> Get '[JSON] ()

type BadReqBodyGet
    = "foo" :> ReqBody '[JSON] () :> Get '[JSON] ()

type BadReqBodyPosition
    = ReqBody '[JSON] () :> "bar" :> Post '[JSON] ()

type Bad500Code
  = "foo" :> Verb 'POST 500 '[JSON] ()

type Phan :: k -> Type
data Phan a
data Ghostly = Waaa | Oooo

type DisambiguatedByPhantom =
  "spooky"
  :> Capture "boo!" (Phan 'Waaa)
  :> Capture "entrails" (Phan 'Oooo) :> Post '[JSON] ()

type Duplicates
  = "bar" :> CaptureAll "foo" () :> "baz" :> CaptureAll "bar" () :> Get '[JSON] ()
  :<|> "hunk" :> QueryParam "foo" () :> QueryParam "bar" () :> Get '[JSON] ()
  :<|> "baz" :> Capture "foo" () :> Capture "bar" () :> Get '[JSON] ()
  :<|> "foo" :> "foo" :> "foo" :> Get '[JSON] ()
  :<|> "mixed" :> Capture "id" Int :> ReqBody '[JSON] Int :> Post '[JSON] String
  :<|> "query-capture" :> Capture "userId" String :> QueryParam "name" String :> Get '[JSON] ()

type DuplicateQueryParamNames
  = "test" :> QueryParam "foo" Int :> QueryParam "foo" Bool :> Get '[JSON] ()
  :<|> "multi" :> QueryParam "name" Int :> QueryParam "age" Int :> QueryParam "name" Int :> Post '[JSON] ()

type MultipleDuplicateQueryParamNames
  = "chaos" :> QueryParam "foo" Int :> QueryParam "bar" String :> QueryParam "foo" Bool :> QueryParam "bar" Double :> Get '[JSON] ()

type AuthProtectAPI
  = "protected" :> AuthProtect "jwt" :> MultipartForm Mem (MultipartData Mem) :> Get '[JSON] ()

lintShouldBe :: forall api. Lintable api => [[Chunk]] -> IO ()
lintShouldBe expected' = let
    renderError = renderChunksText With24BitColours . unlinesChunks . fmap (unlinesChunks . toChunks)
    actual = renderError $ lintAPI' @api
    expected = renderError [Error expected']
  in if expected' == mempty || actual == expected then pure ()
     else expectationFailure $ Text.unpack $ "Expected:\n\n" <> expected <> "But Got:\n\n" <> actual

main :: IO ()
main = sydTest $ do

  describe "Disambiguation" $ do
    it "DisambiguatedQuery" $ lintShouldBe @DisambiguatedQuery $ mempty
    it "DisambiguatedMethod" $ lintShouldBe @DisambiguatedMethod $ mempty
    it "DisambiguatedReturn" $ lintShouldBe @DisambiguatedReturn $ mempty
    it "DisambiguatedStatic" $ lintShouldBe @DisambiguatedStatic $ mempty
    it "DisambiguatedNoContent" $ lintShouldBe @DisambiguatedNoContent $ mempty
    it "DisambiguatedReqBody" $ lintShouldBe @DisambiguatedReqBody $ mempty
    it "DisambiguatedByPhantom" $ lintShouldBe @DisambiguatedByPhantom $ mempty
    it "AuthProtect and Multipart instances work" $ lintShouldBe @AuthProtectAPI $ mempty

  describe "Ambiguation" $ do
    it "AmbiguousRoot" $ lintShouldBe @AmbiguousRoot
        [ [ chunk "Ambiguous with ", bold $ chunk "Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\tVerb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\tVerb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousCapture" $ lintShouldBe @AmbiguousCapture
        [ [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> Capture \"gurf\" Int :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"gurf\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ chunk "\t\"foo\" :> Verb 'GET 200 ()"
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"wat\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousCaptureWithStatic" $ lintShouldBe @AmbiguousCaptureWithStatic
        [ [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> Capture \"gurf\" Int :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"gurf\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> \"5\" :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ chunk "\t\"bar\" :> \"5\" :> \"wat\" :> Verb 'GET 200 ()"
        ]
    it "AmbiguousCaptureAllWithStatic" $ lintShouldBe @AmbiguousCaptureAllWithStatic
        [ [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> CaptureAll \"murf\" [Int] :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> CaptureAll \"murf\" [Int] :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> \"5\" :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"how\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> \"3\" :> \"2\" :> Verb 'GET 200 () πŸ‘ˆ"
        , []
        , [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> \"5\" :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> CaptureAll \"murf\" [Int] :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> \"5\" :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"how\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ chunk "\t\"bar\" :> \"3\" :> \"2\" :> Verb 'GET 200 ()"
        , []
        , [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> Capture \"how\" Int :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> CaptureAll \"murf\" [Int] :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> \"5\" :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"bar\" :> Capture \"how\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ chunk "\t\"bar\" :> \"3\" :> \"2\" :> Verb 'GET 200 ()"
        , []
        , [ chunk "Ambiguous with ", bold $ chunk "\"bar\" :> \"3\" :> \"2\" :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"bar\" :> CaptureAll \"murf\" [Int] :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ chunk "\t\"bar\" :> \"5\" :> Verb 'GET 200 ()"
        , pure $ chunk "\t\"bar\" :> Capture \"how\" Int :> Verb 'GET 200 ()"
        , pure $ fore red $ chunk "\t\"bar\" :> \"3\" :> \"2\" :> Verb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousQuery" $ lintShouldBe @AmbiguousQuery
        [ [ chunk "Ambiguous with ", bold $ chunk "QueryParam \"foo\" Int :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\tQueryParam \"foo\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\tQueryParam \"foo\" [Char] :> Verb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousQuerys" $ lintShouldBe @AmbiguousQuerys
        [ [ chunk "Ambiguous with ", bold $ chunk "QueryParam \"foo\" Int :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\tQueryParam \"foo\" Int :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\tQueryParam \"foo\" [Char] :> Verb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousStatic" $ lintShouldBe @AmbiguousStatic
        [ [ chunk "Ambiguous with ", bold $ chunk "\"foo\" :> Verb 'GET 200 ():" ]
        , pure $ fore red $ chunk "\t\"foo\" :> Verb 'GET 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\t\"foo\" :> Verb 'GET 200 () πŸ‘ˆ"
        ]
    it "AmbiguousReqBody" $ lintShouldBe @AmbiguousReqBody
        [ [ chunk "Ambiguous with ", bold $ chunk "ReqBody _ _ Int :> Verb 'POST 200 ():" ]
        , pure $ fore red $ chunk "\tReqBody _ _ Int :> Verb 'POST 200 () πŸ‘ˆ"
        , pure $ fore red $ chunk "\tReqBody _ _ [Char] :> Verb 'POST 200 () πŸ‘ˆ"
        ]

  describe "Bad Returns" $ do
    it "NoContent" $ lintShouldBe @Bad200NoContent
      [ [ chunk "Bad verb, NoContent must use HTTP Status Code 204, not "
      , bold $ chunk "200:" ]
      , pure $ fore red $ chunk "\t\"foo\" :> Verb 'GET 200 NoContent πŸ‘ˆ"
      , pure $ chunk "\t\"bar\" :> Verb 'GET 200 ()"
      ]

    it "ReqBody on GET" $ lintShouldBe @BadReqBodyGet
      [ [ chunk "Bad verb, do not use ReqBody in a GET request, Http 1.1 says its meaningless" ]
      , pure $ fore red $ chunk "\t\"foo\" :> ReqBody _ _ () :> Verb 'GET 200 () πŸ‘ˆ"
      ]

    it "500 not allowed as valid response code" $ lintShouldBe @Bad500Code
      [ [ chunk "Bad verb, you should never intentionally return 500 as part of your API:" ]
      , pure $ fore red $ chunk "\t\"foo\" :> Verb 'POST 500 () πŸ‘ˆ"
      ]

    it "ReqBody not next to Verb" $ lintShouldBe @BadReqBodyPosition
      [ [ chunk "ReqBody must be the last combinator before the Verb" ]
      , pure $ fore red $ chunk "\tReqBody _ _ () :> \"bar\" :> Verb 'POST 200 () πŸ‘ˆ"
      ]

  it "duplicates" $ lintShouldBe @Duplicates
      [ [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "()"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"bar\" :> CaptureAll \"foo\" () πŸ‘ˆ :> \"baz\" :> CaptureAll \"bar\" () πŸ‘ˆ :> Verb 'GET 200 ()" ]
      , []
      , [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "()"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"hunk\" :> QueryParam \"foo\" () πŸ‘ˆ :> QueryParam \"bar\" () πŸ‘ˆ :> Verb 'GET 200 ()" ]
      , []
      , [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "()"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"baz\" :> Capture \"foo\" () πŸ‘ˆ :> Capture \"bar\" () πŸ‘ˆ :> Verb 'GET 200 ()" ]
      , []
      , [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "Int"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"mixed\" :> Capture \"id\" Int πŸ‘ˆ :> ReqBody _ _ Int πŸ‘ˆ :> Verb 'POST 200 [Char]" ]
      , []
      , [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "[Char]"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"query-capture\" :> Capture \"userId\" [Char] πŸ‘ˆ :> QueryParam \"name\" [Char] πŸ‘ˆ :> Verb 'GET 200 ()" ]
      ]

  it "duplicate QueryParam names" $ lintShouldBe @DuplicateQueryParamNames
      [ [ chunk "Route has multiple QueryParam with the same name: "
        , bold $ chunk "foo"
        , chunk ". QueryParam names must be unique within a route:"
        ]
      , [ chunk "\t\"test\" :> QueryParam \"foo\" Int πŸ‘ˆ :> QueryParam \"foo\" Bool πŸ‘ˆ :> Verb 'GET 200 ()" ]
      , []
      , [ chunk "Route accepts the same type multiple times: "
        , bold $ chunk "Int, Int"
        , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"
        ]
      , [ chunk "\t\"multi\" :> QueryParam \"name\" Int πŸ‘ˆ :> QueryParam \"age\" Int πŸ‘ˆ :> QueryParam \"name\" Int πŸ‘ˆ :> Verb 'POST 200 ()" ]
      , []
      , [ chunk "Route has multiple QueryParam with the same name: "
        , bold $ chunk "name"
        , chunk ". QueryParam names must be unique within a route:"
        ]
      , [ chunk "\t\"multi\" :> QueryParam \"name\" Int πŸ‘ˆ :> QueryParam \"age\" Int :> QueryParam \"name\" Int πŸ‘ˆ :> Verb 'POST 200 ()" ]
      ]

  it "multiple duplicate QueryParam names" $ lintShouldBe @MultipleDuplicateQueryParamNames
      [ [ chunk "Route has multiple QueryParam with the same name: "
        , bold $ chunk "foo, bar"
        , chunk ". QueryParam names must be unique within a route:"
        ]
      , [ chunk "\t\"chaos\" :> QueryParam \"foo\" Int πŸ‘ˆ :> QueryParam \"bar\" [Char] πŸ‘ˆ :> QueryParam \"foo\" Bool πŸ‘ˆ :> QueryParam \"bar\" Double πŸ‘ˆ :> Verb 'GET 200 ()" ]
      ]