12
12
13
13
14
14
class ProxyEventType (Enum ):
15
+ """An enumerations of the supported proxy event types.
16
+
17
+ **NOTE:** api_gateway is an alias of http_api_v1"""
18
+
15
19
http_api_v1 = "APIGatewayProxyEvent"
16
20
http_api_v2 = "APIGatewayProxyEventV2"
17
21
alb_event = "ALBEvent"
18
22
api_gateway = http_api_v1
19
23
20
24
21
25
class CORSConfig (object ):
22
- """CORS Config"""
26
+ """CORS Config
27
+
28
+
29
+ Examples
30
+ --------
31
+
32
+ Simple cors example using the default permissive cors, not this should only be used during early prototyping
33
+
34
+ >>> from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver
35
+ >>>
36
+ >>> app = ApiGatewayResolver()
37
+ >>>
38
+ >>> @app.get("/my/path", cors=True)
39
+ >>> def with_cors():
40
+ >>> return {"message": "Foo"}
41
+
42
+ Using a custom CORSConfig where `with_cors` used the custom provided CORSConfig and `without_cors`
43
+ do not include any cors headers.
44
+
45
+ >>> from aws_lambda_powertools.event_handler.api_gateway import (
46
+ >>> ApiGatewayResolver, CORSConfig
47
+ >>> )
48
+ >>>
49
+ >>> cors_config = CORSConfig(
50
+ >>> allow_origin="https://wwww.example.com/",
51
+ >>> expose_headers=["x-exposed-response-header"],
52
+ >>> allow_headers=["x-custom-request-header"],
53
+ >>> max_age=100,
54
+ >>> allow_credentials=True,
55
+ >>> )
56
+ >>> app = ApiGatewayResolver(cors=cors_config)
57
+ >>>
58
+ >>> @app.get("/my/path", cors=True)
59
+ >>> def with_cors():
60
+ >>> return {"message": "Foo"}
61
+ >>>
62
+ >>> @app.get("/another-one")
63
+ >>> def without_cors():
64
+ >>> return {"message": "Foo"}
65
+ """
23
66
24
67
_REQUIRED_HEADERS = ["Authorization" , "Content-Type" , "X-Amz-Date" , "X-Api-Key" , "X-Amz-Security-Token" ]
25
68
@@ -55,6 +98,7 @@ def __init__(
55
98
self .allow_credentials = allow_credentials
56
99
57
100
def to_dict (self ) -> Dict [str , str ]:
101
+ """Builds the configured Access-Control http headers"""
58
102
headers = {
59
103
"Access-Control-Allow-Origin" : self .allow_origin ,
60
104
"Access-Control-Allow-Headers" : "," .join (sorted (self .allow_headers )),
@@ -68,7 +112,37 @@ def to_dict(self) -> Dict[str, str]:
68
112
return headers
69
113
70
114
115
+ class Response :
116
+ """Response data class that provides greater control over what is returned from the proxy event"""
117
+
118
+ def __init__ (
119
+ self , status_code : int , content_type : Optional [str ], body : Union [str , bytes , None ], headers : Dict = None
120
+ ):
121
+ """
122
+
123
+ Parameters
124
+ ----------
125
+ status_code: int
126
+ Http status code, example 200
127
+ content_type: str
128
+ Optionally set the Content-Type header, example "application/json". Note this will be merged into any
129
+ provided http headers
130
+ body: Union[str, bytes, None]
131
+ Optionally set the response body. Note: bytes body will be automatically base64 encoded
132
+ headers: dict
133
+ Optionally set specific http headers. Setting "Content-Type" hear would override the `content_type` value.
134
+ """
135
+ self .status_code = status_code
136
+ self .body = body
137
+ self .base64_encoded = False
138
+ self .headers : Dict = headers or {}
139
+ if content_type :
140
+ self .headers .setdefault ("Content-Type" , content_type )
141
+
142
+
71
143
class Route :
144
+ """Internally used Route Configuration"""
145
+
72
146
def __init__ (
73
147
self , method : str , rule : Any , func : Callable , cors : bool , compress : bool , cache_control : Optional [str ]
74
148
):
@@ -80,68 +154,125 @@ def __init__(
80
154
self .cache_control = cache_control
81
155
82
156
83
- class Response :
84
- def __init__ (
85
- self , status_code : int , content_type : Optional [str ], body : Union [str , bytes , None ], headers : Dict = None
86
- ):
87
- self .status_code = status_code
88
- self .body = body
89
- self .base64_encoded = False
90
- self .headers : Dict = headers or {}
91
- if content_type :
92
- self .headers .setdefault ("Content-Type" , content_type )
157
+ class ResponseBuilder :
158
+ """Internally used Response builder"""
93
159
94
- def add_cors (self , cors : CORSConfig ):
95
- self .headers .update (cors .to_dict ())
160
+ def __init__ (self , response : Response , route : Route = None ):
161
+ self .response = response
162
+ self .route = route
96
163
97
- def add_cache_control (self , cache_control : str ):
98
- self .headers ["Cache-Control" ] = cache_control if self .status_code == 200 else "no-cache"
164
+ def _add_cors (self , cors : CORSConfig ):
165
+ """Update headers to include the configured Access-Control headers"""
166
+ self .response .headers .update (cors .to_dict ())
99
167
100
- def compress (self ):
101
- self .headers ["Content-Encoding" ] = "gzip"
102
- if isinstance (self .body , str ):
103
- self .body = bytes (self .body , "utf-8" )
104
- gzip = zlib .compressobj (9 , zlib .DEFLATED , zlib .MAX_WBITS | 16 )
105
- self .body = gzip .compress (self .body ) + gzip .flush ()
168
+ def _add_cache_control (self , cache_control : str ):
169
+ """Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used."""
170
+ self .response .headers ["Cache-Control" ] = cache_control if self .response .status_code == 200 else "no-cache"
106
171
107
- def to_dict (self ) -> Dict [str , Any ]:
108
- if isinstance (self .body , bytes ):
109
- self .base64_encoded = True
110
- self .body = base64 .b64encode (self .body ).decode ()
172
+ def _compress (self ):
173
+ """Compress the response body, but only if `Accept-Encoding` headers includes gzip."""
174
+ self .response .headers ["Content-Encoding" ] = "gzip"
175
+ if isinstance (self .response .body , str ):
176
+ self .response .body = bytes (self .response .body , "utf-8" )
177
+ gzip = zlib .compressobj (9 , zlib .DEFLATED , zlib .MAX_WBITS | 16 )
178
+ self .response .body = gzip .compress (self .response .body ) + gzip .flush ()
179
+
180
+ def _route (self , event : BaseProxyEvent , cors : Optional [CORSConfig ]):
181
+ """Optionally handle any of the route's configure response handling"""
182
+ if self .route is None :
183
+ return
184
+ if self .route .cors :
185
+ self ._add_cors (cors or CORSConfig ())
186
+ if self .route .cache_control :
187
+ self ._add_cache_control (self .route .cache_control )
188
+ if self .route .compress and "gzip" in (event .get_header_value ("accept-encoding" , "" ) or "" ):
189
+ self ._compress ()
190
+
191
+ def build (self , event : BaseProxyEvent , cors : CORSConfig = None ) -> Dict [str , Any ]:
192
+ """Build the full response dict to be returned by the lambda"""
193
+ self ._route (event , cors )
194
+
195
+ if isinstance (self .response .body , bytes ):
196
+ self .response .base64_encoded = True
197
+ self .response .body = base64 .b64encode (self .response .body ).decode ()
111
198
return {
112
- "statusCode" : self .status_code ,
113
- "headers" : self .headers ,
114
- "body" : self .body ,
115
- "isBase64Encoded" : self .base64_encoded ,
199
+ "statusCode" : self .response . status_code ,
200
+ "headers" : self .response . headers ,
201
+ "body" : self .response . body ,
202
+ "isBase64Encoded" : self .response . base64_encoded ,
116
203
}
117
204
118
205
119
206
class ApiGatewayResolver :
207
+ """API Gateway and ALB proxy resolver
208
+
209
+ Examples
210
+ --------
211
+ Simple example with a custom lambda handler using the Tracer capture_lambda_handler decorator
212
+
213
+ >>> from aws_lambda_powertools import Tracer
214
+ >>> from aws_lambda_powertools.event_handler.api_gateway import (
215
+ >>> ApiGatewayResolver
216
+ >>> )
217
+ >>>
218
+ >>> tracer = Tracer()
219
+ >>> app = ApiGatewayResolver()
220
+ >>>
221
+ >>> @app.get("/get-call")
222
+ >>> def simple_get():
223
+ >>> return {"message": "Foo"}
224
+ >>>
225
+ >>> @app.post("/post-call")
226
+ >>> def simple_post():
227
+ >>> post_data: dict = app.current_event.json_body
228
+ >>> return {"message": post_data["value"]}
229
+ >>>
230
+ >>> @tracer.capture_lambda_handler
231
+ >>> def lambda_handler(event, context):
232
+ >>> return app.resolve(event, context)
233
+
234
+ """
235
+
120
236
current_event : BaseProxyEvent
121
237
lambda_context : LambdaContext
122
238
123
239
def __init__ (self , proxy_type : Enum = ProxyEventType .http_api_v1 , cors : CORSConfig = None ):
240
+ """
241
+ Parameters
242
+ ----------
243
+ proxy_type: ProxyEventType
244
+ Proxy request type, defaults to API Gateway V1
245
+ cors: CORSConfig
246
+ Optionally configure and enabled CORS. Not each route will need to have to cors=True
247
+ """
124
248
self ._proxy_type = proxy_type
125
249
self ._routes : List [Route ] = []
126
250
self ._cors = cors
127
251
self ._cors_methods : Set [str ] = {"OPTIONS" }
128
252
129
253
def get (self , rule : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
254
+ """Get route decorator with GET `method`"""
130
255
return self .route (rule , "GET" , cors , compress , cache_control )
131
256
132
257
def post (self , rule : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
258
+ """Post route decorator with POST `method`"""
133
259
return self .route (rule , "POST" , cors , compress , cache_control )
134
260
135
261
def put (self , rule : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
262
+ """Put route decorator with PUT `method`"""
136
263
return self .route (rule , "PUT" , cors , compress , cache_control )
137
264
138
265
def delete (self , rule : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
266
+ """Delete route decorator with DELETE `method`"""
139
267
return self .route (rule , "DELETE" , cors , compress , cache_control )
140
268
141
269
def patch (self , rule : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
270
+ """Patch route decorator with PATCH `method`"""
142
271
return self .route (rule , "PATCH" , cors , compress , cache_control )
143
272
144
273
def route (self , rule : str , method : str , cors : bool = False , compress : bool = False , cache_control : str = None ):
274
+ """Route decorator includes parameter `method`"""
275
+
145
276
def register_resolver (func : Callable ):
146
277
self ._routes .append (Route (method , self ._compile_regex (rule ), func , cors , compress , cache_control ))
147
278
if cors :
@@ -151,60 +282,87 @@ def register_resolver(func: Callable):
151
282
return register_resolver
152
283
153
284
def resolve (self , event , context ) -> Dict [str , Any ]:
154
- self .current_event = self ._to_data_class (event )
155
- self .lambda_context = context
156
- route , response = self ._find_route (self .current_event .http_method .upper (), self .current_event .path )
157
- if route is None : # No matching route was found
158
- return response .to_dict ()
285
+ """Resolves the response based on the provide event and decorator routes
159
286
160
- if route .cors :
161
- response .add_cors (self ._cors or CORSConfig ())
162
- if route .cache_control :
163
- response .add_cache_control (route .cache_control )
164
- if route .compress and "gzip" in (self .current_event .get_header_value ("accept-encoding" ) or "" ):
165
- response .compress ()
287
+ Parameters
288
+ ----------
289
+ event: Dict[str, Any]
290
+ Event
291
+ context: LambdaContext
292
+ Lambda context
293
+ Returns
294
+ -------
295
+ dict
296
+ Returns the dict response
297
+ """
298
+ self .current_event = self ._to_proxy_event (event )
299
+ self .lambda_context = context
300
+ return self ._resolve ().build (self .current_event , self ._cors )
166
301
167
- return response .to_dict ()
302
+ def __call__ (self , event , context ) -> Any :
303
+ return self .resolve (event , context )
168
304
169
305
@staticmethod
170
306
def _compile_regex (rule : str ):
307
+ """Precompile regex pattern"""
171
308
rule_regex : str = re .sub (r"(<\w+>)" , r"(?P\1.+)" , rule )
172
309
return re .compile ("^{}$" .format (rule_regex ))
173
310
174
- def _to_data_class (self , event : Dict ) -> BaseProxyEvent :
311
+ def _to_proxy_event (self , event : Dict ) -> BaseProxyEvent :
312
+ """Convert the event dict to the corresponding data class"""
175
313
if self ._proxy_type == ProxyEventType .http_api_v1 :
176
314
return APIGatewayProxyEvent (event )
177
315
if self ._proxy_type == ProxyEventType .http_api_v2 :
178
316
return APIGatewayProxyEventV2 (event )
179
317
return ALBEvent (event )
180
318
181
- def _find_route (self , method : str , path : str ) -> Tuple [Optional [Route ], Response ]:
319
+ def _resolve (self ) -> ResponseBuilder :
320
+ """Resolves the response or return the not found response"""
321
+ method = self .current_event .http_method .upper ()
322
+ path = self .current_event .path
182
323
for route in self ._routes :
183
324
if method != route .method :
184
325
continue
185
326
match : Optional [re .Match ] = route .rule .match (path )
186
327
if match :
187
328
return self ._call_route (route , match .groupdict ())
188
329
330
+ return self ._not_found (method , path )
331
+
332
+ def _not_found (self , method : str , path : str ) -> ResponseBuilder :
333
+ """Called when no matching route was found and includes support for the cors preflight response"""
189
334
headers = {}
190
335
if self ._cors :
191
336
headers .update (self ._cors .to_dict ())
337
+
192
338
if method == "OPTIONS" : # Preflight
193
339
headers ["Access-Control-Allow-Methods" ] = "," .join (sorted (self ._cors_methods ))
194
- return None , Response (status_code = 204 , content_type = None , body = None , headers = headers )
340
+ return ResponseBuilder ( Response (status_code = 204 , content_type = None , headers = headers , body = None ) )
195
341
196
- return None , Response (
197
- status_code = 404 ,
198
- content_type = "application/json" ,
199
- body = json .dumps ({"message" : f"No route found for '{ method } .{ path } '" }),
200
- headers = headers ,
342
+ return ResponseBuilder (
343
+ Response (
344
+ status_code = 404 ,
345
+ content_type = "application/json" ,
346
+ headers = headers ,
347
+ body = json .dumps ({"message" : f"No route found for '{ method } .{ path } '" }),
348
+ )
201
349
)
202
350
203
- def _call_route (self , route : Route , args : Dict [str , str ]) -> Tuple [Route , Response ]:
204
- return route , self ._to_response (route .func (** args ))
351
+ def _call_route (self , route : Route , args : Dict [str , str ]) -> ResponseBuilder :
352
+ """Actually call the matching route with any provided keyword arguments."""
353
+ return ResponseBuilder (self ._to_response (route .func (** args )), route )
205
354
206
355
@staticmethod
207
356
def _to_response (result : Union [Tuple [int , str , Union [bytes , str ]], Dict , Response ]) -> Response :
357
+ """Convert the route's result to a Response
358
+
359
+ 3 main result types are supported:
360
+
361
+ - Tuple[int, str, bytes] and Tuple[int, str, str]: status code, content-type and body (str|bytes)
362
+ - Dict[str, Any]: Rest api response with just the Dict to json stringify and content-type is set to
363
+ application/json
364
+ - Response: returned as is, and allows for more flexibility
365
+ """
208
366
if isinstance (result , Response ):
209
367
return result
210
368
elif isinstance (result , dict ):
@@ -215,6 +373,3 @@ def _to_response(result: Union[Tuple[int, str, Union[bytes, str]], Dict, Respons
215
373
)
216
374
else : # Tuple[int, str, Union[bytes, str]]
217
375
return Response (* result )
218
-
219
- def __call__ (self , event , context ) -> Any :
220
- return self .resolve (event , context )
0 commit comments