packages feed

wai-handler-hal 0.2.0.0 → 0.3.0.0

raw patch · 5 files changed

+277/−47 lines, 5 filesdep +tasty-hunitdep ~halPVP ok

version bump matches the API change (PVP)

Dependencies added: tasty-hunit

Dependency ranges changed: hal

API changes (from Hackage documentation)

+ Network.Wai.Handler.Hal: Options :: Vault -> PortNumber -> (Text -> Bool) -> Options
+ Network.Wai.Handler.Hal: [binaryMimeType] :: Options -> Text -> Bool
+ Network.Wai.Handler.Hal: [portNumber] :: Options -> PortNumber
+ Network.Wai.Handler.Hal: [vault] :: Options -> Vault
+ Network.Wai.Handler.Hal: data Options
+ Network.Wai.Handler.Hal: defaultOptions :: Options
- Network.Wai.Handler.Hal: fromWaiResponse :: Response -> IO ProxyResponse
+ Network.Wai.Handler.Hal: fromWaiResponse :: Options -> Response -> IO ProxyResponse
- Network.Wai.Handler.Hal: runWithContext :: MonadIO m => Vault -> PortNumber -> (Key LambdaContext -> Key (ProxyRequest NoAuthorizer) -> Application) -> LambdaContext -> ProxyRequest NoAuthorizer -> m ProxyResponse
+ Network.Wai.Handler.Hal: runWithContext :: MonadIO m => Options -> (Key LambdaContext -> Key (ProxyRequest NoAuthorizer) -> Application) -> LambdaContext -> ProxyRequest NoAuthorizer -> m ProxyResponse
- Network.Wai.Handler.Hal: toWaiRequest :: Vault -> PortNumber -> ProxyRequest a -> IO Request
+ Network.Wai.Handler.Hal: toWaiRequest :: Options -> ProxyRequest a -> IO Request

Files

