servant-lint-0.1.0.0: src/Servant/Lint.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
module Servant.Lint
( lintAPI
, lintAPI'
, printLintAPI
, Error(..)
, Path(..)
, Lintable(..)
, Ambiguity(..)
, unlinesChunks
) where
import Data.ByteString (ByteString)
import Data.Containers.ListUtils (nubOrd)
import Data.Kind (Constraint, Type)
import Data.Maybe (mapMaybe, maybeToList)
import Data.Proxy (Proxy (Proxy))
import qualified Data.Text as Text (pack, unpack)
import Data.Text.Encoding (decodeUtf8)
import Data.Typeable (TypeRep, Typeable, typeRep)
import GHC.Generics (Generic)
import GHC.TypeLits (KnownNat, KnownSymbol, natVal,
symbolVal)
import Servant.API (Capture', CaptureAll, NoContent,
NoContentVerb, QueryParam,
QueryParams, ReflectMethod (..),
ReqBody', Verb, type (:<|>),
type (:>))
import Text.Colour (Chunk, TerminalCapabilities (..),
bold, renderChunksText)
import Text.Colour.Chunk (chunk, fore, red)
-- | A term level representation of the API
-- Its defined recursive to flatten the API to a list of routes instead of a tree
type Path :: Type
data Path
= PPath String !Path
| PCapture String TypeRep !Path
| PCaptureAll String TypeRep !Path
| PQueryParam String TypeRep !Path
| PQueryParams String TypeRep !Path
| PReqBody TypeRep !Path
| PVerb ByteString Integer TypeRep
deriving (Eq, Ord)
instance Show Path where
show (PPath s p) = "\"" <> s <> "\" :> " <> show p
show (PCapture s t p) = "Capture \"" <> s <> "\" " <> show t <> " :> " <> show p
show (PCaptureAll s t p) = "CaptureAll \"" <> s <> "\" " <> show t <> " :> " <> show p
show (PQueryParam s t p) = "QueryParam \"" <> s <> "\" " <> show t <> " :> " <> show p
show (PQueryParams s t p) = "QueryParams \"" <> s <> "\" " <> show t <> " :> " <> show p
show (PReqBody t p) = "ReqBody _ _ " <> show t <> " :> " <> show p
show (PVerb method code t) = "Verb '" <> Text.unpack (decodeUtf8 method) <> " " <> show code <> " " <> show t
-- | The Lintable type class describes how to go from a Servant Combinator to a @Path@
-- This is essentially a function from `Type -> [Path]`
-- If you have custom Servant Combinators you may need to add an instance of Lintable for your Combinator, typically ignoring the custom Combinator.
type Lintable :: Type -> Constraint
class Lintable a where
paths :: [Path]
instance (Lintable a, Lintable b) => Lintable (a :<|> b) where
paths = paths @a <> paths @b
instance (Lintable b, KnownSymbol hint, Typeable a) => Lintable (Capture' _mods hint a :> b) where
paths = PCapture (symbolVal (Proxy @hint)) (typeRep (Proxy @a))<$> paths @b
instance (Lintable b, KnownSymbol hint, Typeable a) => Lintable (CaptureAll hint a :> b) where
paths = PCaptureAll (symbolVal (Proxy @hint)) (typeRep (Proxy @a)) <$> paths @b
instance (Lintable b, KnownSymbol hint, Typeable a) => Lintable (QueryParam hint a :> b) where
paths = PQueryParam (symbolVal (Proxy @hint)) (typeRep (Proxy @a)) <$> paths @b
instance (Lintable b, KnownSymbol hint, Typeable a) => Lintable (QueryParams hint a :> b) where
paths = PQueryParam (symbolVal (Proxy @hint)) (typeRep (Proxy @a)) <$> paths @b
instance (Lintable b, Typeable a) => Lintable (ReqBody' _mods _ms a :> b) where
paths = PReqBody (typeRep (Proxy @a)) <$> paths @b
instance (KnownSymbol a, Lintable b) => Lintable (a :> b) where
paths = PPath (symbolVal (Proxy @a)) <$> paths @b
instance {-# OVERLAPPABLE #-}
( ReflectMethod method
) => Lintable (NoContentVerb method) where
paths = pure $ PVerb (reflectMethod (Proxy @method)) 204 (typeRep (Proxy @NoContent))
instance {-# OVERLAPPABLE #-}
( Typeable ret
, ReflectMethod method
, KnownNat code
) => Lintable (Verb method code _cs ret) where
paths = pure $ PVerb (reflectMethod (Proxy @method)) (natVal (Proxy @code)) (typeRep (Proxy @ret))
-- | This is a striped down version of the @Path@ focusing on removing details that ambiguate routes
type Ambiguity :: Type
data Ambiguity
= ACapture
| ACaptureAll
| AQueryParam String
| APath String
| AReqBody
| AVerb ByteString Integer
deriving (Eq, Ord, Show, Generic)
-- | Non lawful Eq check
(=!=) :: Ambiguity -> Ambiguity -> Bool
ACapture =!= _ = True
_ =!= ACapture = True
AReqBody =!= AReqBody = True
APath s =!= APath s' = s == s'
AVerb m c =!= AVerb m' c' = m == m' && c == c'
AQueryParam s =!= AQueryParam s' = s == s'
_ =!= _ = False
-- | Non lawful Eq check
(=!!=) :: [Ambiguity] -> [Ambiguity] -> Bool
a@(ACaptureAll : _) =!!= b@(_:_) = last a =!= last b
a@(_:_) =!!= b@(ACaptureAll:_) = last a =!= last b
(a:as) =!!= (b:bs) = a =!= b && as =!!= bs
[] =!!= [] = True
_ =!!= _ = False
ambiguity :: Path -> [Ambiguity]
ambiguity = \case
PPath s p -> APath s : ambiguity p
PCapture _ _ p -> ACapture : ambiguity p
PCaptureAll _ _ p -> ACaptureAll : ambiguity p
PReqBody _ p -> AReqBody : ambiguity p
PQueryParam s _ p -> AQueryParam s : ambiguity p
PQueryParams s _ p -> AQueryParam s : ambiguity p
PVerb method code _ty -> [AVerb method code]
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
where
noStatic = filter (\case APath _ -> False; _ -> True) $ ambiguity p
-- | Pretty errors via @Text.Colour@
newtype Error = Error { toChunks :: [[Chunk]] }
deriving newtype (Eq, Show, Semigroup, Monoid)
elem' :: [Ambiguity] -> [[Ambiguity]] -> Bool
elem' = any . (=!!=)
-- | Pass your API type for lint errors as Chunks
-- Chunks are colored terminal bits from @Text.Colour@ for making
-- pretty errors.
lintAPI' :: forall api. Lintable api => [Error]
lintAPI' = case go psAll of [x,_] -> [x]; x -> x
where
psAll = paths @api
go [] = []
go (p:ps) =
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
-- | Pass your API type for lint errors thrown in IO
-- This is typically useful for testing
lintAPI :: forall api. Lintable api => IO ()
lintAPI = case lintAPI' @api of
[] -> pure ()
ls -> error $ Text.unpack $ renderChunksText With24BitColours . unlinesChunks $ unlinesChunks . toChunks <$> ls
-- | Pass your API type for lint via @putStrLn@ in stdout
printLintAPI :: forall api. Lintable api => IO ()
printLintAPI = case lintAPI' @api of
[] -> pure ()
ls -> putStrLn $ Text.unpack $ renderChunksText With24BitColours . unlinesChunks $ unlinesChunks . toChunks <$> ls
deleteFirst :: Eq t => t -> [t] -> [t]
deleteFirst _ [] = []
deleteFirst a (b:bc) | a == b = bc
| otherwise = b : deleteFirst a bc
badReturn :: [Path] -> Path -> Path -> Maybe Error
badReturn psAll c = \case
PPath _ p -> badReturn psAll c p
PCapture _ _ p -> badReturn psAll c p
PCaptureAll _ _ p -> badReturn psAll c p
PQueryParam _ _ p -> badReturn psAll c p
PVerb _method 500 _ty -> Just $ Error $
[ chunk "Bad verb, you should never intentionally return 500 as part of your API:"
] : (badReturnColor <$> psAll)
PVerb _method code ty | code /= 204 && ty == typeRep (Proxy @NoContent) -> Just $ Error $
[ chunk "Bad verb, NoContent must use HTTP Status Code 204, not "
, bold $ chunk $ Text.pack $ show code <> ":"
] : (badReturnColor <$> psAll)
PReqBody _ (PVerb "GET" _ _) -> Just $ Error $
[ chunk "Bad verb, do not use ReqBody in a GET request, Http 1.1 says its meaningless"
] : (badReturnColor <$> psAll)
PReqBody _ (PVerb {}) -> Nothing
PReqBody _ _ -> Just $ Error $
[ chunk "ReqBody must be the last combinator before the Verb"
] : (badReturnColor <$> psAll)
_ -> Nothing
where
badReturnColor :: Path -> [Chunk]
badReturnColor p' = pure $
if c == p'
then fore red $ chunk $ ("\t" <>) $ Text.pack $ show p' <> " 👈"
else chunk $ ("\t" <>) $ Text.pack $ show p'
printAmbiguity :: [Path] -> Path -> Error
printAmbiguity ps p = Error $
[ chunk "Ambiguous with "
, bold $ chunk $ Text.pack (show p) <> ":"
] : (overlappingColor <$> ps)
where
overlappingColor p' = pure $
if ambiguity p =!!= ambiguity p'
then fore red $ chunk $ ("\t" <>) $ Text.pack $ show p' <> " 👈"
else chunk $ ("\t" <>) $ Text.pack $ show p'
-- | Exported for testing only
unlinesChunks :: [[Chunk]] -> [Chunk]
unlinesChunks = concatMap (<> [chunk "\n"])