From ac110907e04df49d0baa06b9e7e04b4c1496e68d Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Tue, 16 Jan 2024 15:00:16 +1000 Subject: [PATCH 1/9] .gitignore: Ignore `.envrc` and `.direnv/` --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7f2126f..4dfb357 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .dir-locals.el +.direnv/ +.envrc dist-newstyle/ dist/ node_modules/ From 1dfc591e0f21a76f41286984f77833bb38889f71 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Tue, 16 Jan 2024 14:59:05 +1000 Subject: [PATCH 2/9] CHANGELOG.md: Fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c899e4a..3a90261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.3.0.0 -- 2023-12-17 -- Breaking change: add `Options` record parameter to `runWithOptions`, +- Breaking change: add `Options` record parameter to `runWithContext`, `toWaiRequest` and `fromWaiResponse`. - Provide a `defaultOptions`. - Make whether or not to run base64-encoding on the response body customizable From 70f2f4e32b42267603c18b162d102efc20f371b6 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Tue, 16 Jan 2024 14:59:11 +1000 Subject: [PATCH 3/9] CHANGELOG.md: Document breakage introduced in 0.3.0.0 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a90261..2c98408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.3.0.0 -- 2023-12-17 +- Accidental breaking change: more elaborate `Content-Type` headers + like `Content-Type: application/json; charset=utf-8` are now encoded + as if they were binary payloads. This release has been deprecated. - Breaking change: add `Options` record parameter to `runWithContext`, `toWaiRequest` and `fromWaiResponse`. - Provide a `defaultOptions`. From 8ea72ae2409af19b629649fdf1458181bfd552c3 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Tue, 16 Jan 2024 15:00:03 +1000 Subject: [PATCH 4/9] Introduce `runWithOptions` function --- CHANGELOG.md | 6 ++++++ src/Network/Wai/Handler/Hal.hs | 29 ++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c98408..fccc418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Revision history for wai-handler-hal +## 0.4.0.0 -- 2024-01-?? + +- New function: `Wai.Handler.Hal.runWithOptions :: Options -> + Application -> ProxyRequest NoAuthorizer -> ProxyResponse`. This + provides a convenient way to pass custom `Options` without all the + bells and whistles of `runWithContext`. ## 0.3.0.0 -- 2023-12-17 - Accidental breaking change: more elaborate `Content-Type` headers diff --git a/src/Network/Wai/Handler/Hal.hs b/src/Network/Wai/Handler/Hal.hs index a227517..7225bbf 100644 --- a/src/Network/Wai/Handler/Hal.hs +++ b/src/Network/Wai/Handler/Hal.hs @@ -31,6 +31,7 @@ -- @ module Network.Wai.Handler.Hal ( run, + runWithOptions, runWithContext, Options (..), defaultOptions, @@ -105,13 +106,7 @@ run :: Wai.Application -> HalRequest.ProxyRequest HalRequest.NoAuthorizer -> m HalResponse.ProxyResponse -run app req = liftIO $ do - waiReq <- toWaiRequest defaultOptions req - responseRef <- IORef.newIORef Nothing - Wai.ResponseReceived <- app waiReq $ \waiResp -> - Wai.ResponseReceived <$ IORef.writeIORef responseRef (Just waiResp) - Just waiResp <- IORef.readIORef responseRef - fromWaiResponse defaultOptions waiResp +run = runWithOptions defaultOptions -- | Options that can be used to customize the behaviour of 'runWithContext'. -- 'defaultOptions' provides sensible defaults. @@ -152,6 +147,26 @@ defaultOptions = _ -> True } +-- | A variant of 'run' with configurable 'Options'. Useful if you +-- just want to override the 'binaryMediaTypes' setting but don't need +-- the rest of 'runWithContext''s features. +-- +-- @since 0.4.0.0 +runWithOptions :: + (MonadIO m) => + -- | Configuration options. 'defaultOptions' provides sensible defaults. + Options -> + Wai.Application -> + HalRequest.ProxyRequest HalRequest.NoAuthorizer -> + m HalResponse.ProxyResponse +runWithOptions opts app req = liftIO $ do + waiReq <- toWaiRequest opts req + responseRef <- IORef.newIORef Nothing + Wai.ResponseReceived <- app waiReq $ \waiResp -> + Wai.ResponseReceived <$ IORef.writeIORef responseRef (Just waiResp) + Just waiResp <- IORef.readIORef responseRef + fromWaiResponse opts waiResp + -- | Convert a WAI 'Wai.Application' into a function that can -- be run by hal's 'AWS.Lambda.Runtime.mRuntimeWithContext''. This -- function exposes all the configurable knobs. From 020b4961c25a0cebfb84d348c6d8b3eb47cf9aa8 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Tue, 16 Jan 2024 15:01:06 +1000 Subject: [PATCH 5/9] Options: hold a `[MediaType]` to be considered for binary responses A careful reading between the lines of the [API Gateway documentation][1] implies that if `binaryMediaTypes` is not set on the API Gateway, all responses are considered to be text. So we should be able to get away with an explicit list of media types that require binary responses, and we can ask the developer to ensure that it matches the `binaryMediaTypes` setting on his or her API. [1]: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html --- CHANGELOG.md | 13 +++++++ src/Network/Wai/Handler/Hal.hs | 57 +++++++++++++++------------ test/Network/Wai/Handler/HalTest.hs | 60 +++++++++++++++++------------ wai-handler-hal.cabal | 1 + 4 files changed, 82 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fccc418..d7f2489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Application -> ProxyRequest NoAuthorizer -> ProxyResponse`. This provides a convenient way to pass custom `Options` without all the bells and whistles of `runWithContext`. + +- Instead of guessing whether a given response `Content-Type` should + be sent as text or base64-encoded binary, `Options` now contains a + `binaryMediaTypes :: [MediaType]`, which lists the media types that + should be base64-encoded. This should match the `binaryMediaTypes` + setting you have configured on the API Gateway that integrates with + your Lambda Function. + + _See:_ [Content type conversion in API + Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html) + in the [Amazon API Gateway Developer + Guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/). + ## 0.3.0.0 -- 2023-12-17 - Accidental breaking change: more elaborate `Content-Type` headers diff --git a/src/Network/Wai/Handler/Hal.hs b/src/Network/Wai/Handler/Hal.hs index 7225bbf..67787cd 100644 --- a/src/Network/Wai/Handler/Hal.hs +++ b/src/Network/Wai/Handler/Hal.hs @@ -1,4 +1,5 @@ {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE TupleSections #-} @@ -62,11 +63,13 @@ import Data.HashMap.Lazy (HashMap) import qualified Data.HashMap.Lazy as H import qualified Data.IORef as IORef import Data.List (foldl', sort) +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T import Data.Vault.Lazy (Key, Vault) import qualified Data.Vault.Lazy as Vault +import Network.HTTP.Media (MediaType, matches, parseAccept, renderHeader) import Network.HTTP.Types.Header ( HeaderName, ResponseHeaders, @@ -119,17 +122,20 @@ data Options = Options -- have to tell it yourself. This is almost always going to be 443 -- (HTTPS). portNumber :: PortNumber, - -- | Binary responses need to be encoded as base64. This option lets you - -- customize which mime types are considered binary data. + -- | To return binary data, API Gateway requires you to configure + -- the @binaryMediaTypes@ setting on your API, and then + -- base64-encode your binary responses. -- - -- The following mime types are __not__ considered binary by default: + -- If the @Content-Type@ header in the @wai@ 'Wai.Response' + -- matches any of the media types in this field, @wai-handler-hal@ + -- will base64-encode its response to the API Gateway. -- - -- * @application/json@ - -- * @application/xml@ - -- * anything starting with @text/@ - -- * anything ending with @+json@ - -- * anything ending with @+xml@ - binaryMimeType :: Text -> Bool + -- If you set @binaryMediaTypes@ in your API, you should override + -- the default (empty) list to match. + -- + -- /See:/ [Content type conversion in API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html) + -- in the [Amazon API Gateway Developer Guide](https://docs.aws.amazon.com/apigateway/latest/developerguide/). + binaryMediaTypes :: [MediaType] } -- | Default options for running 'Wai.Application's on Lambda. @@ -138,13 +144,7 @@ defaultOptions = Options { vault = Vault.empty, portNumber = 443, - binaryMimeType = \mime -> case mime of - "application/json" -> False - "application/xml" -> False - _ | "text/" `T.isPrefixOf` mime -> False - _ | "+json" `T.isSuffixOf` mime -> False - _ | "+xml" `T.isSuffixOf` mime -> False - _ -> True + binaryMediaTypes = [] } -- | A variant of 'run' with configurable 'Options'. Useful if you @@ -345,12 +345,18 @@ readFilePart path mPart = withFile path ReadMode $ \h -> do hSeek h AbsoluteSeek offset B.hGet h $ fromIntegral count -createProxyBody :: Options -> Text -> ByteString -> HalResponse.ProxyBody -createProxyBody opts contentType body - | binaryMimeType opts contentType = - HalResponse.ProxyBody contentType (T.decodeUtf8 $ B64.encode body) True - | otherwise = - HalResponse.ProxyBody contentType (T.decodeUtf8 body) False +createProxyBody :: Options -> MediaType -> ByteString -> HalResponse.ProxyBody +createProxyBody opts contentType body = + HalResponse.ProxyBody + { HalResponse.contentType = T.decodeUtf8 $ renderHeader contentType, + HalResponse.serialized = + if isBase64Encoded + then T.decodeUtf8 $ B64.encode body + else T.decodeUtf8 body, + HalResponse.isBase64Encoded + } + where + isBase64Encoded = any (contentType `matches`) $ binaryMediaTypes opts addHeaders :: ResponseHeaders -> HalResponse.ProxyResponse -> HalResponse.ProxyResponse @@ -364,6 +370,7 @@ addHeaders headers response = foldl' addHeader response headers -- | Try to find the content-type of a response, given the response -- headers. If we can't, return @"application/octet-stream"@. -getContentType :: ResponseHeaders -> Text -getContentType = - maybe "application/octet-stream" T.decodeUtf8 . lookup hContentType +getContentType :: ResponseHeaders -> MediaType +getContentType headers = + fromMaybe "application/octet-stream" $ + lookup hContentType headers >>= parseAccept diff --git a/test/Network/Wai/Handler/HalTest.hs b/test/Network/Wai/Handler/HalTest.hs index df32236..5c47298 100644 --- a/test/Network/Wai/Handler/HalTest.hs +++ b/test/Network/Wai/Handler/HalTest.hs @@ -1,17 +1,26 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} module Network.Wai.Handler.HalTest where -import AWS.Lambda.Events.ApiGateway.ProxyRequest +import AWS.Lambda.Events.ApiGateway.ProxyRequest (ProxyRequest) +import AWS.Lambda.Events.ApiGateway.ProxyResponse + ( ProxyBody (..), + ProxyResponse (..), + ) import Data.Aeson (eitherDecodeFileStrict') -import qualified Data.Text as T +import qualified Data.ByteString.Base64 as B64 +import qualified Data.Text.Encoding as T import qualified Data.Text.Lazy.Encoding as TL import Data.Void (Void) +import Network.HTTP.Types (hContentType, ok200) +import Network.Wai (Response, responseLBS) import Network.Wai.Handler.Hal -import Test.Tasty -import Test.Tasty.Golden +import Test.Tasty (TestTree) +import Test.Tasty.Golden (goldenVsString) import Test.Tasty.HUnit (assertEqual, testCase) -import Text.Pretty.Simple +import Text.Pretty.Simple (pShowNoColor) test_ConvertProxyRequest :: TestTree test_ConvertProxyRequest = @@ -22,22 +31,25 @@ test_ConvertProxyRequest = waiRequest <- toWaiRequest defaultOptions proxyRequest pure . TL.encodeUtf8 $ pShowNoColor waiRequest -test_DefaultBinaryMimeTypes :: TestTree -test_DefaultBinaryMimeTypes = testCase "default binary MIME types" $ do - assertBinary False "text/plain" - assertBinary False "text/html" - assertBinary False "application/json" - assertBinary False "application/xml" - assertBinary False "application/vnd.api+json" - assertBinary False "application/vnd.api+xml" - assertBinary False "image/svg+xml" - - assertBinary True "application/octet-stream" - assertBinary True "audio/vorbis" - assertBinary True "image/png" - where - assertBinary expected mime = - assertEqual - mime - (binaryMimeType defaultOptions (T.pack mime)) - expected +test_BinaryResponse :: TestTree +test_BinaryResponse = testCase "Responding to API Gateway with text" $ do + let options = defaultOptions {binaryMediaTypes = ["*/*"]} + ProxyResponse {body = ProxyBody {..}} <- + fromWaiResponse options helloWorld + + assertEqual "response is binary" True isBase64Encoded + assertEqual + "response is base64-encoded" + (Right "Hello, World!") + (B64.decode (T.encodeUtf8 serialized)) + +test_TextResponse :: TestTree +test_TextResponse = testCase "Responding to API Gateway with text" $ do + ProxyResponse {body = ProxyBody {..}} <- + fromWaiResponse defaultOptions helloWorld + + assertEqual "response is not binary" False isBase64Encoded + assertEqual "response is unmangled" "Hello, World!" serialized + +helloWorld :: Response +helloWorld = responseLBS ok200 [(hContentType, "text/plain")] "Hello, World!" diff --git a/wai-handler-hal.cabal b/wai-handler-hal.cabal index 120f192..28fdf63 100644 --- a/wai-handler-hal.cabal +++ b/wai-handler-hal.cabal @@ -52,6 +52,7 @@ common deps , bytestring >=0.10.8 && <0.12.1 , case-insensitive ^>=1.2.0.0 , hal >=0.4.7 && <0.4.11 || >=1.0.0 && <1.2 + , http-media ^>=0.8.1.1 , http-types ^>=0.12.3 , network >=2.8.0.0 && <3.2 , text ^>=1.2.3 || >=2.0 && <2.1.1 From 6a5f750f260555fe18897d4db8a715aebd467ea7 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Wed, 17 Jan 2024 09:19:10 +1000 Subject: [PATCH 6/9] tests: `test_` -> `unit_` for `tasty-hunit` tests --- test/Network/Wai/Handler/HalTest.hs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/Network/Wai/Handler/HalTest.hs b/test/Network/Wai/Handler/HalTest.hs index 5c47298..12acffc 100644 --- a/test/Network/Wai/Handler/HalTest.hs +++ b/test/Network/Wai/Handler/HalTest.hs @@ -19,7 +19,7 @@ import Network.Wai (Response, responseLBS) import Network.Wai.Handler.Hal import Test.Tasty (TestTree) import Test.Tasty.Golden (goldenVsString) -import Test.Tasty.HUnit (assertEqual, testCase) +import Test.Tasty.HUnit (Assertion, assertEqual) import Text.Pretty.Simple (pShowNoColor) test_ConvertProxyRequest :: TestTree @@ -31,8 +31,8 @@ test_ConvertProxyRequest = waiRequest <- toWaiRequest defaultOptions proxyRequest pure . TL.encodeUtf8 $ pShowNoColor waiRequest -test_BinaryResponse :: TestTree -test_BinaryResponse = testCase "Responding to API Gateway with text" $ do +unit_BinaryResponse :: Assertion +unit_BinaryResponse = do let options = defaultOptions {binaryMediaTypes = ["*/*"]} ProxyResponse {body = ProxyBody {..}} <- fromWaiResponse options helloWorld @@ -43,8 +43,8 @@ test_BinaryResponse = testCase "Responding to API Gateway with text" $ do (Right "Hello, World!") (B64.decode (T.encodeUtf8 serialized)) -test_TextResponse :: TestTree -test_TextResponse = testCase "Responding to API Gateway with text" $ do +unit_TextResponse :: Assertion +unit_TextResponse = do ProxyResponse {body = ProxyBody {..}} <- fromWaiResponse defaultOptions helloWorld From 74097116bc78ac78e90c0e59c91eda7eceb6c246 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Wed, 17 Jan 2024 09:35:46 +1000 Subject: [PATCH 7/9] Update version --- wai-handler-hal.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wai-handler-hal.cabal b/wai-handler-hal.cabal index 28fdf63..ed104c9 100644 --- a/wai-handler-hal.cabal +++ b/wai-handler-hal.cabal @@ -1,6 +1,6 @@ cabal-version: 2.2 name: wai-handler-hal -version: 0.3.0.0 +version: 0.4.0.0 synopsis: Wrap WAI applications to run on AWS Lambda description: This library provides a function 'Network.Wai.Handler.Hal.run' to From f7ec66b95d2e056d03e876133bd69d90cfe920a5 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Wed, 17 Jan 2024 14:15:52 +1000 Subject: [PATCH 8/9] CHANGELOG.md: Add release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f2489..58d592c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Revision history for wai-handler-hal -## 0.4.0.0 -- 2024-01-?? +## 0.4.0.0 -- 2024-01-17 - New function: `Wai.Handler.Hal.runWithOptions :: Options -> Application -> ProxyRequest NoAuthorizer -> ProxyResponse`. This From 3f35b8be8c1116733f8588c717b947e8c050ca96 Mon Sep 17 00:00:00 2001 From: Jack Kelly Date: Wed, 17 Jan 2024 14:18:58 +1000 Subject: [PATCH 9/9] wai-handler-hal.cabal: Satisfy `cabal check` --- wai-handler-hal.cabal | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wai-handler-hal.cabal b/wai-handler-hal.cabal index ed104c9..7b84861 100644 --- a/wai-handler-hal.cabal +++ b/wai-handler-hal.cabal @@ -21,8 +21,9 @@ maintainer: Bellroy Tech Team copyright: Copyright (C) 2021 Bellroy Pty Ltd category: AWS, Cloud build-type: Simple -extra-source-files: +extra-doc-files: CHANGELOG.md +extra-source-files: README.md test/data/ProxyRequest.json test/golden/WaiRequest.txt