Skip to content

Commit 95aea37

Browse files
committed
Refactor handlers to be separate from core logic (Kludex#170)
* Refactor handlers to be separate from core logic * Cleanup code for black, flake8, pypy * Cleanup imports * Move flake8 config to existing setup.cfg * Updated code style to adhere to 88 char limit
1 parent b39ef8d commit 95aea37

26 files changed

+2407
-835
lines changed

docs/asgi-frameworks.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ We can think about the ASGI framework support without referencing an existing im
1111
Let's invent an API for a non-existent microframework to demonstrate things further. This could represent *any* ASGI framework application:
1212

1313
```python
14+
import mangum.adapter
1415
import framework
15-
from mangum import Mangum
16+
from mangum import Mangum, Request
1617

1718
app = framework.applications.Application()
1819

1920

2021
@app.route("/")
21-
def endpoint(request: framework.requests.Request) -> dict:
22+
def endpoint(request: Request) -> dict:
2223
return {"hi": "there"}
2324

2425

docs/http.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
# HTTP
22

3-
Mangum provides support for both [REST](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) and the newer [HTTP](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) APIs in API Gateway. It also includes configurable binary response support.
4-
3+
Mangum provides support for the following AWS HTTP Lambda Event Source:
4+
5+
* [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html)
6+
([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html))
7+
* [HTTP Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html)
8+
([Event Examples](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html))
9+
* [Application Load Balancer (ALB)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html)
10+
([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html))
11+
* [CloudFront Lambda@Edge](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html)
12+
([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html))
13+
514
```python
615
from fastapi import FastAPI
716
from fastapi.middleware.gzip import GZipMiddleware

mangum/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
from .types import Request, Response
12
from .adapter import Mangum # noqa: F401
3+
4+
__all__ = ["Mangum", "Request", "Response"]

mangum/adapter.py

Lines changed: 33 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,31 @@
1-
import base64
2-
import typing
31
import logging
4-
import urllib.parse
5-
6-
from dataclasses import dataclass, InitVar
72
from contextlib import ExitStack
8-
9-
from mangum.types import ASGIApp, Scope
10-
from mangum.protocols.lifespan import LifespanCycle
11-
from mangum.protocols.http import HTTPCycle
12-
from mangum.exceptions import ConfigurationError
13-
14-
if typing.TYPE_CHECKING: # pragma: no cover
3+
from typing import (
4+
Any,
5+
ContextManager,
6+
Dict,
7+
TYPE_CHECKING,
8+
)
9+
10+
from .exceptions import ConfigurationError
11+
from .handlers import AbstractHandler
12+
from .protocols import HTTPCycle, LifespanCycle
13+
from .types import ASGIApp
14+
15+
if TYPE_CHECKING: # pragma: no cover
1516
from awslambdaric.lambda_context import LambdaContext
1617

1718
DEFAULT_TEXT_MIME_TYPES = [
19+
"text/",
1820
"application/json",
1921
"application/javascript",
2022
"application/xml",
2123
"application/vnd.api+json",
2224
]
2325

24-
LOG_LEVELS = {
25-
"critical": logging.CRITICAL,
26-
"error": logging.ERROR,
27-
"warning": logging.WARNING,
28-
"info": logging.INFO,
29-
"debug": logging.DEBUG,
30-
}
26+
logger = logging.getLogger("mangum")
3127

3228

33-
@dataclass
3429
class Mangum:
3530
"""
3631
Creates an adapter instance.
@@ -41,153 +36,40 @@ class Mangum:
4136
and `off`. Default is `auto`.
4237
* **log_level** - A string to configure the log level. Choices are: `info`,
4338
`critical`, `error`, `warning`, and `debug`. Default is `info`.
44-
* **api_gateway_base_path** - Base path to strip from URL when using a custom
45-
domain name.
4639
* **text_mime_types** - A list of MIME types to include with the defaults that
4740
should not return a binary response in API Gateway.
4841
"""
4942

5043
app: ASGIApp
5144
lifespan: str = "auto"
52-
log_level: str = "info"
53-
api_gateway_base_path: typing.Optional[str] = None
54-
text_mime_types: InitVar[typing.Optional[typing.List[str]]] = None
5545

56-
def __post_init__(self, text_mime_types: typing.Optional[typing.List[str]]) -> None:
46+
def __init__(
47+
self,
48+
app: ASGIApp,
49+
lifespan: str = "auto",
50+
**handler_kwargs: Dict[str, Any],
51+
):
52+
self.app = app
53+
self.lifespan = lifespan
54+
self.handler_kwargs = handler_kwargs
55+
5756
if self.lifespan not in ("auto", "on", "off"):
5857
raise ConfigurationError(
5958
"Invalid argument supplied for `lifespan`. Choices are: auto|on|off"
6059
)
6160

62-
if self.log_level not in ("critical", "error", "warning", "info", "debug"):
63-
raise ConfigurationError(
64-
"Invalid argument supplied for `log_level`. "
65-
"Choices are: critical|error|warning|info|debug"
66-
)
67-
68-
self.logger = logging.getLogger("mangum")
69-
self.logger.setLevel(LOG_LEVELS[self.log_level])
70-
71-
should_prefix_base_path = (
72-
self.api_gateway_base_path
73-
and not self.api_gateway_base_path.startswith("/")
74-
)
75-
if should_prefix_base_path:
76-
self.api_gateway_base_path = f"/{self.api_gateway_base_path}"
77-
78-
if text_mime_types:
79-
text_mime_types += DEFAULT_TEXT_MIME_TYPES
80-
else:
81-
text_mime_types = DEFAULT_TEXT_MIME_TYPES
82-
self.text_mime_types = text_mime_types
83-
8461
def __call__(self, event: dict, context: "LambdaContext") -> dict:
85-
self.logger.debug("Event received.")
62+
logger.debug("Event received.")
8663

8764
with ExitStack() as stack:
8865
if self.lifespan != "off":
89-
lifespan_cycle: typing.ContextManager = LifespanCycle(
90-
self.app, self.lifespan
91-
)
66+
lifespan_cycle: ContextManager = LifespanCycle(self.app, self.lifespan)
9267
stack.enter_context(lifespan_cycle)
9368

94-
is_binary = event.get("isBase64Encoded", False)
95-
initial_body = event.get("body") or b""
96-
if is_binary:
97-
initial_body = base64.b64decode(initial_body)
98-
elif not isinstance(initial_body, bytes):
99-
initial_body = initial_body.encode()
100-
101-
scope = self.create_scope(event, context)
102-
http_cycle = HTTPCycle(scope, text_mime_types=self.text_mime_types)
103-
response = http_cycle(self.app, initial_body)
104-
105-
return response
106-
107-
def create_scope(self, event: dict, context: "LambdaContext") -> Scope:
108-
"""
109-
Creates a scope object according to ASGI specification from a Lambda Event.
110-
111-
https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope
112-
113-
The event comes from various sources: AWS ALB, AWS API Gateway of different
114-
versions and configurations(multivalue header, etc).
115-
Thus, some heuristics is applied to guess an event type.
116-
117-
"""
118-
request_context = event["requestContext"]
119-
120-
if event.get("multiValueHeaders"):
121-
headers = {
122-
k.lower(): ", ".join(v) if isinstance(v, list) else ""
123-
for k, v in event.get("multiValueHeaders", {}).items()
124-
}
125-
elif event.get("headers"):
126-
headers = {k.lower(): v for k, v in event.get("headers", {}).items()}
127-
else:
128-
headers = {}
129-
130-
# API Gateway v2
131-
if event.get("version") == "2.0":
132-
source_ip = request_context["http"]["sourceIp"]
133-
path = request_context["http"]["path"]
134-
http_method = request_context["http"]["method"]
135-
query_string = event.get("rawQueryString", "").encode()
136-
137-
if event.get("cookies"):
138-
headers["cookie"] = "; ".join(event.get("cookies", []))
139-
140-
# API Gateway v1 / ELB
141-
else:
142-
if "elb" in request_context:
143-
# NOTE: trust only the most right side value
144-
source_ip = headers.get("x-forwarded-for", "").split(", ")[-1]
145-
else:
146-
source_ip = request_context.get("identity", {}).get("sourceIp")
147-
148-
path = event["path"]
149-
http_method = event["httpMethod"]
150-
151-
if event.get("multiValueQueryStringParameters"):
152-
query_string = urllib.parse.urlencode(
153-
event.get("multiValueQueryStringParameters", {}), doseq=True
154-
).encode()
155-
elif event.get("queryStringParameters"):
156-
query_string = urllib.parse.urlencode(
157-
event.get("queryStringParameters", {})
158-
).encode()
159-
else:
160-
query_string = b""
161-
162-
server_name = headers.get("host", "mangum")
163-
if ":" not in server_name:
164-
server_port = headers.get("x-forwarded-port", 80)
165-
else:
166-
server_name, server_port = server_name.split(":") # pragma: no cover
167-
server = (server_name, int(server_port))
168-
client = (source_ip, 0)
169-
170-
if not path: # pragma: no cover
171-
path = "/"
172-
elif self.api_gateway_base_path:
173-
if path.startswith(self.api_gateway_base_path):
174-
path = path[len(self.api_gateway_base_path) :]
175-
176-
scope = {
177-
"type": "http",
178-
"http_version": "1.1",
179-
"method": http_method,
180-
"headers": [[k.encode(), v.encode()] for k, v in headers.items()],
181-
"path": urllib.parse.unquote(path),
182-
"raw_path": None,
183-
"root_path": "",
184-
"scheme": headers.get("x-forwarded-proto", "https"),
185-
"query_string": query_string,
186-
"server": server,
187-
"client": client,
188-
"asgi": {"version": "3.0"},
189-
"aws.event": event,
190-
"aws.context": context,
191-
}
69+
handler = AbstractHandler.from_trigger(
70+
event, context, **self.handler_kwargs
71+
)
72+
http_cycle = HTTPCycle(handler.scope)
73+
response = http_cycle(self.app, handler.body)
19274

193-
return scope
75+
return handler.transform_response(response)

mangum/handlers/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .abstract_handler import AbstractHandler
2+
from .aws_alb import AwsAlb
3+
from .aws_api_gateway import AwsApiGateway
4+
from .aws_cf_lambda_at_edge import AwsCfLambdaAtEdge
5+
from .aws_http_gateway import AwsHttpGateway
6+
7+
__all__ = [
8+
"AbstractHandler",
9+
"AwsAlb",
10+
"AwsApiGateway",
11+
"AwsCfLambdaAtEdge",
12+
"AwsHttpGateway",
13+
]

0 commit comments

Comments
 (0)