{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} -- | Data types for curl-runnings tests module Testing.CurlRunnings.Types ( AssertionFailure(..) , CaseResult(..) , CurlSuite(..) , CurlCase(..) , Header(..) , HeaderMatcher(..) , Headers(..) , HttpMethod(..) , JsonMatcher(..) , JsonSubExpr(..) , PartialHeaderMatcher(..) , StatusCodeMatcher(..) , QueryError(..) , Index(..) , Query(..) , InterpolatedQuery(..) , FullQueryText , SingleQueryText , CurlRunningsState(..) , isFailing , isPassing , logger , unsafeLogger ) where import Data.Aeson import Data.Aeson.Encode.Pretty import Data.Aeson.Types import qualified Data.ByteString.Lazy.Char8 as B8 import Data.Either import qualified Data.HashMap.Strict as H import Data.List import Data.Maybe import qualified Data.Text as T import qualified Data.Vector as V import GHC.Generics import Testing.CurlRunnings.Internal import Text.Printf -- | A basic enum for supported HTTP verbs data HttpMethod = GET | POST | PUT | PATCH | DELETE deriving (Show, Generic) instance FromJSON HttpMethod instance ToJSON HttpMethod -- | A predicate to apply to the json body from the response data JsonMatcher -- | Performs `==` = Exactly Value -- | A list of matchers to make assertions about some subset of the response. | Contains [JsonSubExpr] deriving (Show, Generic) instance ToJSON JsonMatcher instance FromJSON JsonMatcher where parseJSON (Object v) | isJust $ H.lookup "exactly" v = Exactly <$> v .: "exactly" | isJust $ H.lookup "contains" v = Contains <$> v .: "contains" parseJSON invalid = typeMismatch "JsonMatcher" invalid -- | A representation of a single header data Header = Header T.Text T.Text deriving (Show, Generic) instance ToJSON Header -- | Simple container for a list of headers, useful for a vehicle for defining a -- fromJSON data Headers = HeaderSet [Header] deriving (Show, Generic) instance ToJSON Headers -- | Specify a key, value, or both to match against in the returned headers of a -- response. data PartialHeaderMatcher = PartialHeaderMatcher (Maybe T.Text) (Maybe T.Text) deriving (Show, Generic) instance ToJSON PartialHeaderMatcher -- | Collection of matchers to run against a single curl response data HeaderMatcher = HeaderMatcher [PartialHeaderMatcher] deriving (Show, Generic) instance ToJSON HeaderMatcher -- | Different errors relating to querying json from previous test cases data QueryError -- | The query was malformed and couldn't be parsed = QueryParseError T.Text T.Text -- | The retrieved a value of the wrong type or was otherwise operating on the -- wrong type of thing | QueryTypeMismatch T.Text Value -- | The query was parse-able | QueryValidationError T.Text -- | Tried to access a value in a null object | NullPointer T.Text T.Text instance Show QueryError where show (QueryParseError t q) = printf "error parsing query %s: %s" q $ T.unpack t show (NullPointer full part) = printf "null pointer in %s at %s" (T.unpack full) $ T.unpack part show (QueryTypeMismatch message val) = printf "type error: %s %s" (message) $ show val show (QueryValidationError message) = printf "invalid query: %s" message parseHeader :: T.Text -> Either T.Text Header parseHeader str = case map T.strip $ T.splitOn ":" str of [key, val] -> Right $ Header key val anythingElse -> Left . T.pack $ "bad header found: " ++ (show anythingElse) parseHeaders :: T.Text -> Either T.Text Headers parseHeaders str = let _headers = filter (/= "") $ T.splitOn ";" str parses = map parseHeader _headers in case find isLeft parses of Just (Left failure) -> Left failure _ -> Right . HeaderSet $ map (fromRight $ error "Internal error parsing headers, this is a bug in curl runnings :(") parses instance FromJSON Headers where parseJSON a@(String v) = case parseHeaders v of Right h -> return h Left failure -> typeMismatch ("Header failure: " ++ T.unpack failure) a parseJSON invalid = typeMismatch "Header" invalid instance FromJSON HeaderMatcher where parseJSON o@(String v) = either (\s -> typeMismatch ("HeaderMatcher: " ++ T.unpack s) o) (\(HeaderSet parsed) -> return . HeaderMatcher $ map (\(Header key val) -> PartialHeaderMatcher (Just key) (Just val)) parsed) (parseHeaders v) parseJSON (Object v) = do partial <- PartialHeaderMatcher <$> v .:? "key" <*> v .:? "value" return $ HeaderMatcher [partial] parseJSON (Array v) = mconcat . V.toList $ V.map parseJSON v parseJSON invalid = typeMismatch "HeaderMatcher" invalid -- | A matcher for a subvalue of a json payload data JsonSubExpr -- | Assert some value anywhere in the json has a value equal to a given -- value. The motivation for this field is largely for checking contents of a -- top level array. It's also useful if you don't know the key ahead of time. = ValueMatch Value -- | Assert the key value pair can be found somewhere the json. | KeyValueMatch { matchKey :: T.Text , matchValue :: Value } deriving (Show, Generic) instance FromJSON JsonSubExpr where parseJSON (Object v) | isJust $ H.lookup "keyValueMatch" v = let toParse = fromJust $ H.lookup "keyValueMatch" v in case toParse of Object o -> KeyValueMatch <$> o .: "key" <*> o .: "value" _ -> typeMismatch "JsonSubExpr" toParse | isJust $ H.lookup "valueMatch" v = ValueMatch <$> v .: "valueMatch" parseJSON invalid = typeMismatch "JsonSubExpr" invalid instance ToJSON JsonSubExpr -- | Check the status code of a response. You can specify one or many valid codes. data StatusCodeMatcher = ExactCode Int | AnyCodeIn [Int] deriving (Show, Generic) instance ToJSON StatusCodeMatcher instance FromJSON StatusCodeMatcher where parseJSON obj@(Number _) = ExactCode <$> parseJSON obj parseJSON obj@(Array _) = AnyCodeIn <$> parseJSON obj parseJSON invalid = typeMismatch "StatusCodeMatcher" invalid -- | A single curl test case, the basic foundation of a curl-runnings test. data CurlCase = CurlCase { name :: String -- ^ The name of the test case , url :: String -- ^ The target url to test , requestMethod :: HttpMethod -- ^ Verb to use for the request , requestData :: Maybe Value -- ^ Payload to send with the request, if any , headers :: Maybe Headers -- ^ Headers to send with the request, if any , expectData :: Maybe JsonMatcher -- ^ The assertions to make on the response payload, if any , expectStatus :: StatusCodeMatcher -- ^ Assertion about the status code returned by the target , expectHeaders :: Maybe HeaderMatcher -- ^ Assertions to make about the response headers, if any } deriving (Show, Generic) instance FromJSON CurlCase instance ToJSON CurlCase -- | Represents the different type of test failures we can have. A single test case -- | might return many assertion failures. data AssertionFailure -- | The json we got back was wrong. We include this redundant field (it's -- included in the CurlCase field above) in order to enforce at the type -- level that we have to be expecting some data in order to have this type of -- failure. = DataFailure CurlCase JsonMatcher (Maybe Value) -- | The status code we got back was wrong | StatusFailure CurlCase Int -- | The headers we got back were wrong | HeaderFailure CurlCase HeaderMatcher Headers -- | Something went wrong with a test case json query | QueryFailure CurlCase QueryError -- | Something else | UnexpectedFailure colorizeExpects :: String -> String colorizeExpects t = let expectedColor = makeRed "Excpected:" actualColor = makeRed "Actual:" replacedExpected = T.replace "Expected:" (T.pack expectedColor) (T.pack t) in T.unpack $ T.replace "Actual:" (T.pack actualColor) replacedExpected instance Show AssertionFailure where show (StatusFailure c receivedCode) = case expectStatus c of ExactCode code -> colorizeExpects $ printf "Incorrect status code from %s. Expected: %s. Actual: %s" (url c) (show code) (show receivedCode) AnyCodeIn codes -> colorizeExpects $ printf "Incorrect status code from %s. Expected: %s. Actual: %s" (url c) (show codes) (show receivedCode) show (DataFailure curlCase expected receivedVal) = case expected of Exactly expectedVal -> colorizeExpects $ printf "JSON response from %s didn't match spec. Expected: %s. Actual: %s" (url curlCase) (B8.unpack (encodePretty expectedVal)) (B8.unpack (encodePretty receivedVal)) (Contains expectedVals) -> colorizeExpects $ printf "JSON response from %s didn't contain the matcher. Expected: %s to be each be subvalues in: %s" (url curlCase) (B8.unpack (encodePretty expectedVals)) (B8.unpack (encodePretty receivedVal)) show (HeaderFailure curlCase expected receivedHeaders) = colorizeExpects $ printf "Headers from %s didn't contain expected headers. Expected: %s. Actual: %s" (url curlCase) (show expected) (show receivedHeaders) show (QueryFailure curlCase queryErr) = colorizeExpects $ printf "JSON query error in spec %s: %s" (name curlCase) (show queryErr) show UnexpectedFailure = "Unexpected Error D:" -- | A type representing the result of a single curl, and all associated -- assertions data CaseResult = CasePass CurlCase (Maybe Headers) (Maybe Value) | CaseFail CurlCase (Maybe Headers) (Maybe Value) [AssertionFailure] instance Show CaseResult where show (CasePass c _ _) = makeGreen "[PASS] " ++ name c show (CaseFail c _ _ failures) = makeRed "[FAIL] " ++ name c ++ "\n" ++ concatMap ((\s -> "\nAssertion failed: " ++ s) . (++ "\n") . show) failures -- | A wrapper type around a set of test cases. This is the top level spec type -- that we parse a test spec file into newtype CurlSuite = CurlSuite [CurlCase] deriving (Show, Generic) instance FromJSON CurlSuite instance ToJSON CurlSuite -- | Simple predicate that checks if the result is passing isPassing :: CaseResult -> Bool isPassing CasePass {} = True isPassing CaseFail {} = False -- | Simple predicate that checks if the result is failing isFailing :: CaseResult -> Bool isFailing = not . isPassing -- | A map of the system environment type Environment = H.HashMap T.Text T.Text -- | The state of a suite. Tracks environment variables, and all the test results so far data CurlRunningsState = CurlRunningsState Environment [CaseResult] LogLevel logger :: CurlRunningsState -> CurlRunningsLogger logger (CurlRunningsState _ _ l) = makeLogger l unsafeLogger :: Show a => CurlRunningsState -> CurlRunningsUnsafeLogger a unsafeLogger (CurlRunningsState _ _ l) = makeUnsafeLogger l -- | A single lookup operation in a json query data Index -- | Drill into the json of a specific test case. The SUITE object is -- accessible as an array of values that have come back from previous test -- cases = CaseResultIndex Integer -- | A standard json key lookup. | KeyIndex T.Text -- | A standard json array index lookup. | ArrayIndex Integer deriving (Show) printOriginalQuery (CaseResultIndex t) = "SUITE[" ++ show t ++ "]" printOriginalQuery (KeyIndex key) = "." ++ T.unpack key printOriginalQuery (ArrayIndex i) = printf "[%d]" i -- | A single entity to be queries from a json value data Query = -- | A single query contains a list of discrete index operations Query [Index] | -- | Lookup a string in the environment EnvironmentVariable T.Text deriving (Show) -- | A distinct parsed unit in a query data InterpolatedQuery -- | Regular text, no query = LiteralText T.Text -- | Some leading text, then a query | InterpolatedQuery T.Text Query -- | Just a query, no leading text | NonInterpolatedQuery Query deriving (Show) printQueryString :: InterpolatedQuery -> String printQueryString (LiteralText t) = show t printQueryString (InterpolatedQuery raw (Query indexes)) = printf "%s$<%s>" raw (concat $ map show indexes) printQueryString (NonInterpolatedQuery (Query indexes)) = printf "$<%s>" (concat $ map show indexes) -- | The full string in which a query appears, eg "prefix-${{SUITE[0].key.another_key[0].last_key}}" type FullQueryText = T.Text -- | The string for one query given the FullQueryText above, the single query text would be SUITE[0].key.another_key[0].last_key type SingleQueryText = T.Text