CHANGELOG.md view
@@ -1,5 +1,13 @@ # Revision history for wai-handler-hal +## 0.3.0.0 -- 2023-12-17++- Breaking change: add `Options` record parameter to `runWithOptions`,+  `toWaiRequest` and `fromWaiResponse`.+- Provide a `defaultOptions`.+- Make whether or not to run base64-encoding on the response body customizable+  through `Options.binaryMimeType`.+ ## 0.2.0.0 -- 2023-03-17  - Breaking change: `toWaiRequest` now sorts request headers and query string
README.md view
@@ -17,3 +17,160 @@ CloudFormation). If you try to use such a Lambda with an API Gateway **HTTP API** (`AWS::ApiGatewayV2::Api` in CloudFormation), it will return 500s.+++For code examples, see [this repository](https://github.com/bellroy/wai-handler-hal-example),+which contains:++* [`wai-handler-hal-example`](https://github.com/bellroy/wai-handler-hal-example): A simple `servant` API set up to run on both `hal` and `warp`; and+* [`wai-handler-hal-cdk`](https://github.com/bellroy/wai-handler-hal-example/tree/master/wai-handler-hal-cdk): A small [AWS+  CDK](https://docs.aws.amazon.com/cdk/latest/guide/home.html)+  application that deploys an API backed by `wai-handler-hal-example`.++## Developing++Instead of serving your application with something like+[`warp`](https://hackage.haskell.org/package/warp), you will set up an+executable that serves your application with+[`hal`](https://hackage.haskell.org/package/hal). The necessary glue+code looks something like this:++```haskell+import AWS.Lambda.Runtime (mRuntime)+import Network.Wai (Application)+import qualified Network.Wai.Handler.Hal as WaiHandler++app :: Application+app = undefined -- From Servant or wherever else++main :: IO ()+main = mRuntime $ WaiHandler.run app+```++### Local testing++Compiled Lambda functions can be awkward to test, especially if you're+relying on an API Gateway integration to translate HTTP+requests. Consider defining a second executable target that serves+your `Application` using `warp`, which you can use for local+testing.++### Caveats++* The Lambda is never told the port that the API Gateway is listening+  on. Most APIs will listen on `443` (HTTPS), so that's what the+  library reports by default. See `runWithContext` if you need to+  change this.++* The Lambda is never told the HTTP version that the client uses when+  talking with the API Gateway. We assume HTTP 1.1.++## Packaging++Lambda functions are packaged in one of two ways: as `.zip` files or+as Docker container images. The [`wai-handler-hal-cdk` example](https://github.com/bellroy/wai-handler-hal-example/tree/master/wai-handler-hal-cdk) uses+`.zip` files for simplicity. CDK doesn't give us an easy way to build+container images using `nix build`, so a container-based deployment+using CDK would need to push to ECR first and then reference the image+by name.++### `.zip` files++You will need to create a `.zip` file containing ([at+least](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html#runtimes-custom-build))+an executable named `bootstrap`. This executable needs to run in+Amazon's runtime environment, and there are multiple ways to ensure+this.++#### Static linking++IOHK's [`haskell.nix`](https://github.com/input-output-hk/haskell.nix)+can build static Haskell binaries by cross-compiling against musl+libc. This is convenient, but consider copyleft implications (of+`libgmp`; `wai-handler-hal` is BSD-3-Clause) if you are distributing+the binaries to other people.++#### Dynamic linking++The other option is to compile the executable in an environment with+packages that match the [Lambda runtime+environment](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). Some+ideas:++* Build the executable in an "imitation environment" like the [`lambci/lambda`](https://hub.docker.com/r/lambci/lambda) Docker container; or+* (Untested) build the executable on an Amazon Linux 2 EC2 instance.++### Docker container images++It is possible to package Lambdas into Docker containers for+deployment. Amazon provide base containers for custom runtimes in the+repository+[`amazon/aws-lambda-provided`](https://hub.docker.com/r/amazon/aws-lambda-provided/). One+nice feature of these images is that they provide a [runtime interface+emulator](https://docs.aws.amazon.com/lambda/latest/dg/images-test.html#images-test-AWSbase),+which they fall back to when not running "for real". This makes it+possible to directly invoke the lambda and see how it behaves. (This+is less important here because we can serve our application off+`warp`, but for writing Lambdas not invoked by an API Gateway Proxy+Integration, it's handy.)++At the time of writing (2021-04-06), the+`amazon/aws-lambda-provided:al2` (Amazon Linux 2 tag) is the newest+base image that Amazon recommends for custom runtimes. You can use a+`Dockerfile` to build your images; there are also commented .nix files+in the [example repository](https://github.com/bellroy/wai-handler-hal-example),+showing a couple of different approaches to building container images using Nix.++## Integrating++Actually calling the Lambda is done with a [Lambda proxy+integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html)+from an [API Gateway REST+API](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html).++The simplest possible integration sends every request to the Lambda+(where the `wai` `Application` can return 404 or whatever if the+endpoint doesn't match). This is done by mapping the paths `/` and+`/{proxy+}` for the HTTP method `ANY`. We do this in our [CDK example](https://github.com/bellroy/wai-handler-hal-example/tree/master/wai-handler-hal-cdk),+and [CDK provides a Construct](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigateway.LambdaRestApi.html) which encapsulates this pattern, making+it extremely simple to deploy.++### Splitting Servant applications++While it's simplest to back the entire API with a single Lambda, it+does mean that you risk one Lambda carrying an ever-expanding set of+IAM permissions. Servant's combinators make it easy to break the API+into parts that are served by individual Lambdas, which can each have+the minimal set of permissions attached to their role. If your Servant+service had endpoints `foo`, `bar`, and `baz`, you'd serve them all+from Warp with an expression like `Warp.runEnv 8080 . Servant.serve $+foo :<|> bar :<|> baz`. But `Servant.serve foo`, `Servant.serve bar`,+and `Servant.serve baz` are also all WAI `Application`s, and can each+be bundled into distinct Lambda functions using `wai-handler-hal`.++### Other caveats++* The stage name in a path segment is not passed to the Lambda, so it+  is not passed to the `wai` `Application`. An invoke URL like+  `https://abcde12345.execute-api.us-east-1.amazonaws.com/prod/hoot`+  will send `pathInfo = ["hoot"]` to the `Application`.++## Formatters++The formatters used in this repo are provided by `shell.nix`:++* `*.hs`: [`ormolu`](https://github.com/tweag/ormolu)+* `*.cabal`:+  [`cabal-fmt`](https://hackage.haskell.org/package/cabal-fmt)+  (`cabal-fmt --inplace wai-handler-hal.cabal`)+* `*.nix`:+  [`nixpkgs-fmt`](https://github.com/nix-community/nixpkgs-fmt)+  (`nixpkgs-fmt *.nix`)++## Regenerate CI++This repo uses `haskell-ci`, which is provided by `flake.nix`:++```shell+haskell-ci regenerate+```
src/Network/Wai/Handler/Hal.hs view
@@ -32,6 +32,8 @@ module Network.Wai.Handler.Hal   ( run,     runWithContext,+    Options (..),+    defaultOptions,     toWaiRequest,     fromWaiResponse,   )@@ -99,31 +101,64 @@ -- form, and probably all that you'll need. See 'runWithContext' if -- you have more complex needs. run ::-  MonadIO m =>+  (MonadIO m) =>   Wai.Application ->   HalRequest.ProxyRequest HalRequest.NoAuthorizer ->   m HalResponse.ProxyResponse run app req = liftIO $ do-  waiReq <- toWaiRequest Vault.empty 443 req+  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 waiResp+  fromWaiResponse defaultOptions waiResp +-- | Options that can be used to customize the behaviour of 'runWithContext'.+-- 'defaultOptions' provides sensible defaults.+data Options = Options+  { -- | Vault of values to share between the application and any+    -- middleware. You can pass in @Data.Vault.Lazy.'Vault.empty'@, or+    -- 'mempty' if you don't want to depend on @vault@ directly.+    vault :: Vault,+    -- | API Gateway doesn't tell us the port it's listening on, so you+    -- 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.+    --+    -- The following mime types are __not__ considered binary by default:+    --+    -- * @application/json@+    -- * @application/xml@+    -- * anything starting with @text/@+    -- * anything ending with @+json@+    -- * anything ending with @+xml@+    binaryMimeType :: Text -> Bool+  }++-- | Default options for running 'Wai.Application's on Lambda.+defaultOptions :: Options+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+    }+ -- | 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. runWithContext ::-  MonadIO m =>-  -- | Vault of values to share between the application and any-  -- middleware. You can pass in @Data.Vault.Lazy.'Vault.empty'@, or-  -- 'mempty' if you don't want to depend on @vault@ directly.-  Vault ->-  -- | API Gateway doesn't tell us the port it's listening on, so you-  -- have to tell it yourself. This is almost always going to be 443-  -- (HTTPS).-  PortNumber ->+  (MonadIO m) =>+  -- | Configuration options. 'defaultOptions' provides sensible defaults.+  Options ->   -- | We pass two 'Vault' keys to the callback that provides the   -- 'Wai.Application'. This allows the application to look into the   -- 'Vault' part of each request and read @hal@ data structures, if@@ -146,19 +181,20 @@   -- an "ambiguous type variable" error at the use site.   HalRequest.ProxyRequest HalRequest.NoAuthorizer ->   m HalResponse.ProxyResponse-runWithContext vault port app ctx req = liftIO $ do+runWithContext opts app ctx req = liftIO $ do   contextKey <- Vault.newKey   requestKey <- Vault.newKey   let vault' =-        vault+        vault opts           & Vault.insert contextKey ctx           & Vault.insert requestKey req-  waiReq <- toWaiRequest vault' port req+      opts' = opts {vault = vault'}+  waiReq <- toWaiRequest opts' req   responseRef <- IORef.newIORef Nothing   Wai.ResponseReceived <- app contextKey requestKey waiReq $ \waiResp ->     Wai.ResponseReceived <$ IORef.writeIORef responseRef (Just waiResp)   Just waiResp <- IORef.readIORef responseRef-  fromWaiResponse waiResp+  fromWaiResponse opts' waiResp  -- | Convert the request sent to a Lambda serving an API Gateway proxy -- integration into a WAI request.@@ -166,12 +202,12 @@ -- __Note:__ We aren't told the HTTP version the client is using, so -- we assume HTTP 1.1. toWaiRequest ::-  Vault ->-  PortNumber ->+  Options ->   HalRequest.ProxyRequest a ->   IO Wai.Request-toWaiRequest vault port req = do-  let pathSegments = T.splitOn "/" . T.dropWhile (== '/') $ HalRequest.path req+toWaiRequest opts req = do+  let port = portNumber opts+      pathSegments = T.splitOn "/" . T.dropWhile (== '/') $ HalRequest.path req       query = sort . constructQuery $ HalRequest.multiValueQueryStringParameters req       hints =         NS.defaultHints@@ -226,7 +262,7 @@             Wai.pathInfo = pathSegments,             Wai.queryString = query,             Wai.requestBody = body,-            Wai.vault = vault,+            Wai.vault = vault opts,             Wai.requestBodyLength =               Wai.KnownLength . fromIntegral . BL.length $ HalRequest.body req,             Wai.requestHeaderHost = getHeader hHost req,@@ -262,29 +298,29 @@  -- | Convert a WAI 'Wai.Response' into a hal -- 'HalResponse.ProxyResponse'.-fromWaiResponse :: Wai.Response -> IO HalResponse.ProxyResponse-fromWaiResponse (Wai.ResponseFile status headers path mFilePart) = do+fromWaiResponse :: Options -> Wai.Response -> IO HalResponse.ProxyResponse+fromWaiResponse opts (Wai.ResponseFile status headers path mFilePart) = do   fileData <- readFilePart path mFilePart   pure     . addHeaders headers     . HalResponse.response status-    . createProxyBody (getContentType headers)+    . createProxyBody opts (getContentType headers)     $ fileData-fromWaiResponse (Wai.ResponseBuilder status headers builder) =+fromWaiResponse opts (Wai.ResponseBuilder status headers builder) =   pure     . addHeaders headers     . HalResponse.response status-    . createProxyBody (getContentType headers)+    . createProxyBody opts (getContentType headers)     . BL.toStrict     $ Builder.toLazyByteString builder-fromWaiResponse (Wai.ResponseStream status headers stream) = do+fromWaiResponse opts (Wai.ResponseStream status headers stream) = do   builderRef <- IORef.newIORef mempty   let addChunk chunk = IORef.modifyIORef builderRef (<> chunk)       flush = IORef.modifyIORef builderRef (<> Builder.flush)   stream addChunk flush   builder <- IORef.readIORef builderRef-  fromWaiResponse (Wai.ResponseBuilder status headers builder)-fromWaiResponse (Wai.ResponseRaw _ resp) = fromWaiResponse resp+  fromWaiResponse opts (Wai.ResponseBuilder status headers builder)+fromWaiResponse opts (Wai.ResponseRaw _ resp) = fromWaiResponse opts resp  readFilePart :: FilePath -> Maybe Wai.FilePart -> IO ByteString readFilePart path mPart = withFile path ReadMode $ \h -> do@@ -294,12 +330,12 @@       hSeek h AbsoluteSeek offset       B.hGet h $ fromIntegral count -createProxyBody :: Text -> ByteString -> HalResponse.ProxyBody-createProxyBody contentType body-  | any (`T.isPrefixOf` contentType) ["text/plain", "application/json"] =-    HalResponse.ProxyBody contentType (T.decodeUtf8 body) False+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 $ B64.encode body) True+      HalResponse.ProxyBody contentType (T.decodeUtf8 body) False  addHeaders ::   ResponseHeaders -> HalResponse.ProxyResponse -> HalResponse.ProxyResponse
test/Network/Wai/Handler/HalTest.hs view
@@ -4,11 +4,13 @@  import AWS.Lambda.Events.ApiGateway.ProxyRequest import Data.Aeson (eitherDecodeFileStrict')-import qualified Data.Text.Lazy.Encoding as T+import qualified Data.Text as T+import qualified Data.Text.Lazy.Encoding as TL import Data.Void (Void) import Network.Wai.Handler.Hal import Test.Tasty import Test.Tasty.Golden+import Test.Tasty.HUnit (assertEqual, testCase) import Text.Pretty.Simple  test_ConvertProxyRequest :: TestTree@@ -17,5 +19,25 @@     proxyRequest :: ProxyRequest Void <-       eitherDecodeFileStrict' "test/data/ProxyRequest.json"         >>= either fail pure-    waiRequest <- toWaiRequest mempty 443 proxyRequest-    pure . T.encodeUtf8 $ pShowNoColor waiRequest+    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
wai-handler-hal.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.2 name:               wai-handler-hal-version:            0.2.0.0+version:            0.3.0.0 synopsis:           Wrap WAI applications to run on AWS Lambda description:   This library provides a function 'Network.Wai.Handler.Hal.run' to@@ -28,7 +28,13 @@   test/golden/WaiRequest.txt  tested-with:-  GHC ==8.6.5 || ==8.8.4 || ==8.10.7 || ==9.0.2 || ==9.2.2+  GHC ==8.6.5+   || ==8.8.4+   || ==8.10.7+   || ==9.0.2+   || ==9.2.4+   || ==9.4.5+   || ==9.6.2  common opts   default-language: Haskell2010@@ -41,14 +47,14 @@  common deps   build-depends:-    , base                  >=4.12     && <4.18-    , base64-bytestring     >=1.0.0.0  && <1.3-    , bytestring            >=0.10.8   && <0.12+    , base                  >=4.12      && <4.19+    , base64-bytestring     >=1.0.0.0   && <1.3+    , bytestring            >=0.10.8    && <0.12.1     , case-insensitive      ^>=1.2.0.0-    , hal                   >=0.4.7    && <0.4.11 || >=1.0.0 && <1.1+    , hal                   >=0.4.7     && <0.4.11 || >=1.0.0 && <1.1     , http-types            ^>=0.12.3-    , network               >=2.8.0.0  && <3.2-    , text                  ^>=1.2.3 || ^>=2.0+    , network               >=2.8.0.0   && <3.2+    , text                  ^>=1.2.3    || >=2.0   && <2.1.1     , unordered-containers  ^>=0.2.10.0     , vault                 ^>=0.3.1.0     , wai                   ^>=3.2.2@@ -67,10 +73,11 @@   other-modules:      Network.Wai.Handler.HalTest   build-tool-depends: tasty-discover:tasty-discover ^>=4.2.2   build-depends:-    , aeson            >=1.5.6.0 && <1.6 || >=2.0 && <2.2+    , aeson            >=1.5.6.0  && <1.6  || >=2.0 && <2.3     , pretty-simple    ^>=4.1.0.0-    , tasty            >=1.3     && <1.5+    , tasty            >=1.3      && <1.6     , tasty-golden     ^>=2.3+    , tasty-hunit      >=0.9      && <0.11     , text     , wai-handler-hal