Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/support api gateway proxy events #52

Merged
merged 52 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
3577a6d
Very WIP for supporting API Gateway Lambda Proxy Events. Basic struc…
IamfromSpace Jan 12, 2019
b07a646
Reflect the API Gateway Events at a very low level.
IamfromSpace Jan 15, 2019
8f80114
Add custom authorizer and correctly identify nullable fields.
IamfromSpace Jan 15, 2019
c2400ef
Use lazy text for ApiGatewayProxyRequest.
IamfromSpace Jan 17, 2019
33d313a
Unparameterize ApiGatewayProxyRequest, since it doesn't make much sen…
IamfromSpace Jan 17, 2019
9a78e23
WIP add a more idiomatic/typed api gateway event. In desperate need …
IamfromSpace Jan 17, 2019
19d0c03
Add an 'ApiGatewayRuntime' that is almost the same interface as the g…
IamfromSpace Jan 21, 2019
1de5e10
Experiment with a chaining/composable API based on profunctors.
IamfromSpace Jan 24, 2019
7dbf8b3
Use combinators for the entire ApiGatewayRuntime module.
IamfromSpace Jan 25, 2019
567f330
Use a default 400 so that the interfaces are almost identical between…
IamfromSpace Jan 25, 2019
928e694
Use Text instead of Bytestring when converting a raw API Gateway requ…
IamfromSpace Jan 25, 2019
9e153e2
Use combinators for the ApiGatewayRuntime.
IamfromSpace Feb 10, 2019
e523045
Fix an issue where the authorizer field was required, when it may be …
IamfromSpace Feb 12, 2019
1eb7c8e
Use a HashMap for api gateway response headers so they encode a JSON …
IamfromSpace Feb 12, 2019
3635921
Make the json decode for the ApiGatewayProxyRequest much more Haskell…
IamfromSpace Feb 18, 2019
c70eb35
Clean up warnings.
IamfromSpace Feb 18, 2019
dcc97d9
Decode an ApiGatewayProxyRequest body as a (contextually-correct) Byt…
IamfromSpace Feb 22, 2019
84c5a27
Use Text instead of lazy Text in most Api Gateway event properties.
IamfromSpace May 2, 2019
f880f62
Parse additional API Gateway Request Event fields.
IamfromSpace May 4, 2019
2497bb0
Use a LambdaSerializable class to help safely encode a variety of con…
IamfromSpace May 5, 2019
9dfe827
Export types that should have been available.
IamfromSpace May 7, 2019
dd7294d
Add metadata about encoding to LambdaSerializable and the resulting J…
IamfromSpace May 7, 2019
6fe29c8
Create an ApiGatewayProxyBody type instead using a typeclass strategy.
IamfromSpace May 10, 2019
66530e5
Use the HTTP status type instead of Ints for status codes.
IamfromSpace May 10, 2019
eaf8b33
Use maybes for unguaranteed fields (not present in sam local).
IamfromSpace May 17, 2019
e005b69
Re-export all http-types Statuses.
IamfromSpace May 17, 2019
6b34ef5
Update simple example to be up to date with API Gateway proxy events.…
IamfromSpace May 17, 2019
005895f
Remove authorizer information with a TODO to return it with a proper …
IamfromSpace May 19, 2019
1dbdd3a
Allow an arbitrary value for custom authorizor data, with that type a…
IamfromSpace May 19, 2019
c2161ce
Fix parsing for domainName, domainPrefix, and extendedRequestId (all …
IamfromSpace May 31, 2019
e42e35e
Add TODO.
IamfromSpace Jan 27, 2020
3a314c4
Styling
IamfromSpace Jan 27, 2020
c86a6a4
Move ApiGateway events into a more nested module to group them.
IamfromSpace Jan 27, 2020
a5b0d85
Add NoAuthorizer and StrictlyNoAuthorizer as helper types to correctl…
IamfromSpace Jan 27, 2020
a47beef
Add documentation strings.
IamfromSpace Jan 27, 2020
fe44c1c
Require that headers on the API Gateway proxy response be case insens…
IamfromSpace Jan 27, 2020
7e69a37
Allow API Gateway responses to include multi-value headers.
IamfromSpace Jan 27, 2020
98d943e
Restore simple example.
IamfromSpace Jan 27, 2020
1749a29
Remove diff noise.
IamfromSpace Jan 27, 2020
0011086
Set DuplicateRecordFields as a default extension.
IamfromSpace Feb 2, 2020
53a6540
Remove unneeded extensions.
IamfromSpace Feb 2, 2020
8d5060e
Fix copyright dates to earliest date of publish.
IamfromSpace Feb 2, 2020
3219d55
Use _only_ multiValueHeaders instead of headers, and supply smart con…
IamfromSpace Aug 1, 2020
65a235f
Move status exports to the bottom of the exports for better haddock r…
IamfromSpace Aug 1, 2020
fb1ce37
Move imageGif and imageJpeg into doc examples rather than exorts. Al…
IamfromSpace Aug 2, 2020
3884635
Fix incorrect doc reference.
IamfromSpace Aug 2, 2020
d8d6649
Remove redundant indentation.
IamfromSpace Aug 2, 2020
0402a92
Explicitly choose statuses for export to ensure nothing else gets exp…
IamfromSpace Aug 2, 2020
f9f2b12
Add module level documentation for ProxyRequest and ProxyResponse.
IamfromSpace Aug 2, 2020
e6b8adb
Use fold for defaulting Monoidal parsed fields.
IamfromSpace Sep 18, 2020
52fe784
Use withObject instead of pattern matching for parseJSON.
IamfromSpace Sep 18, 2020
6565b44
Force StrictlyNoAuthorizer to fail parsing in all cases where an auth…
IamfromSpace Sep 19, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ default-extensions:
- DeriveGeneric
- DeriveLift
- DeriveTraversable
- DuplicateRecordFields
- EmptyCase
- GeneralizedNewtypeDeriving
- InstanceSigs
Expand Down Expand Up @@ -60,5 +61,8 @@ library:
- exceptions
- mtl
- containers
- unordered-containers
- time
- text
- base64-bytestring
- case-insensitive
168 changes: 168 additions & 0 deletions src/AWS/Lambda/Events/ApiGateway/ProxyRequest.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
{-|
Module : AWS.Lambda.Events.ApiGateway.ProxyRequest
Description : Data types that represent typical lambda responses
Copyright : (c) Nike, Inc., 2019
License : BSD3
Maintainer : [email protected], [email protected]
Stability : stable

This module exposes types used to model incoming __proxy__ requests from AWS
API Gateway. These types are a light pass over the incoming JSON
representation.
-}
module AWS.Lambda.Events.ApiGateway.ProxyRequest
( ProxyRequest(..)
, RequestContext(..)
, Identity(..)
, NoAuthorizer
, StrictlyNoAuthorizer
) where

import Data.Aeson (FromJSON, Value, parseJSON,
withObject, (.:), (.:?))
import Data.ByteString.Base64.Lazy (decodeLenient)
import Data.ByteString.Lazy (ByteString)
import Data.CaseInsensitive (CI, mk)
import Data.Foldable (fold)
import Data.Functor ((<&>))
import Data.HashMap.Strict (HashMap, foldrWithKey, insert)
import Data.Text (Text)
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import Data.Void (Void)
import GHC.Generics (Generic (..))

data Identity = Identity
{ cognitoIdentityPoolId :: Maybe Text
, accountId :: Maybe Text
, cognitoIdentityId :: Maybe Text
, caller :: Maybe Text
, apiKey :: Maybe Text
, sourceIp :: Text
, accessKey :: Maybe Text
, cognitoAuthenticationType :: Maybe Text
, cognitoAuthenticationProvider :: Maybe Text
, userArn :: Maybe Text
, apiKeyId :: Maybe Text
, userAgent :: Maybe Text
, user :: Maybe Text
} deriving (Generic)

instance FromJSON Identity

data RequestContext a = RequestContext
{ path :: Text
, accountId :: Text
, authorizer :: Maybe a
, resourceId :: Text
, stage :: Text
, domainPrefix :: Maybe Text
, requestId :: Text
, identity :: Identity
, domainName :: Maybe Text
, resourcePath :: Text
, httpMethod :: Text
, extendedRequestId :: Maybe Text
, apiId :: Text
}

instance FromJSON a => FromJSON (RequestContext a) where
parseJSON = withObject "ProxyRequest" $ \v ->
RequestContext <$> v .: "path" <*> v .: "accountId" <*>
v .:? "authorizer" <*>
v .: "resourceId" <*>
v .: "stage" <*>
v .:? "domainPrefix" <*>
v .: "requestId" <*>
v .: "identity" <*>
v .:? "domainName" <*>
v .: "resourcePath" <*>
v .: "httpMethod" <*>
v .:? "extendedRequestId" <*>
v .: "apiId"

-- TODO: Should also include websocket fields
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a card here: #53

Other than adding the fields, is there anything else we need to support websockets from apig?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is a bit of an odd one. The more I think about it, the more I think that this should be a separate event that maybe has some common models (Identity ab RequestContext maybe). But ultimately it makes a bunch of fields optional if we try to do them together. Which makes sense for JSON, but less so here—where we want to strongly type and communicate guarantees wherever possible.

The downside of the separate is a bunch of boilerplate. The record field destructuring is super useful for these types, but it doesn’t seem to compose for reuse very nicely.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to chew on this one a bit more before responding more completely

-- | This type is for representing events that come from API Gateway via the
-- Lambda Proxy integration (forwarding HTTP data directly, rather than a
-- custom integration). It will automatically decode the event that comes in.
--
-- The 'ProxyRequest' notably has one parameter for the type of information
-- returned by the API Gateway's custom authorizer (if applicable). This type
-- must also implement FromJSON so that it can be decoded. If you do not
-- expect this data to be populated we recommended using the 'NoAuthorizer'
-- type exported from this module (which is just an alias for 'Value'). If
-- there _must not_ be authorizer populated (this is unlikely) then use the
-- 'StrictlyNoAuthorizer' type.
--
-- @
-- {-\# LANGUAGE NamedFieldPuns \#-}
-- {-\# LANGUAGE DuplicateRecordFields \#-}
--
-- module Main where
--
-- import AWS.Lambda.Runtime (pureRuntime)
-- import AWS.Lambda.Events.ApiGateway.ProxyRequest (ProxyRequest(..), NoAuthorizer)
-- import AWS.Lambda.Events.ApiGateway.ProxyResponse (ProxyResponse(..), textPlain, forbidden403, ok200)
--
-- myHandler :: ProxyRequest NoAuthorizer -> ProxyResponse
-- myHandler ProxyRequest { httpMethod = \"GET\", path = "/say_hello" } =
-- ProxyResponse
-- { status = ok200
-- , body = textPlain \"Hello\"
-- , headers = mempty
-- , multiValueHeaders = mempty
-- }
-- myHandler _ =
-- ProxyResponse
-- { status = forbidden403
-- , body = textPlain \"Forbidden\"
-- , headers = mempty
-- , multiValueHeaders = mempty
-- }
--
-- main :: IO ()
-- main = pureRuntime myHandler
-- @
data ProxyRequest a = ProxyRequest
{ path :: Text
, headers :: HashMap (CI Text) Text
, multiValueHeaders :: HashMap (CI Text) [Text]
, pathParameters :: HashMap Text Text
, stageVariables :: HashMap Text Text
, requestContext :: RequestContext a
, resource :: Text
, httpMethod :: Text
, queryStringParameters :: HashMap Text Text
, multiValueQueryStringParameters :: HashMap Text [Text]
, body :: ByteString
} deriving (Generic)

toCIHashMap :: HashMap Text a -> HashMap (CI Text) a
toCIHashMap = foldrWithKey (insert . mk) mempty

toByteString :: Bool -> TL.Text -> ByteString
toByteString isBase64Encoded =
if isBase64Encoded
then decodeLenient . TLE.encodeUtf8
else TLE.encodeUtf8

-- | For ignoring API Gateway custom authorizer values
type NoAuthorizer = Value

-- | For ensuring that there were no API Gateway custom authorizer values (this
-- is not likely to be useful, you probably want 'NoAuthorizer')
type StrictlyNoAuthorizer = Void

instance FromJSON a => FromJSON (ProxyRequest a) where
parseJSON = withObject "ProxyRequest" $ \v ->
ProxyRequest <$> v .: "path" <*>
(v .:? "headers" <&> toCIHashMap . fold) <*>
(v .:? "multiValueHeaders" <&> toCIHashMap . fold) <*>
(v .:? "pathParameters" <&> fold) <*>
(v .:? "stageVariables" <&> fold) <*>
v .: "requestContext" <*>
v .: "resource" <*>
v .: "httpMethod" <*>
(v .:? "queryStringParameters" <&> fold) <*>
(v .:? "multiValueQueryStringParameters" <&> fold) <*>
(toByteString <$> v .: "isBase64Encoded" <*> (v .:? "body" <&> fold))
209 changes: 209 additions & 0 deletions src/AWS/Lambda/Events/ApiGateway/ProxyResponse.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
{-|
Module : AWS.Lambda.Events.ApiGateway.ProxyResponse
Description : Data types that represent typical lambda responses
Copyright : (c) Nike, Inc., 2019
License : BSD3
Maintainer : [email protected], [email protected]
Stability : stable

This module enable exposes the required types for responding to API Gateway
Proxy Events. Responses must return a status, body, and optionaly headers.
Multiple smart contructors and helpers are provided to help encapsulated
details like header case-insensitivity, multiple header copies, correct base64
encoding, and default content type.
-}
module AWS.Lambda.Events.ApiGateway.ProxyResponse
( ProxyResponse(..)
, response
, addHeader
, setHeader
, ProxyBody(..)
, textPlain
, applicationJson
, genericBinary
, module Network.HTTP.Types.Status
) where

import Data.Aeson (ToJSON, encode, object, toJSON,
(.=))
import Data.ByteString (ByteString)
import qualified Data.ByteString.Base64 as B64
import Data.CaseInsensitive (CI, mk, original)
import Data.HashMap.Strict (HashMap, foldrWithKey, insert,
insertWith)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import Network.HTTP.Types.Status (Status (..), accepted202,
badGateway502, badRequest400,
conflict409, continue100,
created201, expectationFailed417,
forbidden403, found302,
gatewayTimeout504, gone410,
httpVersionNotSupported505,
imATeapot418,
internalServerError500,
lengthRequired411,
methodNotAllowed405,
movedPermanently301,
multipleChoices300,
networkAuthenticationRequired511,
noContent204, nonAuthoritative203,
notAcceptable406, notFound404,
notImplemented501, notModified304,
ok200, partialContent206,
paymentRequired402,
permanentRedirect308,
preconditionFailed412,
preconditionRequired428,
proxyAuthenticationRequired407,
requestEntityTooLarge413,
requestHeaderFieldsTooLarge431,
requestTimeout408,
requestURITooLong414,
requestedRangeNotSatisfiable416,
resetContent205, seeOther303,
serviceUnavailable503, status100,
status101, status200, status201,
status202, status203, status204,
status205, status206, status300,
status301, status302, status303,
status304, status305, status307,
status308, status400, status401,
status402, status403, status404,
status405, status406, status407,
status408, status409, status410,
status411, status412, status413,
status414, status415, status416,
status417, status418, status422,
status426, status428, status429,
status431, status500, status501,
status502, status503, status504,
status505, status511,
switchingProtocols101,
temporaryRedirect307,
tooManyRequests429, unauthorized401,
unprocessableEntity422,
unsupportedMediaType415,
upgradeRequired426, useProxy305)

-- | Type that represents the body returned to an API Gateway when using HTTP
-- Lambda Proxy integration. It is highly recommended that you do not use this
-- type directly, and instead use the smart constructors exposed such as
-- 'textPlain', 'applicationJson', and 'genericBinary'. These make sure that
-- the base64 encodings work transparently.
data ProxyBody = ProxyBody
{ contentType :: T.Text
, serialized :: T.Text
, isBase64Encoded :: Bool
} deriving (Show)

-- | A response returned to an API Gateway when using the HTTP Lambda Proxy
-- integration. ContentType will be set based on the ProxyBody (recommended)
-- if a value is not present in the headers field.
--
-- This type can be constructed explicity or via the smart constructor
-- `response`. Headers can then be added incrementally with `addHeader` or
-- `setHeader`. The smart constructor pattern is recommended because it avoids
-- some of the awkwardness of dealing with the multiValueHeaders field's type.
--
-- @
-- {-\# LANGUAGE NamedFieldPuns \#-}
-- {-\# LANGUAGE DuplicateRecordFields \#-}
-- {-\# LANGUAGE OverloadedStrings \#-}
--
-- module Main where
--
-- import AWS.Lambda.Runtime (pureRuntime)
-- import AWS.Lambda.Events.ApiGateway.ProxyRequest (ProxyRequest(..), NoAuthorizer)
-- import AWS.Lambda.Events.ApiGateway.ProxyResponse (ProxyResponse(..), textPlain, forbidden403, ok200, response)
--
-- myHandler :: ProxyRequest NoAuthorizer -> ProxyResponse
-- myHandler ProxyRequest { httpMethod = \"GET\", path = "/say_hello" } =
-- -- Smart Constructor and added header (recommended)
-- addHeader "My-Custom-Header" "Value" $
-- response ok200 $ textPlain \"Hello\"
-- myHandler _ =
-- -- Explicit Construction (not recommended)
-- ProxyResponse
-- { status = forbidden403
-- , body = textPlain \"Forbidden\"
-- , multiValueHeaders =
-- fromList [(mk "My-Custom-Header", ["Other Value])]
-- }
--
-- main :: IO ()
-- main = pureRuntime myHandler
-- @
data ProxyResponse = ProxyResponse
{ status :: Status
, multiValueHeaders :: HashMap (CI T.Text) [T.Text]
, body :: ProxyBody
} deriving (Show)

-- | Smart constructor for creating a ProxyResponse from a status and a body
response :: Status -> ProxyBody -> ProxyResponse
response =
flip ProxyResponse mempty

-- | Add a header to the ProxyResponse. If there was already a value for this
-- header, this one is __added__, meaning the response will include multiple
-- copies of this header (valid by the HTTP spec). This does NOT replace any
-- previous headers or their values.
addHeader :: T.Text -> T.Text -> ProxyResponse -> ProxyResponse
addHeader header value (ProxyResponse s mvh b) =
ProxyResponse s (insertWith (<>) (mk header) [value] mvh) b

-- | Set a header to the ProxyResponse. If there were any previous values for
-- this header they are __all replaced__ by this new value.
setHeader :: T.Text -> T.Text -> ProxyResponse -> ProxyResponse
setHeader header value (ProxyResponse s mvh b) =
ProxyResponse s (insert (mk header) [value] mvh) b

-- | Smart constructor for creating a ProxyBody with an arbitrary ByteString of
-- the chosen content type. Use this smart constructor to avoid invalid JSON
-- representations of binary data.
--
-- From here it is easy to make more specific body constructors:
--
-- @
-- imageGif :: ByteString -> ProxyBody
-- imageGif = genericBinary "image/gif"
--
-- imageJpeg :: ByteString -> ProxyBody
-- imageJpeg = genericBinary "image/jpeg"
-- @
genericBinary :: T.Text -> ByteString -> ProxyBody
genericBinary contentType x =
ProxyBody contentType (TE.decodeUtf8 $ B64.encode x) True

-- | Smart constructor for creating a simple body of text.
textPlain :: T.Text -> ProxyBody
textPlain x = ProxyBody "text/plain; charset=utf-8" x False

-- | Smart constructor for creating a simple body of JSON.
applicationJson :: ToJSON a => a -> ProxyBody
applicationJson x =
ProxyBody
"application/json; charset=utf-8"
(TL.toStrict $ TLE.decodeUtf8 $ encode x)
False

-- | Smart constructor for creating a simple body of a GIF (that has already
IamfromSpace marked this conversation as resolved.
Show resolved Hide resolved
-- been converted to a ByteString).

instance ToJSON ProxyResponse where
toJSON (ProxyResponse status mvh (ProxyBody contentType body isBase64Encoded)) =
let unCI = foldrWithKey (insert . original) mempty
in object
[ "statusCode" .= statusCode status
, "multiValueHeaders" .=
insertWith
(\_ old -> old)
("Content-Type" :: T.Text)
[contentType]
(unCI mvh)
, "body" .= body
, "isBase64Encoded" .= isBase64Encoded
]