packages feed

line 1.0.1.0 → 2.0.0.0

raw patch · 17 files changed

+1553/−76 lines, 17 filesdep +QuickCheckdep +hspecdep +hspec-waidep ~line

Dependencies added: QuickCheck, hspec, hspec-wai, quickcheck-instances, raw-strings-qq, scotty

Dependency ranges changed: line

Files

+ CHANGELOG.md view
@@ -0,0 +1,23 @@+## 2.0.0.0 (4 Dev 2016)++* Make `Line.Messaging.Webhook.Validation` independent from WAI. As it does not+  use `Request` of WAI, its argument type is changed.+* Remove `WebhookResult`, as returning other than empty string is meaningless+  for webhook response+* Add Scotty version of webhook handler+* Add Stack yaml to fix macOS Sierra problem+* Derive `Eq` type class for `APIErrorBody`+* Make optional fields of template messages have type of `Maybe a`++## 1.0.1.0 (28 Nov 2016)++* Update lower bound of `base` to 4.8 (Issue: #2)+* Add Stack yamls for lts-6.26 resolver++## 1.0.0.1 (27 Nov 2016)++* Documentation fix++## 1.0.0.0 (27 Nov 2016)++* Initial release
+ README.md view
@@ -0,0 +1,22 @@+# line [!['travis-ci/noraesae/line](https://travis-ci.org/noraesae/line.svg?branch=master)](https://travis-ci.org/noraesae/line)++Haskell SDK for the [LINE](https://line.me) API++## Features++* Internal [auth signature validator](https://devdocs.line.me/en/#webhook-authentication)+* Webhook handled with handler function, [WAI](https://hackage.haskell.org/package/wai) application,+  or [Scotty](https://hackage.haskell.org/package/scotty) action+* Functions and types for [LINE Messaging API](https://devdocs.line.me/en/#messaging-api)++## Documentation++Please refer to [the API docs available on Hackage](https://hackage.haskell.org/package/line).++## Examples++Please see the [examples](examples) directory.++## LICENSE++[BSD3](LICENSE)
+ examples/API.hs view
@@ -0,0 +1,78 @@+{-|+An example code to show how to use functions and types for LINE Messaging API.+-}++{-# LANGUAGE OverloadedStrings #-}++module API where++import Line.Messaging.API+import Line.Messaging.Webhook+import qualified Data.ByteString.Lazy as BL+import qualified Data.Text as T++-- | Message event handler.+--+-- For using the handler and create a webhook server, please refer to the+-- webhook example.+handleMessageEvent :: ReplyableEvent EventMessage -> IO ()+handleMessageEvent event = do+  case getMessage event of+    -- For text event message, just echo+    TextEM _ (Text text) -> echo (getReplyToken event) text+    -- For image, video and audio, download contents+    ImageEM identifier -> download identifier ".jpg"+    VideoEM identifier -> download identifier ".mp4"+    AudioEM identifier -> download identifier ".m4a"+    -- For location, push a template message+    LocationEM identifier location -> pushTemplate identifier location+    _ -> return ()++-- | An unlifter for the 'APIIO' type.+--+-- It uses the 'runAPI' function with a channel access token provided.+api :: APIIO a -> IO (Either APIError a)+api = runAPI $ return "some channel access token"++-- | An echoing action+--+-- It just send a reply with a text. By 'api', the result is+-- 'Either APIError ()', but the error is not handled for simplicity.+echo :: ReplyToken -> T.Text -> IO ()+echo replyToken content = do+  api $ reply replyToken [ Message . Text $ content ]+  return ()++-- | Download content+--+-- It downloads a binary content of image, video or audio spcified by+-- its identifier.+download :: ID -> String -> IO ()+download identifier ext = do+  let filename = "recent" ++ ext+  content <- either (const "") id <$> (api $ getContent identifier)+  BL.writeFile filename content++-- | Push a template message.+--+-- In this case, it is better to use 'reply' instead of 'push', as it is+-- cheap. 'push' is used here for example usage.+pushTemplate :: ID -> Location -> IO ()+pushTemplate identifier (Location _ address _ _) = do+  (name, description, homepage, thumbnail) <- getRestaurant address+  api $ push identifier [+    Message . Text $ "Here are some restaurants around the location.",+    Message . Template "Alt text for old clients" $+      Buttons (Just thumbnail) (Just name) description [+        TplPostbackAction "Reservation" "Reserved!" (Just name),+        TplURIAction "Homepage" homepage+        ]+    ]+  return ()++  where+  -- | A fucntion to return a list of restaunts for an address+  --+  -- This function is just to be used as an example, so it is not implemented.+  getRestaurant :: T.Text -> IO (T.Text, T.Text, URL, URL)+  getRestaurant address = undefined
+ examples/ScottyWebhook.hs view
@@ -0,0 +1,76 @@+{-|+An example code to show how to create a simple Scotty application to handle LINE+message webhooks.+-}++{-# LANGUAGE OverloadedStrings #-}++module ScottyWebhook where++import Control.Monad (forM_)+import Control.Monad.IO.Class (liftIO)+import Line.Messaging.Webhook+import Line.Messaging.Types (Text(..), Location(..))+import Web.Scotty+import qualified Data.Text as T++-- | An IO action to get channel secret. The channel secret is issued for each+-- LINE bot, and can be found in the LINE developer page.+getChannelSecret :: IO ChannelSecret+getChannelSecret = return "some channel secret"++-- | A Scotty application to handle webhook requests.+--+-- 'webhookAction' is used with a channel secret, event handler and error+-- handler.+app :: ScottyM ()+app =+  get "/webhook" $ do+    channelSecret <- liftIO getChannelSecret+    webhookAction channelSecret handler defaultOnFailure'++-- | An event handler.+--+-- A webhook request can contain several events at once, so the first argument+-- is a list of 'Event's. The function just call the event handler for each+-- event.+handler :: [Event] -> IO ()+handler events = forM_ events handleEvent++-- | A function to handle each event.+--+-- The type of events can be decided with pattern matching. About possible+-- types, please refer to the doc of Line.Messaging.Webhook.Types module.+--+-- Basically, content of each event has a type of 'EventTuple', which may or+-- may not contain a reply token and internal data.+handleEvent :: Event -> IO ()+handleEvent (JoinEvent event) = printSource event+handleEvent (LeaveEvent event) = printSource event+handleEvent (MessageEvent event) = handleMessageEvent event+handleEvent _ = return ()++-- | An example function to print event source of event.+--+-- Event source exists for all the event tuple types, so the reply token and+-- data types can be polymorphic.+printSource :: EventTuple r a -> IO ()+printSource event = do+  putStrLn . concatMap T.unpack $ case getSource event of+    User identifier -> [ "A user", identifier ]+    Group identifier -> [ "A group", identifier ]+    Room identifier -> [ "A room", identifier ]++-- | An example function to print text and location details of a message+--+-- A message event is always replyable and has message data in it.+handleMessageEvent :: ReplyableEvent EventMessage -> IO ()+handleMessageEvent event = do+  putStrLn . concatMap T.unpack $ case getMessage event of+    TextEM identifier (Text text) -> -- TextEM stands for a text event message+      -- print message ID and internal text+      [ "A text message", identifier, text ]+    LocationEM identifier (Location title address _ _) ->+      -- print message ID and location information+      [ "A location message", identifier, title, address ]+    _ -> []
+ examples/WAIWebhook.hs view
@@ -0,0 +1,73 @@+{-|+An example code to show how to create a simple WAI application to handle LINE+message webhooks.+-}++{-# LANGUAGE OverloadedStrings #-}++module WAIWebhook where++import Control.Monad (forM_)+import Line.Messaging.Webhook+import Line.Messaging.Types (Text(..), Location(..))+import Network.Wai+import qualified Data.Text as T++-- | An IO action to get channel secret. The channel secret is issued for each+-- LINE bot, and can be found in the LINE developer page.+getChannelSecret :: IO ChannelSecret+getChannelSecret = return "some channel secret"++-- | A WAI application to handle webhook requests.+--+-- 'webhookApp' is used with a channel secret, event handler and error handler.+app :: Application+app req f = do+  channelSecret <- getChannelSecret+  webhookApp channelSecret handler defaultOnFailure req f++-- | An event handler.+--+-- A webhook request can contain several events at once, so the first argument+-- is a list of 'Event's. The function just call the event handler for each+-- event.+handler :: [Event] -> IO ()+handler events = forM_ events handleEvent++-- | A function to handle each event.+--+-- The type of events can be decided with pattern matching. About possible+-- types, please refer to the doc of Line.Messaging.Webhook.Types module.+--+-- Basically, content of each event has a type of 'EventTuple', which may or+-- may not contain a reply token and internal data.+handleEvent :: Event -> IO ()+handleEvent (JoinEvent event) = printSource event+handleEvent (LeaveEvent event) = printSource event+handleEvent (MessageEvent event) = handleMessageEvent event+handleEvent _ = return ()++-- | An example function to print event source of event.+--+-- Event source exists for all the event tuple types, so the reply token and+-- data types can be polymorphic.+printSource :: EventTuple r a -> IO ()+printSource event = do+  putStrLn . concatMap T.unpack $ case getSource event of+    User identifier -> [ "A user", identifier ]+    Group identifier -> [ "A group", identifier ]+    Room identifier -> [ "A room", identifier ]++-- | An example function to print text and location details of a message+--+-- A message event is always replyable and has message data in it.+handleMessageEvent :: ReplyableEvent EventMessage -> IO ()+handleMessageEvent event = do+  putStrLn . concatMap T.unpack $ case getMessage event of+    TextEM identifier (Text text) -> -- TextEM stands for a text event message+      -- print message ID and internal text+      [ "A text message", identifier, text ]+    LocationEM identifier (Location title address _ _) ->+      -- print message ID and location information+      [ "A location message", identifier, title, address ]+    _ -> []
line.cabal view
@@ -1,5 +1,5 @@ name: line-version: 1.0.1.0+version: 2.0.0.0 cabal-version: >=1.10 build-type: Simple license: BSD3@@ -15,7 +15,7 @@     .     * Internal auth signature validator     .-    * Webhook handled with handler function or WAI application+    * Webhook handled with handler function, WAI application, or Scotty action     .     * Functions and types for <https://devdocs.line.me/en/#messaging-api LINE Messaging API>     .@@ -23,6 +23,10 @@     <https://github.com/noraesae/line/tree/master/examples examples> directory. category: Network author: Jun+extra-source-files:+    README.md+    CHANGELOG.md+    examples/*.hs  source-repository head     type: git@@ -49,7 +53,8 @@         base64-bytestring >=1.0.0.1 && <1.1,         time >=1.6.0.1 && <1.7,         wreq >=0.4.1.0 && <0.5,-        lens ==4.14.*+        lens ==4.14.*,+        scotty >=0.11.0 && <0.12     default-language: Haskell2010     default-extensions: OverloadedStrings     hs-source-dirs: src@@ -60,8 +65,28 @@     main-is: Spec.hs     build-depends:         base >=4.9.0.0 && <4.10,-        line >=1.0.1.0 && <1.1+        line >=2.0.0.0 && <2.1,+        hspec >=2.2.4 && <2.3,+        hspec-wai >=0.6.6 && <0.7,+        QuickCheck >=2.8.2 && <2.9,+        quickcheck-instances >=0.3.12 && <0.4,+        text >=1.2.2.1 && <1.3,+        bytestring >=0.10.8.1 && <0.11,+        cryptohash-sha256 >=0.11.100.1 && <0.12,+        base64-bytestring >=1.0.0.1 && <1.1,+        transformers >=0.5.2.0 && <0.6,+        scotty >=0.11.0 && <0.12,+        aeson >=0.11.2.1 && <0.12,+        raw-strings-qq ==1.1.*,+        time >=1.6.0.1 && <1.7     default-language: Haskell2010     default-extensions: OverloadedStrings     hs-source-dirs: test+    other-modules:+        Line.Messaging.API.TypesSpec+        Line.Messaging.API.TypesSpecHelper+        Line.Messaging.WebhookSpec+        Line.Messaging.Webhook.TypesSpec+        Line.Messaging.Webhook.TypesSpecHelper+        Line.Messaging.Webhook.ValidationSpec     ghc-options: -threaded -rtsopts -with-rtsopts=-N
src/Line/Messaging/API/Types.hs view
@@ -48,6 +48,7 @@ import Control.Exception (SomeException) import Data.Aeson (FromJSON(..), ToJSON(..), Value(..), object, (.:), (.=)) import Data.Aeson.Types (Pair)+import Data.Maybe (maybeToList) import Line.Messaging.Common.Types import qualified Data.Text as T import qualified Data.ByteString.Lazy as BL@@ -311,9 +312,9 @@ -- For more details of each field, please refer to the -- <https://devdocs.line.me/en/#buttons Buttons> section in the LINE -- documentation.-data Buttons = Buttons { getButtonsThumbnailURL :: URL+data Buttons = Buttons { getButtonsThumbnailURL :: Maybe URL                        -- ^ URL for thumbnail image-                       , getButtonsTitle :: T.Text+                       , getButtonsTitle :: Maybe T.Text                        -- ^ Title text                        , getButtonsText :: T.Text                        -- ^ Description text@@ -324,12 +325,15 @@                deriving (Eq, Show)  instance ToJSON Buttons where-  toJSON (Buttons url title text actions) = object [ "type" .= ("buttons" :: T.Text)-                                                   , "thumbnailImageUrl" .= url-                                                   , "title" .= title-                                                   , "text" .= text-                                                   , "actions" .= toJSON actions-                                                   ]+  toJSON (Buttons maybeURL maybeTitle text actions) =+    object . concat $+      [ [ "type" .= ("buttons" :: T.Text)+        , "text" .= text+        , "actions" .= toJSON actions+        ]+      , maybeToList $ ("thumbnailImageUrl" .=) <$> maybeURL+      , maybeToList $ ("title" .=) <$> maybeTitle+      ]  -- | The confirm content type for template message. --@@ -373,9 +377,9 @@ -- -- It has the same fields as 'Buttons', except that the number of actions is -- up to 3.-data Column = Column { getColumnThumbnailURL :: URL+data Column = Column { getColumnThumbnailURL :: Maybe URL                      -- ^ URL for thumbnail image-                     , getColumnTitle :: T.Text+                     , getColumnTitle :: Maybe T.Text                      -- ^ Title text                      , getColumnText :: T.Text                      -- ^ Description text@@ -386,11 +390,14 @@               deriving (Eq, Show)  instance ToJSON Column where-  toJSON (Column url title text actions) = object [ "thumbnailImageUrl" .= url-                                                  , "title" .= title-                                                  , "text" .= text-                                                  , "actions" .= toJSON actions-                                                  ]+  toJSON (Column maybeURL maybeTitle text actions) =+    object . concat $+      [ [ "text" .= text+        , "actions" .= toJSON actions+        ]+      , maybeToList $ ("thumbnailImageUrl" .=) <$> maybeURL+      , maybeToList $ ("title" .=) <$> maybeTitle+      ]  -- | Just a type alias for 'T.Text', used with 'TemplateAction'. type Label = T.Text@@ -403,7 +410,7 @@                       -- ^ Message action. When clicked, a specified text will                       -- be sent into the same room by a user who clicked the                       -- button.-                    | TplPostbackAction Label Postback T.Text+                    | TplPostbackAction Label Postback (Maybe T.Text)                       -- ^ Postback action. When clicked, a specified text will                       -- be sent, and postback data will be sent to webhook                       -- server as a postback event.@@ -413,11 +420,14 @@                     deriving (Eq, Show)  instance ToJSON TemplateAction where-  toJSON (TplPostbackAction label data' text) = object [ "type" .= ("postback" :: T.Text)-                                                       , "label" .= label-                                                       , "data" .= data'-                                                       , "text" .= text-                                                       ]+  toJSON (TplPostbackAction label data' maybeText) =+    object . concat $+      [ [ "type" .= ("postback" :: T.Text)+        , "label" .= label+        , "data" .= data'+        ]+      , maybeToList $ ("text" .=) <$> maybeText+      ]   toJSON (TplMessageAction label text) = object [ "type" .= ("message" :: T.Text)                                                 , "label" .= label                                                 , "text" .= text@@ -486,7 +496,7 @@                                  , getErrorProperty :: Maybe T.Text                                  , getErrorDetails :: Maybe [APIErrorBody]                                  }-                  deriving Show+                  deriving (Eq, Show)  instance FromJSON APIErrorBody where   parseJSON (Object v) = APIErrorBody <$> v .: "message"
src/Line/Messaging/Webhook.hs view
@@ -11,38 +11,43 @@   -- * Webhook as a WAI application   webhookApp,   defaultOnFailure,+  -- * Webhook as a Scotty action+  webhookAction,+  defaultOnFailure',   ) where  import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Except (ExceptT, throwE, runExceptT) import Data.Aeson (decode') import Data.ByteString.Builder (string8)+import Data.Text.Encoding (encodeUtf8) import Line.Messaging.Webhook.Types import Line.Messaging.Webhook.Validation (validateSignature) import Network.HTTP.Types.Status import Network.Wai+import qualified Data.Text.Lazy as TL+import qualified Data.ByteString.Lazy as BL+import qualified Web.Scotty as Scotty  -- | A basic webhook function. It validates a request with a channel secret,--- and parses the request into a list of webhook events.+-- signature and body, and parses the body into a list of webhook events. -- -- To handle failures, the result is in the form of @'ExceptT' 'WebhookFailure'@.-webhook :: ChannelSecret-        -> Request-        -> ExceptT WebhookFailure IO [Event]-webhook secret req = do-  body <- liftIO $ lazyRequestBody req-  if not $ validateSignature secret req body-  then throwE SignatureVerificationFailed-  else do-    case decode' body of-      Nothing -> throwE MessageDecodeFailed-      Just (Body events) -> return events+webhook :: (Monad m)+        => ChannelSecret+        -> BL.ByteString -- ^ Request body+        -> Signature -- ^ Auth signature in @X-Line-Signature@ header+        -> ExceptT WebhookFailure m [Event]+webhook secret body sig = do+  if not $ validateSignature secret body sig+    then throwE SignatureVerificationFailed+    else do+      case decode' body of+        Nothing -> throwE MessageDecodeFailed+        Just (Body events) -> return events -waiResponse :: WebhookResult -> Application-waiResponse result req f = case result of-  Ok              -> f $ responseBuilder status200 [] ""-  WaiResponse res -> f res-  WaiApp app      -> app req f+getSigFromWaiReq :: Request -> Maybe Signature+getSigFromWaiReq = lookup "X-Line-Signature" . requestHeaders  -- | A webhook handler for WAI. It uses 'webhook' internally and returns a WAI -- 'Application'.@@ -57,25 +62,72 @@ --     webhookApp secret handler defaultOnFailure req f --   _ -> undefined ----- handler :: [Event] -> IO WebhookResult--- handler events = forM_ events handleEvent $> Ok+-- handler :: [Event] -> IO ()+-- handler events = forM_ events handleEvent -- -- handleEvent :: Event -> IO () -- handleEvent (MessageEvent event) = undefined -- handle a message event -- handleEvent _ = return () -- @ webhookApp :: ChannelSecret -- ^ Channel secret-           -> ([Event] -> IO WebhookResult) -- ^ Event handler-           -> (WebhookFailure -> Application) -- ^ Error handler. Just to return 400 for failures, use 'defaultOnFailure'.+           -> ([Event] -> IO ()) -- ^ Event handler+           -> (WebhookFailure -> Application)+              -- ^ Error handler. Just to return 400 for failures, use 'defaultOnFailure'.            -> Application webhookApp secret handler failHandler req f = do-  result <- runExceptT $ webhook secret req-  case result of-    Right events -> handler events >>= waiResponse <*> pure req <*> pure f-    Left exception -> failHandler exception req f+  body <- lazyRequestBody req+  let maybeSig = getSigFromWaiReq req+  case maybeSig of+    Nothing -> failHandler SignatureVerificationFailed req f+    Just sig -> do+      result <- runExceptT $ webhook secret body sig+      case result of+        Right events -> handler events >> (f $ responseBuilder status200 [] "")+        Left exception -> failHandler exception req f  -- | A basic error handler to be used with 'webhookApp'. It returns 400 Bad -- Request with the 'WebhookFailure' code for its body. defaultOnFailure :: WebhookFailure -> Application defaultOnFailure failure _ f = f .   responseBuilder status400 [] . string8 . show $ failure++-- | A webhook handler for Scotty. It uses 'webhook' internally and returns a+-- Scotty action of type 'ActionM' @()@+--+-- An example webhook server using WAI will be like below:+--+-- @+-- main :: IO ()+-- main = scotty 3000 $ do+--   get "/webhook" $ webhookAction handler defaultOnFailure'+--+-- handler :: [Event] -> IO ()+-- handler events = forM_ events handleEvent+--+-- handleEvent :: Event -> IO ()+-- handleEvent (MessageEvent event) = undefined -- handle a message event+-- handleEvent _ = return ()+-- @+webhookAction :: ChannelSecret -- ^ Channel secret+              -> ([Event] -> IO ()) -- ^ Event handler+              -> (WebhookFailure -> Scotty.ActionM ())+                 -- ^ Error handler. Just to return 400 for failures, use 'defaultOnFailure''.+              -> Scotty.ActionM ()+webhookAction secret handler failHandler = do+  body <- Scotty.body+  maybeSig <- Scotty.header "X-Line-Signature"+  case maybeSig of+    Nothing -> failHandler SignatureVerificationFailed+    Just sig -> do+      result <- runExceptT $ webhook secret body (encodeUtf8 $ TL.toStrict sig)+      case result of+        Right events -> (liftIO $ handler events) >> Scotty.text ""+        Left exception -> failHandler exception++-- | A basic error handler to be used with 'webhookAction'. It returns 400 Bad+-- Request with the 'WebhookFailure' code for its body. It has the same purpose+-- as 'defaultOnFailure', except that it is for Scotty.+defaultOnFailure' :: WebhookFailure -> Scotty.ActionM ()+defaultOnFailure' err = do+  Scotty.status status400+  Scotty.text . TL.pack . show $ err
src/Line/Messaging/Webhook/Types.hs view
@@ -6,8 +6,9 @@   -- * Common types   -- | Re-exported for convenience.   module Line.Messaging.Common.Types,+  -- * Validation+  Signature,   -- * Result and failure-  WebhookResult (..),   WebhookFailure (..),    -- * Webhook request body@@ -46,15 +47,13 @@ import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Line.Messaging.API.Types import Line.Messaging.Common.Types-import Network.Wai (Response, Application) import qualified Data.Text as T+import qualified Data.ByteString as B --- | A result type a webhook event handler should return.+-- | A type alias for auth signature. ----- It is eventually transformed to a WAI response or application.-data WebhookResult = Ok -- ^ Respond with an empty 200 OK response.-                   | WaiResponse Response -- ^ Respond with a WAI response.-                   | WaiApp Application -- ^ Respond with a WAI application.+-- It is set as @X-Line-Signature@ header in webhook requests+type Signature = B.ByteString  -- | A failure type returned when a webhook request is malformed. data WebhookFailure = SignatureVerificationFailed -- ^ When the signature is not valid.
src/Line/Messaging/Webhook/Validation.hs view
@@ -13,25 +13,15 @@ import Crypto.Hash.SHA256 (hmaclazy) import Data.Text.Encoding (encodeUtf8) import Line.Messaging.Types-import Network.Wai-import qualified Data.ByteString as B import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Lazy as BL -getSignature :: Request -> Maybe B.ByteString-getSignature req = lookup "X-Line-Signature" headers-  where-    headers = requestHeaders req---- | Provided a channel secret, WAI request and request body, it determines--- the request is properly signatured, which probably means it is sent--- from a valid LINE server.+-- | Provided a channel secret, request body and auth signature, it determines+-- the request is properly signatured, which probably means it is sent from a+-- valid LINE server. -- -- For more details of webhook authentication, please refer to -- <https://devdocs.line.me/en/#webhook-authentication the LINE documentation>.-validateSignature :: ChannelSecret -> Request -> BL.ByteString -> Bool-validateSignature secret req body = case getSignature req of-  Nothing -> False-  Just signature -> hash == signature-  where-    hash = Base64.encode $ hmaclazy (encodeUtf8 secret) body+validateSignature :: ChannelSecret -> BL.ByteString -> Signature -> Bool+validateSignature secret body signature = hash == signature+  where hash = Base64.encode $ hmaclazy (encodeUtf8 secret) body
+ test/Line/Messaging/API/TypesSpec.hs view
@@ -0,0 +1,136 @@+module Line.Messaging.API.TypesSpec where++import Test.Hspec++import Line.Messaging.API.TypesSpecHelper++import Data.Aeson+import Data.Aeson.Types+import Data.Maybe (isJust, fromJust)+import Line.Messaging.API.Types+import qualified Data.ByteString.Lazy as BL+import qualified Data.Text as T++fromJSONSpec :: (FromJSON a, Eq a, Show a)+             => [(BL.ByteString, Maybe a)]+             -> SpecWith ()+fromJSONSpec ((raw, result):xs) = do+  let title = if isJust result then "success" else "fail"+  it title $ decode raw `shouldBe` result+  fromJSONSpec xs+fromJSONSpec [] = return ()++messageableSpec :: (Messageable a, Eq a, Show a)+                => String+                -> a+                -> BL.ByteString+                -> SpecWith ()+messageableSpec testTitle a result =+  it testTitle $ toJSON (Message a) `shouldBe` fromJust (decode result)++spec :: Spec+spec = do+  describe "messageables" $ do+    messageableSpec "text"+      (Text "Hello, world")+      textMessageableResult++    messageableSpec "image"+      (Image "https://example.com/original.jpg" "https://example.com/preview.jpg")+      imageMessageableResult++    messageableSpec "video"+      (Video "https://example.com/original.mp4" "https://example.com/preview.jpg")+      videoMessageableResult++    messageableSpec "audio"+      (Audio "https://example.com/original.m4a" 240000)+      audioMessageableResult++    messageableSpec "location"+      (Location "my location" "some address" 35.65910807942215 139.70372892916203)+      locationMessageableResult++    messageableSpec "sticker"+      (Sticker "1" "1")+      stickerMessageableResult++    messageableSpec "imagemap"+      (ImageMap "https://example.com/bot/images/rm001" "this is an imagemap" (1040, 1040)+       [ IMURIAction "https://example.com/" (0, 0, 520, 1040)+       , IMMessageAction "hello" (520, 0, 520, 1040)+       ])+      imageMapMessageableResult++    messageableSpec "template buttons"+      (Template "this is a buttons template" $+       Buttons (Just "https://example.com/bot/images/image.jpg") (Just "Menu") "Please select"+       [ TplPostbackAction "Buy" "action=buy&itemid=123" Nothing+       , TplPostbackAction "Add to cart" "action=add&itemid=123" Nothing+       , TplURIAction "View detail" "http://example.com/page/123"+       ])+      buttonsTemplateMessageableResult++    messageableSpec "template confirm"+      (Template "this is a confirm template" $+       Confirm "Are you sure?"+       [ TplMessageAction "Yes" "yes"+       , TplMessageAction "No" "no"+       ])+      confirmTemplateMessageableResult++    messageableSpec "template carousel"+      (Template "this is a carousel template" $+       Carousel [ Column (Just "https://example.com/bot/images/item1.jpg")+                         (Just "this is menu")+                         "description"+                         [ TplPostbackAction "Buy" "action=buy&itemid=111" Nothing+                         , TplPostbackAction "Add to cart" "action=add&itemid=111" Nothing+                         , TplURIAction "View detail" "http://example.com/page/111"+                         ]+                , Column (Just "https://example.com/bot/images/item2.jpg")+                         (Just "this is menu")+                         "description"+                         [ TplPostbackAction "Buy" "action=buy&itemid=222" Nothing+                         , TplPostbackAction "Add to cart" "action=add&itemid=222" Nothing+                         , TplURIAction "View detail" "http://example.com/page/222"+                         ]+                ])+      carouselTemplateMessageableResult++  describe "JSON decode" $ do+    describe "profile" $ fromJSONSpec+      [ ( fullProfile, Just $ Profile+                                "123"+                                "Jun"+                                (Just "https://example.com/profile.jpg")+                                (Just "some status message") )+      , ( noPicProfile, Just $ Profile+                                 "123"+                                 "Jun"+                                 Nothing+                                 (Just "some status message") )+      , ( noDescProfile, Just $ Profile+                                  "123"+                                  "Jun"+                                  (Just "https://example.com/profile.jpg")+                                  Nothing )+      , ( simpleProfile, Just $ Profile+                                  "123"+                                  "Jun"+                                  Nothing+                                  Nothing )+      , ( badProfile, Nothing )+      ]++    describe "API error body" $ fromJSONSpec+      [ ( simpleError, Just $ APIErrorBody "Invalid reply token" Nothing Nothing )+      , ( complexError, Just $ APIErrorBody+                                 "The request body has 2 error(s)"+                                 Nothing+                                 (Just [ APIErrorBody "May not be empty" (Just "messages[0].text") Nothing+                                       , APIErrorBody "Must be one of the following values: [text, image, video, audio, location, sticker, template, imagemap]" (Just "messages[1].type") Nothing+                                       ])+        )+      , ( badError, Nothing )+      ]
+ test/Line/Messaging/API/TypesSpecHelper.hs view
@@ -0,0 +1,284 @@+{-# LANGUAGE QuasiQuotes #-}++module Line.Messaging.API.TypesSpecHelper where++import Text.RawString.QQ+import qualified Data.ByteString.Lazy as BL++textMessageableResult :: BL.ByteString+textMessageableResult = [r|+{+    "type": "text",+    "text": "Hello, world"+}+|]++imageMessageableResult :: BL.ByteString+imageMessageableResult = [r|+{+    "type": "image",+    "originalContentUrl": "https://example.com/original.jpg",+    "previewImageUrl": "https://example.com/preview.jpg"+}+|]++videoMessageableResult :: BL.ByteString+videoMessageableResult = [r|+{+    "type": "video",+    "originalContentUrl": "https://example.com/original.mp4",+    "previewImageUrl": "https://example.com/preview.jpg"+}+|]++audioMessageableResult :: BL.ByteString+audioMessageableResult = [r|+{+    "type": "audio",+    "originalContentUrl": "https://example.com/original.m4a",+    "duration": 240000+}+|]++locationMessageableResult :: BL.ByteString+locationMessageableResult = [r|+{+    "type": "location",+    "title": "my location",+    "address": "some address",+    "latitude": 35.65910807942215,+    "longitude": 139.70372892916203+}+|]++stickerMessageableResult :: BL.ByteString+stickerMessageableResult = [r|+{+  "type": "sticker",+  "packageId": "1",+  "stickerId": "1"+}+|]++imageMapMessageableResult :: BL.ByteString+imageMapMessageableResult = [r|+{+  "type": "imagemap",+  "baseUrl": "https://example.com/bot/images/rm001",+  "altText": "this is an imagemap",+  "baseSize": {+      "height": 1040,+      "width": 1040+  },+  "actions": [+      {+          "type": "uri",+          "linkUri": "https://example.com/",+          "area": {+              "x": 0,+              "y": 0,+              "width": 520,+              "height": 1040+          }+      },+      {+          "type": "message",+          "text": "hello",+          "area": {+              "x": 520,+              "y": 0,+              "width": 520,+              "height": 1040+          }+      }+  ]+}+|]++buttonsTemplateMessageableResult :: BL.ByteString+buttonsTemplateMessageableResult = [r|+{+  "type": "template",+  "altText": "this is a buttons template",+  "template": {+      "type": "buttons",+      "thumbnailImageUrl": "https://example.com/bot/images/image.jpg",+      "title": "Menu",+      "text": "Please select",+      "actions": [+          {+            "type": "postback",+            "label": "Buy",+            "data": "action=buy&itemid=123"+          },+          {+            "type": "postback",+            "label": "Add to cart",+            "data": "action=add&itemid=123"+          },+          {+            "type": "uri",+            "label": "View detail",+            "uri": "http://example.com/page/123"+          }+      ]+  }+}+|]++confirmTemplateMessageableResult :: BL.ByteString+confirmTemplateMessageableResult = [r|+{+  "type": "template",+  "altText": "this is a confirm template",+  "template": {+      "type": "confirm",+      "text": "Are you sure?",+      "actions": [+          {+            "type": "message",+            "label": "Yes",+            "text": "yes"+          },+          {+            "type": "message",+            "label": "No",+            "text": "no"+          }+      ]+  }+}+|]++carouselTemplateMessageableResult :: BL.ByteString+carouselTemplateMessageableResult = [r|+{+  "type": "template",+  "altText": "this is a carousel template",+  "template": {+      "type": "carousel",+      "columns": [+          {+            "thumbnailImageUrl": "https://example.com/bot/images/item1.jpg",+            "title": "this is menu",+            "text": "description",+            "actions": [+                {+                    "type": "postback",+                    "label": "Buy",+                    "data": "action=buy&itemid=111"+                },+                {+                    "type": "postback",+                    "label": "Add to cart",+                    "data": "action=add&itemid=111"+                },+                {+                    "type": "uri",+                    "label": "View detail",+                    "uri": "http://example.com/page/111"+                }+            ]+          },+          {+            "thumbnailImageUrl": "https://example.com/bot/images/item2.jpg",+            "title": "this is menu",+            "text": "description",+            "actions": [+                {+                    "type": "postback",+                    "label": "Buy",+                    "data": "action=buy&itemid=222"+                },+                {+                    "type": "postback",+                    "label": "Add to cart",+                    "data": "action=add&itemid=222"+                },+                {+                    "type": "uri",+                    "label": "View detail",+                    "uri": "http://example.com/page/222"+                }+            ]+          }+      ]+  }+}+|]++fullProfile :: BL.ByteString+fullProfile = [r|+{+  "userId": "123",+  "displayName": "Jun",+  "pictureUrl": "https://example.com/profile.jpg",+  "statusMessage": "some status message"+}+|]++noPicProfile :: BL.ByteString+noPicProfile = [r|+{+  "userId": "123",+  "displayName": "Jun",+  "pictureUrl": null,+  "statusMessage": "some status message"+}+|]++noDescProfile :: BL.ByteString+noDescProfile = [r|+{+  "userId": "123",+  "displayName": "Jun",+  "pictureUrl": "https://example.com/profile.jpg",+  "statusMessage": null+}+|]++simpleProfile :: BL.ByteString+simpleProfile = [r|+{+  "userId": "123",+  "displayName": "Jun",+  "pictureUrl": null,+  "statusMessage": null+}+|]++badProfile :: BL.ByteString+badProfile = [r|+{+  "userId'": "123",+  "displayName": "Jun",+  "pictureUrl": "https://example.com/profile.jpg",+  "statusMessage": null+}+|]++simpleError :: BL.ByteString+simpleError = [r|+{+  "message":"Invalid reply token"+}+|]+++complexError :: BL.ByteString+complexError = [r|+{+  "message":"The request body has 2 error(s)",+  "details":[+    {"message":"May not be empty","property":"messages[0].text"},+    {"message":"Must be one of the following values: [text, image, video, audio, location, sticker, template, imagemap]","property":"messages[1].type"}+  ]+}+|]++badError :: BL.ByteString+badError = [r|+{+  "massage": "no message, yes massage"+}+|]
+ test/Line/Messaging/Webhook/TypesSpec.hs view
@@ -0,0 +1,134 @@+module Line.Messaging.Webhook.TypesSpec where++import Test.Hspec++import Line.Messaging.Webhook.TypesSpecHelper++import Data.Aeson+import Data.Maybe (isJust)+import Data.Time.Clock (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Line.Messaging.Webhook.Types+import Line.Messaging.Types (Text(..), Sticker(..), Location(..))+import qualified Data.ByteString.Lazy as BL++fromJSONSpec :: (FromJSON a, Eq a, Show a)+             => [(BL.ByteString, Maybe a)]+             -> SpecWith ()+fromJSONSpec ((raw, result):xs) = do+  let title = if isJust result then "success" else "fail"+  it title $ decode raw `shouldBe` result+  fromJSONSpec xs+fromJSONSpec [] = return ()++datetime :: Integer -> UTCTime+datetime = posixSecondsToUTCTime . (/ 1000) . fromInteger++spec :: Spec+spec = do+  describe "body" $ fromJSONSpec+    [ ( goodBody, Just $ Body [] )+    , ( badBody, Nothing)+    ]++  describe "event source" $ fromJSONSpec+    [ ( goodUser, Just $ User "123" )+    , ( goodGroup, Just $ Group "456" )+    , ( goodRoom, Just $ Room "789" )+    , ( badSource, Nothing )+    ]++  let replyE event a = Just $ Body $ [+        event ( User "U206d25c2ea6bd87c17655609a1c37cb8"+              , datetime 1462629479859+              , "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA"+              , a+              )+        ]+  let nonreplyE event a = Just $ Body $ [+        event ( User "U206d25c2ea6bd87c17655609a1c37cb8"+              , datetime 1462629479859+              , ()+              , a+              )+        ]++  describe "text message event" $ fromJSONSpec+    [ ( goodTextMessage, replyE MessageEvent (TextEM "325708" $ Text "Hello, world") )+    , ( badTextMessage, Nothing )+    ]++  describe "image message event" $ fromJSONSpec+    [ ( goodImageMessage, replyE MessageEvent (ImageEM "325708") )+    , ( badImageMessage, Nothing )+    ]++  describe "video message event" $ fromJSONSpec+    [ ( goodVideoMessage, replyE MessageEvent (VideoEM "325708") )+    , ( badVideoMessage, Nothing )+    ]++  describe "audio message event" $ fromJSONSpec+    [ ( goodAudioMessage, replyE MessageEvent (AudioEM "325708") )+    , ( badAudioMessage, Nothing )+    ]++  describe "location message event" $ fromJSONSpec+    [ ( goodLocationMessage, replyE MessageEvent (LocationEM "325708" $+                                               Location+                                                "my location"+                                                "some address"+                                                35.65910807942215+                                                139.70372892916203) )+    , ( badLocationMessage, Nothing )+    ]++  describe "stcker message event" $ fromJSONSpec+    [ ( goodStickerMessage, replyE MessageEvent (StickerEM "325708" $+                                                 Sticker+                                                  "1"+                                                  "1") )+    , ( badStickerMessage, Nothing )+    ]++  describe "follow event" $ fromJSONSpec+    [ ( goodFollow, replyE FollowEvent () )+    , ( badFollow, Nothing )+    ]++  describe "unfollow event" $ fromJSONSpec+    [ ( goodUnfollow, nonreplyE UnfollowEvent () )+    , ( badUnfollow, Nothing )+    ]++  describe "join event" $ fromJSONSpec+    [ ( goodJoin, Just $ Body $ [+        JoinEvent ( Group "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+                  , datetime 1462629479859+                  , "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA"+                  , ()+                  )+        ] )+    , ( badJoin, Nothing )+    ]++  describe "leave event" $ fromJSONSpec+    [ ( goodLeave, Just $ Body $ [+          LeaveEvent ( Group "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+                     , datetime 1462629479859+                     , ()+                     , ()+                     )+          ] )+    , ( badLeave, Nothing )+    ]++  describe "postback event" $ fromJSONSpec+    [ ( goodPostback, replyE PostbackEvent "action=buyItem&itemId=123123&color=red" )+    , ( badPostback, Nothing )+    ]++  describe "beacon event" $ fromJSONSpec+    [ ( goodBeacon, replyE BeaconEvent (BeaconEnter "d41d8cd98f") )+    , ( badBeacon, Nothing )+    ]
+ test/Line/Messaging/Webhook/TypesSpecHelper.hs view
@@ -0,0 +1,469 @@+{-# LANGUAGE QuasiQuotes #-}++module Line.Messaging.Webhook.TypesSpecHelper where++import Text.RawString.QQ+import qualified Data.ByteString.Lazy as BL++goodBody :: BL.ByteString+goodBody = [r|+{ "events": [] }+|]++badBody :: BL.ByteString+badBody = [r|+{ "event": [] }+|]++goodUser :: BL.ByteString+goodUser = [r|+{ "type": "user", "userId": "123" }+|]++goodGroup :: BL.ByteString+goodGroup = [r|+{ "type": "group", "groupId": "456" }+|]++goodRoom :: BL.ByteString+goodRoom = [r|+{ "type": "room", "roomId": "789" }+|]++badSource :: BL.ByteString+badSource = [r|+{ "type": "bad", "userId": "123 }+|]++goodTextMessage :: BL.ByteString+goodTextMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "text",+    "text": "Hello, world"+  }+}+] }+|]++badTextMessage :: BL.ByteString+badTextMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "text'",+    "text": "Hello, world"+  }+}+] }+|]++goodImageMessage :: BL.ByteString+goodImageMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "image"+  }+}++] }+|]++badImageMessage :: BL.ByteString+badImageMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "image'"+  }+}+] }+|]++goodVideoMessage :: BL.ByteString+goodVideoMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "video"+  }+}+] }+|]++badVideoMessage :: BL.ByteString+badVideoMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "video'"+  }+}+] }+|]++goodAudioMessage :: BL.ByteString+goodAudioMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "audio"+  }+}+] }+|]++badAudioMessage :: BL.ByteString+badAudioMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "audio'"+  }+}+] }+|]++goodLocationMessage :: BL.ByteString+goodLocationMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "location",+    "title": "my location",+    "address": "some address",+    "latitude": 35.65910807942215,+    "longitude": 139.70372892916203+  }+}+] }+|]++badLocationMessage :: BL.ByteString+badLocationMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id'": "325708",+    "type": "location",+    "title": "my location",+    "address": "〒150-0002 東京都渋谷区渋谷2丁目21−1",+    "latitude": 35.65910807942215,+    "longitude": 139.70372892916203+  }+}+] }+|]++goodStickerMessage :: BL.ByteString+goodStickerMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message": {+    "id": "325708",+    "type": "sticker",+    "packageId": "1",+    "stickerId": "1"+  }+}+] }+|]++badStickerMessage :: BL.ByteString+badStickerMessage = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "message",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "message'": {+    "id": "325708",+    "type": "sticker",+    "packageId": "1",+    "stickerId": "1"+  }+}+] }+|]++goodFollow :: BL.ByteString+goodFollow = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "follow",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  }+}+] }+|]++badFollow :: BL.ByteString+badFollow = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "follow'",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  }+}+] }+|]++goodUnfollow :: BL.ByteString+goodUnfollow = [r|+{ "events": [+{+  "type": "unfollow",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  }+}+] }+|]++badUnfollow :: BL.ByteString+badUnfollow = [r|+{ "events": [+{+  "type": "unfollow'",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  }+}+] }+|]++goodJoin :: BL.ByteString+goodJoin = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "join",+  "timestamp": 1462629479859,+  "source": {+    "type": "group",+    "groupId": "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+  }+}+] }+|]++badJoin :: BL.ByteString+badJoin = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "join'",+  "timestamp": 1462629479859,+  "source": {+    "type": "group",+    "groupId": "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+  }+}+] }+|]++goodLeave :: BL.ByteString+goodLeave = [r|+{ "events": [+{+  "type": "leave",+  "timestamp": 1462629479859,+  "source": {+    "type": "group",+    "groupId": "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+  }+}+] }+|]++badLeave :: BL.ByteString+badLeave = [r|+{ "events": [+{+  "type": "leave'",+  "timestamp": 1462629479859,+  "source": {+    "type": "group",+    "groupId": "cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"+  }+}+] }+|]++goodPostback :: BL.ByteString+goodPostback = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "postback",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "postback": {+    "data": "action=buyItem&itemId=123123&color=red"+  }+}+] }+|]++badPostback :: BL.ByteString+badPostback = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "postback",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "postback": {+    "data'": "action=buyItem&itemId=123123&color=red"+  }+}+] }+|]++goodBeacon :: BL.ByteString+goodBeacon = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "beacon",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "beacon": {+    "hwid": "d41d8cd98f",+    "type": "enter"+  }+}+] }+|]++badBeacon :: BL.ByteString+badBeacon = [r|+{ "events": [+{+  "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",+  "type": "beacon",+  "timestamp": 1462629479859,+  "source": {+    "type": "user",+    "userId": "U206d25c2ea6bd87c17655609a1c37cb8"+  },+  "bacon": {+    "hwid": "d41d8cd98f",+    "type": "enter"+  }+}+] }+|]
+ test/Line/Messaging/Webhook/ValidationSpec.hs view
@@ -0,0 +1,27 @@+module Line.Messaging.Webhook.ValidationSpec where++import Test.Hspec+import Test.QuickCheck+import Test.QuickCheck.Instances++import Crypto.Hash.SHA256 (hmaclazy)+import Data.Text.Encoding (encodeUtf8)+import Line.Messaging.Webhook.Validation (validateSignature)+import qualified Data.ByteString as B+import qualified Data.ByteString.Base64 as Base64++spec :: Spec+spec = do+  describe "validation" $ do+    it "returns true for valid signature" $ property $ \ channelSecret body ->+      let+        signature = Base64.encode $ hmaclazy (encodeUtf8 channelSecret) body+      in+        validateSignature channelSecret body signature++    it "returns false for invalid signature" $ property $ \ channelSecret body ->+      let+        right = Base64.encode $ hmaclazy (encodeUtf8 channelSecret) body+        wrong = right `B.append` "wrong"+      in+        not $ validateSignature channelSecret body wrong
+ test/Line/Messaging/WebhookSpec.hs view
@@ -0,0 +1,80 @@+module Line.Messaging.WebhookSpec where++import Test.Hspec+import Test.Hspec.Wai++import Control.Monad.Trans.Except (runExceptT)+import Crypto.Hash.SHA256 (hmaclazy)+import Data.Either (isRight)+import Data.Text.Encoding (encodeUtf8)+import Line.Messaging.Webhook+import qualified Data.ByteString.Lazy as BL+import qualified Data.ByteString.Base64 as Base64+import qualified Web.Scotty as Scotty++emptyBody :: BL.ByteString+emptyBody = "{ \"events\": [] }"++wrongBody :: BL.ByteString+wrongBody = "{ \"event\": [] }"++createSig :: ChannelSecret -> BL.ByteString -> Signature+createSig sec body = Base64.encode $ hmaclazy (encodeUtf8 sec) body++spec :: Spec+spec = do+  describe "Basic webhook" $ do+    it "right result" $ do+      let secret = "some-secret"+          sig = createSig secret emptyBody+      result <- runExceptT $ webhook secret emptyBody sig+      result `shouldSatisfy` isRight++    it "wrong sig" $ do+      let secret = "some-secret"+          sig = "wrong-sig"+      result <- runExceptT $ webhook secret emptyBody sig+      result `shouldBe` Left SignatureVerificationFailed++    it "malformed body" $ do+      let secret = "some-secret"+          sig = createSig secret wrongBody+      result <- runExceptT $ webhook secret wrongBody sig+      result `shouldBe` Left MessageDecodeFailed++  describe "WAI webhook" $+    let waiApp = return $ webhookApp "some-secret" (const $ return ()) defaultOnFailure+        webhookReq sec body hasCorrectSig = request "GET" "/" headers body+          where+            sig = if hasCorrectSig then createSig sec body else "wrong sig"+            headers = [ ("X-Line-Signature", sig) ]+    in with waiApp $ do+      it "handle req well" $ do+        webhookReq "some-secret" emptyBody True `shouldRespondWith` 200++      it "with wrong sig" $ do+        webhookReq "some-secret" emptyBody False `shouldRespondWith`+          "SignatureVerificationFailed" { matchStatus = 400 }++      it "malformed body" $ do+        webhookReq "some-secret" wrongBody True `shouldRespondWith`+          "MessageDecodeFailed" { matchStatus = 400 }++  describe "Scotty webhook" $+    let waiApp = Scotty.scottyApp $+          Scotty.get "/" $ webhookAction "some-secret" (const $ return ()) defaultOnFailure'+        webhookReq sec body hasCorrectSig = request "GET" "/" headers body+          where+            sig = if hasCorrectSig then createSig sec body else "wrong sig"+            headers = [ ("X-Line-Signature", sig) ]+    in with waiApp $ do+      it "handle req well" $ do+        webhookReq "some-secret" emptyBody True `shouldRespondWith` 200++      it "with wrong sig" $ do+        webhookReq "some-secret" emptyBody False `shouldRespondWith`+          "SignatureVerificationFailed" { matchStatus = 400 }++      it "malformed body" $ do+        webhookReq "some-secret" wrongBody True `shouldRespondWith`+          "MessageDecodeFailed" { matchStatus = 400 }
test/Spec.hs view
@@ -1,2 +1,1 @@-main :: IO ()-main = putStrLn "Test suite not yet implemented"+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}