hal 0.4.8 → 0.4.9
raw patch · 10 files changed
+673/−35 lines, 10 filesdep +raw-strings-qqdep ~aesondep ~hedgehogdep ~scientificPVP ok
version bump matches the API change (PVP)
Dependencies added: raw-strings-qq
Dependency ranges changed: aeson, hedgehog, scientific, vector
API changes (from Hackage documentation)
+ AWS.Lambda.Events.Kafka: AwsKafka :: EventSource
+ AWS.Lambda.Events.Kafka: CreateTime :: !UTCTime -> Timestamp
+ AWS.Lambda.Events.Kafka: Header :: !Text -> !ByteString -> Header
+ AWS.Lambda.Events.Kafka: KafkaEvent :: !EventSource -> !Maybe Text -> !NonEmpty Text -> !Map Text [Record] -> KafkaEvent
+ AWS.Lambda.Events.Kafka: LogAppendTime :: !UTCTime -> Timestamp
+ AWS.Lambda.Events.Kafka: NoTimestampType :: Timestamp
+ AWS.Lambda.Events.Kafka: Record :: !Text -> !Int32 -> !Int64 -> !Timestamp -> [Header] -> !Maybe ByteString -> !Maybe a -> Record' a
+ AWS.Lambda.Events.Kafka: SelfManagedKafka :: EventSource
+ AWS.Lambda.Events.Kafka: [$sel:bootstrapServers:KafkaEvent] :: KafkaEvent -> !NonEmpty Text
+ AWS.Lambda.Events.Kafka: [$sel:eventSource:KafkaEvent] :: KafkaEvent -> !EventSource
+ AWS.Lambda.Events.Kafka: [$sel:eventSourceArn:KafkaEvent] :: KafkaEvent -> !Maybe Text
+ AWS.Lambda.Events.Kafka: [$sel:headers:Record] :: Record' a -> [Header]
+ AWS.Lambda.Events.Kafka: [$sel:key:Record] :: Record' a -> !Maybe ByteString
+ AWS.Lambda.Events.Kafka: [$sel:offset:Record] :: Record' a -> !Int64
+ AWS.Lambda.Events.Kafka: [$sel:partition:Record] :: Record' a -> !Int32
+ AWS.Lambda.Events.Kafka: [$sel:records:KafkaEvent] :: KafkaEvent -> !Map Text [Record]
+ AWS.Lambda.Events.Kafka: [$sel:timestamp:Record] :: Record' a -> !Timestamp
+ AWS.Lambda.Events.Kafka: [$sel:topic:Record] :: Record' a -> !Text
+ AWS.Lambda.Events.Kafka: [$sel:value:Record] :: Record' a -> !Maybe a
+ AWS.Lambda.Events.Kafka: data EventSource
+ AWS.Lambda.Events.Kafka: data Header
+ AWS.Lambda.Events.Kafka: data KafkaEvent
+ AWS.Lambda.Events.Kafka: data Record' a
+ AWS.Lambda.Events.Kafka: data Timestamp
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.FromJSON.FromJSON (AWS.Lambda.Events.Kafka.Record' Data.ByteString.Internal.ByteString)
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.FromJSON.FromJSON AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.FromJSON.FromJSON AWS.Lambda.Events.Kafka.Header
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.FromJSON.FromJSON AWS.Lambda.Events.Kafka.KafkaEvent
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.ToJSON.ToJSON (AWS.Lambda.Events.Kafka.Record' Data.ByteString.Internal.ByteString)
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.ToJSON.ToJSON AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.ToJSON.ToJSON AWS.Lambda.Events.Kafka.Header
+ AWS.Lambda.Events.Kafka: instance Data.Aeson.Types.ToJSON.ToJSON AWS.Lambda.Events.Kafka.KafkaEvent
+ AWS.Lambda.Events.Kafka: instance Data.Foldable.Foldable AWS.Lambda.Events.Kafka.Record'
+ AWS.Lambda.Events.Kafka: instance Data.Traversable.Traversable AWS.Lambda.Events.Kafka.Record'
+ AWS.Lambda.Events.Kafka: instance GHC.Base.Functor AWS.Lambda.Events.Kafka.Record'
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Eq AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Eq AWS.Lambda.Events.Kafka.Header
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Eq AWS.Lambda.Events.Kafka.KafkaEvent
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Eq AWS.Lambda.Events.Kafka.Timestamp
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Eq a => GHC.Classes.Eq (AWS.Lambda.Events.Kafka.Record' a)
+ AWS.Lambda.Events.Kafka: instance GHC.Classes.Ord AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Enum.Bounded AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Enum.Enum AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Generics.Generic (AWS.Lambda.Events.Kafka.Record' a)
+ AWS.Lambda.Events.Kafka: instance GHC.Generics.Generic AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Generics.Generic AWS.Lambda.Events.Kafka.Header
+ AWS.Lambda.Events.Kafka: instance GHC.Generics.Generic AWS.Lambda.Events.Kafka.KafkaEvent
+ AWS.Lambda.Events.Kafka: instance GHC.Generics.Generic AWS.Lambda.Events.Kafka.Timestamp
+ AWS.Lambda.Events.Kafka: instance GHC.Show.Show AWS.Lambda.Events.Kafka.EventSource
+ AWS.Lambda.Events.Kafka: instance GHC.Show.Show AWS.Lambda.Events.Kafka.Header
+ AWS.Lambda.Events.Kafka: instance GHC.Show.Show AWS.Lambda.Events.Kafka.KafkaEvent
+ AWS.Lambda.Events.Kafka: instance GHC.Show.Show AWS.Lambda.Events.Kafka.Timestamp
+ AWS.Lambda.Events.Kafka: instance GHC.Show.Show a => GHC.Show.Show (AWS.Lambda.Events.Kafka.Record' a)
+ AWS.Lambda.Events.Kafka: int64ToUTCTime :: Int64 -> UTCTime
+ AWS.Lambda.Events.Kafka: parseTimestamp :: Object -> Parser Timestamp
+ AWS.Lambda.Events.Kafka: type Record = Record' ByteString
+ AWS.Lambda.Events.Kafka: unparseTimestamp :: KeyValue kv => Timestamp -> [kv]
+ AWS.Lambda.Events.Kafka: utcTimeToInt64 :: UTCTime -> Int64
Files
- CHANGELOG.md +8/−0
- README.md +12/−12
- hal.cabal +13/−6
- src/AWS/Lambda/Events/Kafka.hs +273/−0
- test/AWS/Lambda/Events/ApiGateway/ProxyRequest/Gen.hs +19/−3
- test/AWS/Lambda/Events/ApiGateway/ProxyRequest/Spec.hs +15/−0
- test/AWS/Lambda/Events/ApiGateway/ProxyResponse/Spec.hs +15/−0
- test/AWS/Lambda/Events/Kafka/Gen.hs +108/−0
- test/AWS/Lambda/Events/Kafka/Spec.hs +199/−0
- test/Spec.hs +11/−14
CHANGELOG.md view
@@ -5,6 +5,14 @@ The format is based on [Keep a Changelog][chg] and this project adheres to [Haskell's Package Versioning Policy][pvp] +## `0.4.9` - 2022-02-28++ - Add `KafkaEvent` type for subscribing Lambda functions to Kafka+ topics+ - Support `aeson ^>=2.0.0.0`+ - Drop official support for GHC 8.0.2+ - Expand hedgehog bounds to include 1.1 to keep ahead of Stack nightly+ ## `0.4.8` - 2021-05-07 - Add `ToJSON` instances for `ProxyRequest` types, and test
README.md view
@@ -1,4 +1,5 @@-[](https://travis-ci.org/Nike-Inc/hal)+[](https://github.com/Nike-Inc/hal/actions/workflows/haskell_stack.yaml)+[](https://github.com/Nike-Inc/hal/actions/workflows/haskell_cabal.yaml) # hal @@ -13,8 +14,8 @@ It accepts a handler with the signature `(FromJSON a, ToJSON b) => a -> b`. This runtime guarantees that side-effects cannot occur. -For advanced use cases `mRuntimeWithContext` unlocks the full power of Monad Transformers.-It accepts handlers with the signature `(HasLambdaContext r, MonadCatch m, MonadReader r m, MonadIO m, FromJSON event, ToJSON result) => (event -> m result)`+For advanced use cases `mRuntime` unlocks the full power of Monad Transformers.+It accepts handlers with the signature `(MonadCatch m, MonadIO m, FromJSON event, ToJSON result) => (event -> m result)` This enables users to add caching logic or expose complex environments. With numerous options in between these two, developers can choose the right balance of flexibility vs simplicity.@@ -63,9 +64,8 @@ ```yaml #...-packages:- - '.'- - hal-0.1.2+extra-deps:+ - hal-${DESIRED_VERSION} # ... docker: enable: true@@ -85,10 +85,10 @@ import GHC.Generics (Generic) data IdEvent = IdEvent { input :: String } deriving Generic-instance FromJSON IdEvent where+instance FromJSON IdEvent data IdResult = IdResult { output :: String } deriving Generic-instance ToJSON IdResult where+instance ToJSON IdResult handler :: IdEvent -> IdResult handler IdEvent { input } = IdResult { output = input }@@ -153,7 +153,7 @@ # Create your function package aws cloudformation package \- --template-file template.yaml+ --template-file template.yaml \ --s3-bucket your-existing-bucket > \ deployment_stack.yaml @@ -167,8 +167,8 @@ # Take it for a spin! aws lambda invoke \ --function-name your-function-name \- --region us-west-2- --payload '{"input": "foo"}'+ --region us-west-2 \+ --payload '{"input": "foo"}' \ output.txt ``` @@ -213,7 +213,7 @@ [aws-sam-cli]: https://github.com/awslabs/aws-sam-cli [Rust Runtime]: https://github.com/awslabs/aws-lambda-rust-runtime [lambda-env]: https://docs.aws.amazon.com/lambda/latest/dg/current-supported-versions.html-[ci]: https://travis-ci.org/Nike-Inc/hal+[ci]: https://github.com/Nike-Inc/hal/actions [stackage]: https://www.stackage.org/ [GHC]: https://www.haskell.org/ghc/download.html [Cabal]: https://www.haskell.org/cabal/download.html
hal.cabal view
@@ -1,13 +1,13 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.34.4.+-- This file has been generated from package.yaml by hpack version 0.33.0. -- -- see: https://github.com/sol/hpack ----- hash: e76a8db81a6c7a721b849d1efca110b833ece7559f999740e5ac5c2d6cdd36e1+-- hash: 804d66701a670f6c063313f96b6b5c9754cf0a4a67b7fc4651e11ed8ab059608 name: hal-version: 0.4.8+version: 0.4.9 synopsis: A runtime environment for Haskell applications running on AWS Lambda. description: This library uniquely supports different types of AWS Lambda Handlers for your needs/comfort with advanced Haskell. Instead of exposing a single function@@ -35,6 +35,7 @@ AWS.Lambda.Context AWS.Lambda.Events.ApiGateway.ProxyRequest AWS.Lambda.Events.ApiGateway.ProxyResponse+ AWS.Lambda.Events.Kafka AWS.Lambda.Events.S3 AWS.Lambda.Events.SQS AWS.Lambda.Internal@@ -81,6 +82,7 @@ , http-client , http-types , mtl+ , scientific >=0.3.3.0 && <0.4 , text , time , unordered-containers@@ -93,7 +95,11 @@ AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen.Parameters AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen.Resource+ AWS.Lambda.Events.ApiGateway.ProxyRequest.Spec AWS.Lambda.Events.ApiGateway.ProxyResponse.Gen+ AWS.Lambda.Events.ApiGateway.ProxyResponse.Spec+ AWS.Lambda.Events.Kafka.Gen+ AWS.Lambda.Events.Kafka.Spec Gen.Header Paths_hal hs-source-dirs:@@ -119,22 +125,23 @@ ScopedTypeVariables ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -fno-warn-partial-type-signatures -fno-warn-name-shadowing -fwarn-tabs -fwarn-unused-imports -fwarn-missing-signatures -fwarn-incomplete-patterns -threaded -rtsopts -with-rtsopts=-N build-depends:- aeson+ aeson >=1.2.0.0 && <1.6 || >=2.0.0.0 && <2.1 , base >=4.7 && <5 , base64-bytestring , bytestring , case-insensitive , containers , hal- , hedgehog >=1.0.3 && <1.1+ , hedgehog >=1.0.3 && <1.2 , hspec , hspec-hedgehog , http-client , http-types >=0.12.2 && <0.13+ , raw-strings-qq ==1.1.* , scientific , text , time , transformers , unordered-containers- , vector+ , vector >=0.12.0.0 && <0.13 default-language: Haskell2010
+ src/AWS/Lambda/Events/Kafka.hs view
@@ -0,0 +1,273 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE DeriveTraversable #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE NamedFieldPuns #-}++{-|+Module : AWS.Lambda.Events.Kafka+Description : Data types for consuming Kafka events.+License : BSD3+Stability : stable++It is possible to subscribe Lambda functions to Kafka topics. You can+subscribe to topics from Amazon Managed Streaming for Kafka (MSK) as+well as self-managed Kafka clusters.++Lambda considers Amazon Managed Streaming for Kafka (MSK) to be a+different event source type from a self-managed Apache Kafka cluster,+but their payloads are very similar. The types in this module are+derived from inspecting invocation payloads, and from reading the+following links:++ * https://docs.aws.amazon.com/lambda/latest/dg/with-kafka.html+ * https://docs.aws.amazon.com/lambda/latest/dg/with-msk.html+ * https://aws.amazon.com/blogs/compute/using-amazon-msk-as-an-event-source-for-aws-lambda/+ * https://aws.amazon.com/blogs/compute/using-self-hosted-apache-kafka-as-an-event-source-for-aws-lambda/++-}++module AWS.Lambda.Events.Kafka (+ KafkaEvent(..),+ EventSource(..),+ Record,+ Record'(..),+ Header (..),+ Timestamp(..),+ -- * Internal+ parseTimestamp,+ unparseTimestamp,+ int64ToUTCTime,+ utcTimeToInt64+) where++import Data.Aeson+import Data.Aeson.Encoding (text)+import Data.Aeson.Types+import Data.ByteString (ByteString)+import qualified Data.ByteString as B+import qualified Data.ByteString.Base64 as B64+import Data.List.NonEmpty (NonEmpty)+import qualified Data.List.NonEmpty as NE+import Data.Function ((&))+import Data.Int (Int32, Int64)+import Data.Map (Map)+import Data.Maybe (catMaybes, maybeToList)+import Data.Scientific (toBoundedInteger)+import Data.Semigroup ((<>))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime,+ utcTimeToPOSIXSeconds)+import GHC.Generics (Generic)++#if MIN_VERSION_aeson(2,0,0)+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM++keyToText :: Key -> Text+keyToText = Key.toText++keyFromText :: Text -> Key+keyFromText = Key.fromText+#else+import qualified Data.HashMap.Strict as KM+type Key = Text++keyToText :: Key -> Text+keyToText = id++keyFromText :: Text -> Key+keyFromText = id+#endif++-- | Represents an event from either Amazon MSK or a self-managed+-- Apache Kafka cluster, as the payloads are very similar.+--+-- The 'ToJSON' and 'FromJSON' instances on 'Record' perform base64+-- conversion for you.+--+-- See the <https://docs.aws.amazon.com/lambda/latest/dg/with-kafka.html AWS documentation>+-- for a sample payload.+data KafkaEvent = KafkaEvent {+ eventSource :: !EventSource,+ -- | Only present when using the "Amazon MSK" event source mapping.+ eventSourceArn :: !(Maybe Text),+ bootstrapServers :: !(NonEmpty Text),+ -- | The map's keys look like @"${topicName}-${partitionNumber}"@+ records :: !(Map Text [Record])+} deriving (Eq, Show, Generic)++instance FromJSON KafkaEvent where+ parseJSON = withObject "KafkaEvent" $ \o ->+ let parseBootstrapServers+ = maybe (fail "bootstrapServers: empty string") pure+ . NE.nonEmpty+ . T.splitOn ","+ in KafkaEvent+ <$> o .: "eventSource"+ <*> o .:! "eventSourceArn"+ <*> (o .: "bootstrapServers" >>= parseBootstrapServers)+ <*> o .: "records"++instance ToJSON KafkaEvent where+ toJSON e = object $+ [ "eventSource" .= eventSource e+ , "bootstrapServers" .= T.intercalate "," (NE.toList (bootstrapServers e))+ , "records" .= records e+ ] <> maybeToList (("eventSourceArn" .=) <$> eventSourceArn e)++ toEncoding e = pairs . mconcat $+ [ "eventSource" .= eventSource e+ , "bootstrapServers" .= T.intercalate "," (NE.toList (bootstrapServers e))+ , "records" .= records e+ ] <> maybeToList (("eventSourceArn" .=) <$> eventSourceArn e)++data EventSource+ = AwsKafka -- ^ @"aws:kafka"@+ | SelfManagedKafka -- ^ @"SelfManagedKafka"@+ deriving (Eq, Show, Bounded, Enum, Ord, Generic)++instance FromJSON EventSource where+ parseJSON = withText "EventSource" $ \case+ "aws:kafka" -> pure AwsKafka+ "SelfManagedKafka" -> pure SelfManagedKafka+ t -> fail $ "unrecognised EventSource: \"" <> show t <> "\""++instance ToJSON EventSource where+ toJSON = \case+ AwsKafka -> String "aws:kafka"+ SelfManagedKafka -> String "SelfManagedKafka"++ toEncoding = text . \case+ AwsKafka -> "aws:kafka"+ SelfManagedKafka -> "SelfManagedKafka"++-- | Convenience alias: most of the time you will parse the records+-- straight into some app-specific structure.+type Record = Record' ByteString++-- | Records from a Kafka event. This is 'Traversable', which means+-- you can do things like parse a JSON-encoded payload:+--+-- @+-- 'traverse' 'decodeStrict' :: 'FromJSON' a => Record -> Maybe (Record' a)+-- @+data Record' a = Record {+ topic :: !Text,+ partition :: !Int32,+ offset :: !Int64,+ timestamp :: !Timestamp,+ -- | NOTE: there can be multiple headers for a given key.+ headers :: [Header],+ key :: !(Maybe ByteString),+ value :: !(Maybe a)+} deriving (Eq, Show, Generic, Functor, Foldable, Traversable)++-- | Decodes base64-encoded keys and values, where present.+instance FromJSON (Record' ByteString) where+ parseJSON = withObject "Record" $ \o -> do+ topic <- o .: "topic"+ partition <- o .: "partition"+ offset <- o .: "offset"+ timestamp <- parseTimestamp o+ headers <- o .: "headers"+ key <- fmap (B64.decodeLenient . TE.encodeUtf8) <$> o .:? "key"+ value <- fmap (B64.decodeLenient . TE.encodeUtf8) <$> o .:? "value"+ pure Record { topic, partition, offset, timestamp, headers, key, value }++-- | Encodes keys and values into base64.+instance ToJSON (Record' ByteString) where+ toJSON r = object $ concat+ [+ [ "offset" .= offset r+ , "partition" .= partition r+ , "topic" .= topic r+ , "headers" .= headers r+ ]+ , unparseTimestamp (timestamp r)+ , catMaybes+ [ ("key" .=) . TE.decodeUtf8 . B64.encode <$> key r+ , ("value" .=) . TE.decodeUtf8 . B64.encode <$> value r+ ]+ ]++ toEncoding r = pairs . mconcat . concat $+ [+ [ "offset" .= offset r+ , "partition" .= partition r+ , "topic" .= topic r+ , "headers" .= headers r+ ]+ , unparseTimestamp (timestamp r)+ , catMaybes+ [ ("key" .=) . TE.decodeUtf8 . B64.encode <$> key r+ , ("value" .=) . TE.decodeUtf8 . B64.encode <$> value r+ ]+ ]++-- | AWS serialises record headers to JSON as an array of+-- objects. From their docs:+--+-- @+-- "headers":[{"headerKey":[104, 101, 97, 100, 101, 114, 86, 97, 108, 117, 101]}]+-- @+--+-- Note:+--+-- >>> map chr [104, 101, 97, 100, 101, 114, 86, 97, 108, 117, 101]+-- "headerValue"+data Header = Header !Text !ByteString deriving (Eq, Show, Generic)++instance FromJSON Header where+ parseJSON = withObject "header" $ \o ->+ case KM.toList o of+ [(key, value)] -> Header (keyToText key) . B.pack <$> parseJSON value+ [] -> fail "Unexpected empty object"+ _ -> fail "Unexpected additional keys in object"++instance ToJSON Header where+ toJSON (Header key value) = object [keyFromText key .= B.unpack value]+ toEncoding (Header key value) = pairs $ keyFromText key .= B.unpack value++-- | Kafka timestamp types, derived from the Java client's enum at:+-- https://github.com/apache/kafka/blob/trunk/clients/src/main/java/org/apache/kafka/common/record/TimestampType.java+data Timestamp = NoTimestampType | CreateTime !UTCTime | LogAppendTime !UTCTime+ deriving (Eq, Show, Generic)++parseTimestamp :: Object -> Parser Timestamp+parseTimestamp o = explicitParseField parseSubtype o "timestampType"+ where+ parseSubtype = withText "timestampType" $ \case+ "NO_TIMESTAMP_TYPE" -> pure NoTimestampType+ "CREATE_TIME" -> CreateTime <$> parseUTCTimestamp+ "LOG_APPEND_TIME" -> LogAppendTime <$> parseUTCTimestamp+ t -> fail $ "unknown timestampType: \"" <> show t <> "\""++ parseUTCTimestamp = explicitParseField convertToUTCTime o "timestamp"++ convertToUTCTime = withScientific "timestamp" $ \stamp ->+ int64ToUTCTime <$> (toBoundedInteger stamp &+ maybe (fail "timestamp out of range") pure :: Parser Int64)++unparseTimestamp :: KeyValue kv => Timestamp -> [kv]+unparseTimestamp = \case+ NoTimestampType -> ["timestampType" .= String "NO_TIMESTAMP_TYPE"]+ CreateTime ts ->+ [ "timestampType" .= String "CREATE_TIME"+ , "timestamp" .= utcTimeToInt64 ts+ ]+ LogAppendTime ts ->+ [ "timestampType" .= String "LOG_APPEND_TIME"+ , "timestamp" .= utcTimeToInt64 ts+ ]++int64ToUTCTime :: Int64 -> UTCTime+int64ToUTCTime int =+ let (seconds, millis) = int `divMod` 1000+ posixSeconds = fromIntegral seconds + fromIntegral millis / 1000+ in posixSecondsToUTCTime posixSeconds++utcTimeToInt64 :: UTCTime -> Int64+utcTimeToInt64 utc = truncate $ utcTimeToPOSIXSeconds utc * 1000
test/AWS/Lambda/Events/ApiGateway/ProxyRequest/Gen.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE CPP #-}+ module AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen where import qualified AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen.Resource as Resource@@ -6,7 +8,6 @@ import Control.Applicative (liftA2) import Data.Aeson (Value(..)) import qualified Data.ByteString.Lazy as BL-import qualified Data.HashMap.Strict as H import Data.Scientific (fromFloatDigits) import qualified Data.Vector as V import qualified Gen.Header as Header@@ -17,6 +18,21 @@ import Data.Text (Text) import qualified Data.Text.Encoding as TE +#if MIN_VERSION_aeson(2,0,0)+import Data.Aeson.Key (Key)+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM++keyFromText :: Text -> Key+keyFromText = Key.fromText+#else+import qualified Data.HashMap.Strict as KM+type Key = Text++keyFromText :: Text -> Key+keyFromText = id+#endif+ proxyRequest :: Gen (ProxyRequest NoAuthorizer) proxyRequest = do segments <- Resource.segments@@ -105,8 +121,8 @@ [string, number, Bool <$> Gen.bool] [object, array] where- object = Object . H.fromList <$> Gen.list (Range.linear 1 50) entry- entry = liftA2 (,) text authJson+ object = Object . KM.fromList <$> Gen.list (Range.linear 1 50) entry+ entry = liftA2 (,) (keyFromText <$> text) authJson array = Array . V.fromList <$> Gen.list (Range.linear 1 50) authJson string = String <$> Gen.text (Range.exponential 1 200) Gen.unicode number = Number . fromFloatDigits <$>
+ test/AWS/Lambda/Events/ApiGateway/ProxyRequest/Spec.hs view
@@ -0,0 +1,15 @@+module AWS.Lambda.Events.ApiGateway.ProxyRequest.Spec where++import qualified AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen as Gen+import Data.Aeson (decode, encode)+import Hedgehog+import Test.Hspec (Spec, describe, specify)+import Test.Hspec.Hedgehog (hedgehog)++spec :: Spec+spec = do+ describe "properties" $+ specify "tripping" $+ hedgehog $ do+ request <- forAll Gen.proxyRequest+ tripping request encode decode
+ test/AWS/Lambda/Events/ApiGateway/ProxyResponse/Spec.hs view
@@ -0,0 +1,15 @@+module AWS.Lambda.Events.ApiGateway.ProxyResponse.Spec where++import qualified AWS.Lambda.Events.ApiGateway.ProxyResponse.Gen as Gen+import Data.Aeson (decode, encode)+import Hedgehog+import Test.Hspec (Spec, describe, specify)+import Test.Hspec.Hedgehog (hedgehog)++spec :: Spec+spec = do+ describe "properties" $+ specify "tripping" $+ hedgehog $ do+ request <- forAll Gen.proxyResponse+ tripping request encode decode
+ test/AWS/Lambda/Events/Kafka/Gen.hs view
@@ -0,0 +1,108 @@+module AWS.Lambda.Events.Kafka.Gen where++import AWS.Lambda.Events.Kafka (KafkaEvent(KafkaEvent),+ EventSource (..),+ Header(..),+ Record,+ Record' (Record),+ Timestamp (..),+ int64ToUTCTime)+import Control.Applicative (liftA2)+import Data.Char (isPrint)+import Data.Foldable (fold)+import Data.List (intersperse)+import qualified Data.List.NonEmpty as NE+import Data.List.NonEmpty (NonEmpty)+import Data.Map (Map)+import Data.Semigroup ((<>))+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time (UTCTime)+import Data.Traversable (for)+import Hedgehog (Gen)+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range++kafkaEvent :: Gen KafkaEvent+kafkaEvent = KafkaEvent+ <$> eventSource+ <*> Gen.maybe eventSourceArn+ <*> bootstrapServers+ <*> records++eventSource :: Gen EventSource+eventSource = Gen.enumBounded++eventSourceArn :: Gen Text+eventSourceArn = do+ region <- Gen.element ["ap-south-1", "us-east-2", "eu-west-3"]+ account <- Gen.text (Range.singleton 12) Gen.digit+ clusterName <- Gen.text (Range.linear 1 20) printable+ uuidParts <- for [ 7, 4, 4, 4, 12, 2 ] $ \n ->+ Gen.text (Range.singleton n) Gen.hexit+ let uuid = fold $ intersperse "-" uuidParts+ resource = T.intercalate "/" ["cluster", clusterName, uuid]+ pure $ T.intercalate ":" ["arn:aws:kafka", region, account, resource]++bootstrapServers :: Gen (NonEmpty Text)+bootstrapServers = Gen.nonEmpty (Range.linear 1 5) server+ where+ server :: Gen Text+ server = (\h p -> h <> ":" <> p) <$> host <*> port++ host :: Gen Text+ host = fold . NE.intersperse "." <$> domainParts++ domainParts :: Gen (NonEmpty Text)+ domainParts = Gen.nonEmpty (Range.linear 1 5) fragment++ fragment :: Gen Text+ fragment = Gen.text (Range.linear 1 20) .+ Gen.element $ ['A'..'Z'] <> ['a'..'z'] <> ['0'..'9'] <> ['-', '_']++ port :: Gen Text+ port = T.pack . show <$> Gen.int (Range.linear 0 65535)++records :: Gen (Map Text [Record])+records = Gen.map (Range.exponential 1 3)+ . liftA2 (,) topicKey+ $ Gen.list (Range.linear 1 5) record+ where+ topicKey :: Gen Text+ topicKey = do+ t <- topic+ n <- T.pack . show <$> Gen.int (Range.linear 0 10)+ pure $ t <> "-" <> n++record :: Gen Record+record = Record+ <$> topic+ <*> Gen.int32 (Range.exponential 0 maxBound)+ <*> Gen.int64 (Range.linear 0 maxBound)+ <*> timestamp+ <*> headers+ <*> Gen.maybe (Gen.bytes (Range.exponential 0 512))+ <*> Gen.maybe (Gen.bytes (Range.exponential 0 512))++timestamp :: Gen Timestamp+timestamp = Gen.choice+ [ pure NoTimestampType+ , CreateTime <$> utcTime+ , LogAppendTime <$> utcTime+ ]+ where+ utcTime :: Gen UTCTime+ utcTime = int64ToUTCTime <$> Gen.int64+ (Range.exponentialFrom 1600000000000 1650000000000 1700000000000)++headers :: Gen [Header]+headers = Gen.list (Range.linear 0 10)+ (Header+ <$> Gen.text (Range.linear 0 512) Gen.unicode+ <*> Gen.bytes (Range.linear 0 512))++topic :: Gen Text+topic = Gen.text (Range.linear 1 20) printable++printable :: Gen Char+printable = Gen.filter isPrint Gen.ascii
+ test/AWS/Lambda/Events/Kafka/Spec.hs view
@@ -0,0 +1,199 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE QuasiQuotes #-}++module AWS.Lambda.Events.Kafka.Spec where++import AWS.Lambda.Events.Kafka+import qualified AWS.Lambda.Events.Kafka.Gen as Gen+import Data.Aeson (decode, eitherDecode, encode)+import Data.ByteString.Lazy (ByteString)+import Data.Int (Int64)+import qualified Data.List.NonEmpty as NE+import qualified Data.Map as M+import Data.Time (UTCTime(..), fromGregorian,+ picosecondsToDiffTime)+import Hedgehog (forAll, tripping)+import Test.Hspec (Spec, describe, shouldBe, specify)+import Test.Hspec.Hedgehog (hedgehog)+import Text.RawString.QQ (r)++spec :: Spec+spec = do+ specify "read MSK payload" $+ eitherDecode sampleMskPayload+ `shouldBe` Right expectedMskPayload++ specify "read Self-Managed Kafka payload" $+ eitherDecode sampleSelfManagedKafkaPayload+ `shouldBe` Right expectedSelfManagedKafkaPayload++ describe "properties" $ do+ specify "tripping" $+ hedgehog $ do+ request <- forAll Gen.kafkaEvent+ tripping request encode decode++sampleMskPayload :: ByteString+sampleMskPayload = [r|+{+ "bootstrapServers": "b-1.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9094,b-2.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9094",+ "eventSource": "aws:kafka",+ "eventSourceArn": "arn:aws:kafka:us-east-1:123456789012:cluster/msk-test-cluster-2/669b9b55-48db-44e6-9c75-b9b9ab79ae36-14",+ "records": {+ "welcome-to-the-space-jam-0": [+ {+ "key": "c2xhbQ==",+ "offset": 1,+ "partition": 0,+ "headers": [+ {+ "headerKey": [+ 104,+ 101,+ 97,+ 100,+ 101,+ 114,+ 86,+ 97,+ 108,+ 117,+ 101+ ]+ }+ ],+ "timestamp": 1621304398263,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ }+ ]+ }+}+|]++expectedMskPayload :: KafkaEvent+expectedMskPayload = KafkaEvent+ { eventSource = AwsKafka+ , eventSourceArn = Just "arn:aws:kafka:us-east-1:123456789012:cluster/msk-test-cluster-2/669b9b55-48db-44e6-9c75-b9b9ab79ae36-14"+ , bootstrapServers = NE.fromList+ [ "b-1.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9094"+ , "b-2.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9094"+ ]+ , records = M.fromList+ [ ( "welcome-to-the-space-jam-0"+ , [ withHeaders [Header "headerKey" "headerValue"]+ . slamJamRecord 1+ $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 8398263000000000)+ ]+ )+ ]+ }++sampleSelfManagedKafkaPayload :: ByteString+sampleSelfManagedKafkaPayload = [r|+{+ "bootstrapServers": "b-2.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9092,b-1.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9092",+ "eventSource": "SelfManagedKafka",+ "records": {+ "welcome-to-the-space-jam-0": [+ {+ "key": "c2xhbQ==",+ "offset": 0,+ "partition": 0,+ "headers": [],+ "timestamp": 1621302644353,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ },+ {+ "key": "c2xhbQ==",+ "offset": 1,+ "partition": 0,+ "headers": [],+ "timestamp": 1621304398263,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ },+ {+ "key": "c2xhbQ==",+ "offset": 2,+ "partition": 0,+ "headers": [],+ "timestamp": 1621309340017,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ },+ {+ "key": "c2xhbQ==",+ "offset": 3,+ "partition": 0,+ "headers": [],+ "timestamp": 1621309352813,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ },+ {+ "key": "c2xhbQ==",+ "offset": 4,+ "partition": 0,+ "headers": [],+ "timestamp": 1621309372115,+ "timestampType": "CREATE_TIME",+ "topic": "welcome-to-the-space-jam",+ "value": "amFt"+ }+ ]+ }+}+|]++expectedSelfManagedKafkaPayload :: KafkaEvent+expectedSelfManagedKafkaPayload = KafkaEvent+ { eventSource = SelfManagedKafka+ , eventSourceArn = Nothing+ , bootstrapServers = NE.fromList+ [ "b-2.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9092"+ , "b-1.msk-test-cluster-2.4jp6ci.c14.kafka.us-east-1.amazonaws.com:9092"+ ]+ , records = M.fromList+ [ ( "welcome-to-the-space-jam-0"+ , [ slamJamRecord 0 $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 6644353000000000)+ , slamJamRecord 1 $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 8398263000000000)+ , slamJamRecord 2 $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 13340017000000000)+ , slamJamRecord 3 $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 13352813000000000)+ , slamJamRecord 4 $ UTCTime+ (fromGregorian 2021 05 18)+ (picosecondsToDiffTime 13372115000000000)+ ]+ )+ ]+ }++slamJamRecord :: Int64 -> UTCTime -> Record+slamJamRecord off stamp = Record+ { topic = "welcome-to-the-space-jam"+ , partition = 0+ , offset = off+ , headers = []+ , timestamp = CreateTime stamp+ , key = Just "slam"+ , value = Just "jam"+ }++withHeaders :: [Header] -> Record -> Record+withHeaders headers record = record { headers }
test/Spec.hs view
@@ -3,11 +3,13 @@ CognitoIdentity (..), LambdaContext (..)) import qualified Gen.Header as Header-import qualified AWS.Lambda.Events.ApiGateway.ProxyRequest.Gen as ProxyRequest-import qualified AWS.Lambda.Events.ApiGateway.ProxyResponse.Gen as ProxyResponse+import qualified AWS.Lambda.Events.ApiGateway.ProxyRequest.Spec as ProxyRequest+import qualified AWS.Lambda.Events.ApiGateway.ProxyResponse.Spec as ProxyResponse++import qualified AWS.Lambda.Events.Kafka.Spec as Kafka import AWS.Lambda.Internal (StaticContext (..)) import AWS.Lambda.RuntimeClient.Internal (eventResponseToNextData)-import Data.Aeson (Value (Null), decode, encode)+import Data.Aeson (Value (Null)) import qualified Data.CaseInsensitive as CI import Data.Map (singleton) import Data.Semigroup ((<>))@@ -25,6 +27,12 @@ main :: IO () main = hspec $ do+ describe "Events" $ do+ describe "ApiGateway" $ do+ describe "ProxyRequest" ProxyRequest.spec+ describe "ProxyResponse" ProxyResponse.spec+ describe "Kafka" Kafka.spec+ describe "Event Response Data" $ do let staticContext = StaticContext@@ -214,17 +222,6 @@ s <- forAll $ CI.mk <$> Gen.text (Range.linear 1 100) Gen.latin1 t <- forAll $ Header.unfoldCase s s === t-- specify "ProxyRequest tripping" $- hedgehog $ do- request <- forAll ProxyRequest.proxyRequest- tripping request encode decode-- specify "ProxyResponse tripping" $- hedgehog $ do- request <- forAll ProxyResponse.proxyResponse- tripping request encode decode- minResponse :: [Header] -> a -> Response a minResponse headers body =