servant-lint 0.1.0.0 → 0.1.1.0
raw patch · 5 files changed
+367/−35 lines, 5 filesdep +servant-multipartdep ~basedep ~bytestringdep ~containersPVP ok
version bump matches the API change (PVP)
Dependencies added: servant-multipart
Dependency ranges changed: base, bytestring, containers, safe-coloured-text, servant, servant-server, text
API changes (from Hackage documentation)
+ Servant.Lint: instance Servant.Lint.Lintable b => Servant.Lint.Lintable (Servant.API.Description.Description _a Servant.API.Sub.:> b)
+ Servant.Lint: instance Servant.Lint.Lintable b => Servant.Lint.Lintable (Servant.API.Description.Summary _a Servant.API.Sub.:> b)
+ Servant.Lint: instance Servant.Lint.Lintable b => Servant.Lint.Lintable (Servant.API.Experimental.Auth.AuthProtect a Servant.API.Sub.:> b)
+ Servant.Lint: instance Servant.Lint.Lintable b => Servant.Lint.Lintable (Servant.Multipart.API.MultipartForm tag a Servant.API.Sub.:> b)
Files
- CHANGELOG.md +14/−2
- README.md +111/−0
- servant-lint.cabal +14/−10
- src/Servant/Lint.hs +145/−13
- test/Main.hs +83/−10
CHANGELOG.md view
@@ -1,5 +1,17 @@-# Revision history for servant-coherent+# Revision history for servant-lint -## 0.1.0.0 -- YYYY-mm-dd+## 0.1.1.0 -- 2025-01-16++* **Enhanced duplicate detection**: Now catches routes that accept the same type multiple times across different combinators (e.g., `Capture "id" Int :> ReqBody '[JSON] Int`)+* **QueryParam name validation**: Detects and prevents multiple QueryParam with the same name within a route+* **Improved error messages**: More descriptive error messages that explain why duplicates are problematic and include precise emoji indicators (👈) pointing to problematic route components+* **Comprehensive test coverage**: Added extensive tests for both type duplicates and QueryParam name duplicates+* **Updated documentation**: README now includes examples of new duplicate detection features++## 0.1.0.1 -- 2024-mm-dd++* Bug fixes and stability improvements++## 0.1.0.0 -- 2024-mm-dd * First version. Released on an unsuspecting world.
+ README.md view
@@ -0,0 +1,111 @@+# Servant Lint++If you have worked with APIs, you have likely encountered ambiguous routes. These issues are painful to debug and can present as "reality violations". Questions like "Why isn't my handler being called?" and "What is happening with type marshaling?" often arise. Additionally, some APIs may promise a JSON parsable output while sending zero bytes, leading to further confusion. This project aims to detect these kinds of problems in Servant API types.++## Rejections++- Straighforward ambiguous overlaps+- Ambiguous overlaps of Capture and CaptureAll with static+- Ambiguous overlaps of Capture and CaptureAll+- Ambiguous overlaps due to QueryParams+- No ReqBody with GET requests+- NoContent must be 204+- ReqBody not adjacent to Verb+- Duplicate combinator hints+- 500 as a success response+- Routes accepting the same type multiple times (argument order ambiguity)+- Multiple QueryParam with the same name++## Output Sample++```haskell+type API =+ = "bar" :> CaptureAll "murf" [Int] :> Get '[JSON] ()+ :<|> "bar" :> "5" :> Get '[JSON] ()+ :<|> "bar" :> Capture "how" Int :> Get '[JSON] ()+ :<|> "bar" :> "3" :> "2" :> Get '[JSON] ()++main :: IO ()+main = lintAPI @API+```++Produces:++> NOTE: Output will NOT exactly match your type definition++```+ Ambiguous with "bar" :> CaptureAll "murf" [Int] :> Verb 'GET 200 ():+ "bar" :> CaptureAll "murf" [Int] :> Verb 'GET 200 () 👈+ "bar" :> "5" :> Verb 'GET 200 () 👈+ "bar" :> Capture "how" Int :> Verb 'GET 200 () 👈+ "bar" :> "3" :> "2" :> Verb 'GET 200 () 👈+ + Ambiguous with "bar" :> "5" :> Verb 'GET 200 ():+ "bar" :> CaptureAll "murf" [Int] :> Verb 'GET 200 () 👈+ "bar" :> "5" :> Verb 'GET 200 () 👈+ "bar" :> Capture "how" Int :> Verb 'GET 200 () 👈+ "bar" :> "3" :> "2" :> Verb 'GET 200 ()+ + Ambiguous with "bar" :> Capture "how" Int :> Verb 'GET 200 ():+ "bar" :> CaptureAll "murf" [Int] :> Verb 'GET 200 () 👈+ "bar" :> "5" :> Verb 'GET 200 () 👈+ "bar" :> Capture "how" Int :> Verb 'GET 200 () 👈+ "bar" :> "3" :> "2" :> Verb 'GET 200 ()+ + Ambiguous with "bar" :> "3" :> "2" :> Verb 'GET 200 ():+ "bar" :> CaptureAll "murf" [Int] :> Verb 'GET 200 () 👈+ "bar" :> "5" :> Verb 'GET 200 ()+ "bar" :> Capture "how" Int :> Verb 'GET 200 ()+ "bar" :> "3" :> "2" :> Verb 'GET 200 () 👈+```+++But with fancy colors:++<img src="ambiguous.png" />++## Duplicate Type Detection++Routes that accept the same type in multiple places can cause argument order confusion:++```haskell+type BadAPI =+ "user" :> Capture "id" Int :> ReqBody '[JSON] Int :> Post '[JSON] ()+ :<|> "search" :> Capture "userId" String :> QueryParam "name" String :> Get '[JSON] ()++main :: IO ()+main = lintAPI @BadAPI+```++Produces:++```+Route accepts the same type multiple times: Int. This doesn't guarantee argument order and can lead to ambiguous behavior:+ "user" :> Capture "id" Int 👈 :> ReqBody _ _ Int 👈 :> Verb 'POST 200 ()++Route accepts the same type multiple times: [Char]. This doesn't guarantee argument order and can lead to ambiguous behavior:+ "search" :> Capture "userId" [Char] 👈 :> QueryParam "name" [Char] 👈 :> Verb 'GET 200 ()+```++## Duplicate QueryParam Names++Routes cannot have multiple QueryParam with the same name:++```haskell+type BadQueryAPI =+ "search" :> QueryParam "filter" Int :> QueryParam "filter" Bool :> Get '[JSON] ()++main :: IO ()+main = lintAPI @BadQueryAPI+```++Produces:++```+Route has multiple QueryParam with the same name: filter. QueryParam names must be unique within a route:+ "search" :> QueryParam "filter" Int 👈 :> QueryParam "filter" Bool 👈 :> Verb 'GET 200 ()+```++## Usage++Servant Lint is best used in your test suite.
servant-lint.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: servant-lint-version: 0.1.0.0+version: 0.1.1.0 synopsis: Lint Servant Routes description: Lint Servant Routes and reject bad routes, overlaps, and more license: BSD-3-Clause@@ -9,7 +9,9 @@ maintainer: isaac.shapira@platonic.systems category: Web build-type: Simple-extra-doc-files: CHANGELOG.md+extra-doc-files:+ README.md+ CHANGELOG.md source-repository head type: git@@ -22,12 +24,13 @@ import: warnings exposed-modules: Servant.Lint build-depends:- base >= 4.18.2 && < 4.19,+ base >= 4.18.2 && < 5, bytestring >= 0.11.5 && < 0.12, containers >= 0.6.7 && < 0.7, safe-coloured-text >= 0.2.0 && < 0.3, text >= 2.0.2 && < 2.1, servant >= 0.20.1 && < 0.21,+ servant-multipart >= 0.12 && < 0.13, servant-server >= 0.20 && < 0.21 hs-source-dirs: src default-language: GHC2021@@ -41,11 +44,12 @@ hs-source-dirs: test src main-is: Main.hs build-depends:- , base- , bytestring- , containers- , safe-coloured-text- , servant- , servant-server+ , base >= 4.18.2 && < 5+ , bytestring >= 0.11.5 && < 0.12+ , containers >= 0.6.7 && < 0.7+ , safe-coloured-text >= 0.2.0 && < 0.3+ , servant >= 0.20.1 && < 0.21+ , servant-multipart >= 0.12 && < 0.13+ , servant-server >= 0.20 && < 0.21 , sydtest- , text+ , text >= 2.0.2 && < 2.1
src/Servant/Lint.hs view
@@ -5,6 +5,36 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-}+{-|+Module : Servant.Lint+Description : Lint Servant API types for common problems+Copyright : (c) Isaac Shapira, 2024+License : BSD-3-Clause+Maintainer : isaac.shapira@platonic.systems+Stability : experimental++This module provides linting functionality for Servant API types to detect:++* Ambiguous route overlaps+* Duplicate type acceptance (same type in multiple combinators)+* Duplicate QueryParam names+* Invalid HTTP method/response code combinations+* Incorrect ReqBody placement++Use 'lintAPI' in your test suite to catch these issues early:++@+import Servant.Lint++type MyAPI = "users" :> Capture "id" Int :> Get '[JSON] User++main :: IO ()+main = lintAPI @MyAPI+@++For more control over error handling, use 'lintAPI'' to get the errors as a list,+or 'printLintAPI' to print them to stdout with colors.+-} module Servant.Lint ( lintAPI , lintAPI'@@ -19,19 +49,22 @@ import Data.ByteString (ByteString) import Data.Containers.ListUtils (nubOrd) import Data.Kind (Constraint, Type)+import Data.List ((\\)) import Data.Maybe (mapMaybe, maybeToList) import Data.Proxy (Proxy (Proxy))-import qualified Data.Text as Text (pack, unpack)+import qualified Data.Text as Text (Text, intercalate, pack, unpack) import Data.Text.Encoding (decodeUtf8) import Data.Typeable (TypeRep, Typeable, typeRep) import GHC.Generics (Generic)-import GHC.TypeLits (KnownNat, KnownSymbol, natVal,+import GHC.TypeLits (KnownNat, KnownSymbol, Symbol, natVal, symbolVal)-import Servant.API (Capture', CaptureAll, NoContent,- NoContentVerb, QueryParam,- QueryParams, ReflectMethod (..),- ReqBody', Verb, type (:<|>),+import Servant.API (AuthProtect, Capture', CaptureAll, Description,+ NoContent, NoContentVerb,+ QueryParam, QueryParams,+ ReflectMethod (..), ReqBody',+ Summary, Verb, type (:<|>), type (:>))+import Servant.Multipart (MultipartForm) import Text.Colour (Chunk, TerminalCapabilities (..), bold, renderChunksText) import Text.Colour.Chunk (chunk, fore, red)@@ -86,6 +119,18 @@ instance (KnownSymbol a, Lintable b) => Lintable (a :> b) where paths = PPath (symbolVal (Proxy @a)) <$> paths @b +instance Lintable b => Lintable (Summary _a :> b) where+ paths = paths @b++instance Lintable b => Lintable (Description _a :> b) where+ paths = paths @b++instance (Lintable b) => Lintable (MultipartForm tag a :> b) where+ paths = paths @b++instance (Lintable b) => Lintable (AuthProtect (a :: Symbol) :> b) where+ paths = paths @b+ instance {-# OVERLAPPABLE #-} ( ReflectMethod method ) => Lintable (NoContentVerb method) where@@ -137,15 +182,101 @@ PQueryParams s _ p -> AQueryParam s : ambiguity p PVerb method code _ty -> [AVerb method code] +-- | Extract TypeReps from a Path for duplicate detection+pathTypes :: Path -> [TypeRep]+pathTypes = \case+ PPath _ p -> pathTypes p+ PCapture _ ty p -> ty : pathTypes p+ PCaptureAll _ ty p -> ty : pathTypes p+ PReqBody ty p -> ty : pathTypes p+ PQueryParam _ ty p -> ty : pathTypes p+ PQueryParams _ ty p -> ty : pathTypes p+ PVerb _ _ _ -> []++-- | Build a route string with emoji pointers for duplicate types+showRouteWithDuplicateHighlights :: [TypeRep] -> Path -> Text.Text+showRouteWithDuplicateHighlights dupTypes = go+ where+ go = \case+ PPath s p -> "\"" <> Text.pack s <> "\" :> " <> go p+ PCapture s ty p ->+ let captureStr = "Capture \"" <> Text.pack s <> "\" " <> Text.pack (show ty)+ highlighted = if ty `elem` dupTypes then captureStr <> " 👈" else captureStr+ in highlighted <> " :> " <> go p+ PCaptureAll s ty p ->+ let captureStr = "CaptureAll \"" <> Text.pack s <> "\" " <> Text.pack (show ty)+ highlighted = if ty `elem` dupTypes then captureStr <> " 👈" else captureStr+ in highlighted <> " :> " <> go p+ PReqBody ty p ->+ let reqBodyStr = "ReqBody _ _ " <> Text.pack (show ty)+ highlighted = if ty `elem` dupTypes then reqBodyStr <> " 👈" else reqBodyStr+ in highlighted <> " :> " <> go p+ PQueryParam s ty p ->+ let queryStr = "QueryParam \"" <> Text.pack s <> "\" " <> Text.pack (show ty)+ highlighted = if ty `elem` dupTypes then queryStr <> " 👈" else queryStr+ in highlighted <> " :> " <> go p+ PQueryParams s ty p ->+ let queryStr = "QueryParams \"" <> Text.pack s <> "\" " <> Text.pack (show ty)+ highlighted = if ty `elem` dupTypes then queryStr <> " 👈" else queryStr+ in highlighted <> " :> " <> go p+ PVerb method code ty -> "Verb '" <> decodeUtf8 method <> " " <> Text.pack (show code) <> " " <> Text.pack (show ty)++-- | Extract QueryParam names from a Path for duplicate name detection+pathQueryParamNames :: Path -> [String]+pathQueryParamNames = \case+ PPath _ p -> pathQueryParamNames p+ PCapture _ _ p -> pathQueryParamNames p+ PCaptureAll _ _ p -> pathQueryParamNames p+ PReqBody _ p -> pathQueryParamNames p+ PQueryParam name _ p -> name : pathQueryParamNames p+ PQueryParams name _ p -> name : pathQueryParamNames p+ PVerb _ _ _ -> []++-- | Build a route string with emoji pointers for duplicate QueryParam names+showRouteWithDuplicateQueryParamHighlights :: [String] -> Path -> Text.Text+showRouteWithDuplicateQueryParamHighlights dupNames = go+ where+ go = \case+ PPath s p -> "\"" <> Text.pack s <> "\" :> " <> go p+ PCapture s ty p -> "Capture \"" <> Text.pack s <> "\" " <> Text.pack (show ty) <> " :> " <> go p+ PCaptureAll s ty p -> "CaptureAll \"" <> Text.pack s <> "\" " <> Text.pack (show ty) <> " :> " <> go p+ PReqBody ty p -> "ReqBody _ _ " <> Text.pack (show ty) <> " :> " <> go p+ PQueryParam name ty p ->+ let queryStr = "QueryParam \"" <> Text.pack name <> "\" " <> Text.pack (show ty)+ highlighted = if name `elem` dupNames then queryStr <> " 👈" else queryStr+ in highlighted <> " :> " <> go p+ PQueryParams name ty p ->+ let queryStr = "QueryParams \"" <> Text.pack name <> "\" " <> Text.pack (show ty)+ highlighted = if name `elem` dupNames then queryStr <> " 👈" else queryStr+ in highlighted <> " :> " <> go p+ PVerb method code ty -> "Verb '" <> decodeUtf8 method <> " " <> Text.pack (show code) <> " " <> Text.pack (show ty)++-- | Check for duplicate QueryParam names+checkForDuplicateQueryParamNames :: Path -> Maybe Error+checkForDuplicateQueryParamNames p =+ case duplicatedNames of+ [] -> Nothing+ dups -> Just $ Error $+ [ chunk "Route has multiple QueryParam with the same name: "+ , bold $ chunk $ Text.intercalate ", " (Text.pack <$> dups)+ , chunk ". QueryParam names must be unique within a route:"+ ] : [[chunk $ "\t" <> showRouteWithDuplicateQueryParamHighlights dups p]]+ where+ names = pathQueryParamNames p+ duplicatedNames = names \\ nubOrd names+ checkForDuplicates :: Path -> Maybe Error checkForDuplicates p =- if length (nubOrd noStatic) /= length noStatic- then Just $ Error [[ chunk "Duplicate found in route "- , bold $ chunk $ Text.pack (show p) <> ":"- ]]- else Nothing+ case duplicatedTypes of+ [] -> Nothing+ dups -> Just $ Error $+ [ chunk "Route accepts the same type multiple times: "+ , bold $ chunk $ Text.intercalate ", " (Text.pack . show <$> dups)+ , chunk ". This doesn't guarantee argument order and can lead to ambiguous behavior:"+ ] : [[chunk $ "\t" <> showRouteWithDuplicateHighlights dups p]] where- noStatic = filter (\case APath _ -> False; _ -> True) $ ambiguity p+ types = pathTypes p+ duplicatedTypes = types \\ nubOrd types -- | Pretty errors via @Text.Colour@ newtype Error = Error { toChunks :: [[Chunk]] }@@ -166,7 +297,8 @@ let ambiguities = [printAmbiguity psAll p | ambiguity p `elem'` (ambiguity <$> deleteFirst p psAll)] badReturns = mapMaybe (badReturn psAll p) psAll duplicates = checkForDuplicates p- in ambiguities <> badReturns <> maybeToList duplicates <> go ps+ duplicateQueryNames = checkForDuplicateQueryParamNames p+ in ambiguities <> badReturns <> maybeToList duplicates <> maybeToList duplicateQueryNames <> go ps -- | Pass your API type for lint errors thrown in IO -- This is typically useful for testing
test/Main.hs view
@@ -6,15 +6,17 @@ {-# 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 (Capture, CaptureAll, Get, JSON, NoContent,+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,@@ -22,6 +24,7 @@ import Text.Colour (Chunk, TerminalCapabilities (With24BitColours), bold, chunk, fore, red, renderChunksText)+import Data.Kind (Type) type DisambiguatedQuery = QueryParam "foo" Int :> Get '[JSON] ()@@ -76,7 +79,7 @@ type AmbiguousQuerys = QueryParams "foo" Int :> Get '[JSON] ()- :<|> QueryParams"foo" String :> Get '[JSON] ()+ :<|> QueryParams "foo" String :> Get '[JSON] () type AmbiguousReqBody = ReqBody '[JSON] Int :> Post '[JSON] ()@@ -95,12 +98,33 @@ 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 "foo" Bool :> Get '[JSON] ()- :<|> "baz" :> Capture "foo" () :> Capture "foo" Bool :> 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)@@ -119,6 +143,8 @@ 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@@ -208,15 +234,62 @@ ] it "duplicates" $ lintShouldBe @Duplicates- [ [ chunk "Duplicate found in route "- , bold $ chunk "\"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\"bar\" :> CaptureAll \"foo\" () 👈 :> \"baz\" :> CaptureAll \"bar\" () 👈 :> Verb 'GET 200 ()" ] , []- , [ chunk "Duplicate found in route "- , bold $ chunk "\"hunk\" :> QueryParam \"foo\" () :> QueryParam \"foo\" Bool :> 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 "Duplicate found in route "- , bold $ chunk "\"baz\" :> Capture \"foo\" () :> Capture \"foo\" Bool :> 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 ()" ]+ ]+