Skip to content

Commit 83dffea

Browse files
authored
Merge pull request #7 from odd12258053/support_http_api_in_aws
Support API Gateway HTTP API ver1.0 and ver2.0 in AWS
2 parents e47f926 + 2bd31f4 commit 83dffea

17 files changed

Lines changed: 262 additions & 96 deletions

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Install Flit
1818
run: pip install flit
1919
- name: Install Dependencies
20-
run: flit install --symlink
20+
run: flit install --symlink --deps production
2121
- name: Publish
2222
env:
2323
FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }}

Makefile

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
TARGETS := agraffe
22

3-
ISORT_OPT := -m3 --tc --py 37
4-
BLACK_OPT := -t py37 --skip-string-normalization
5-
FLAKE8_OPT := --max-line-length 88
6-
MYPY_OPT := --config-file mypy.ini
7-
83
format:
9-
isort ${ISORT_OPT} ${TARGETS}
10-
black ${BLACK_OPT} ${TARGETS}
4+
isort ${TARGETS}
5+
black ${TARGETS}
116

127
lint:
13-
isort --check-only ${ISORT_OPT} ${TARGETS}
14-
black --check ${BLACK_OPT} ${TARGETS}
15-
flake8 ${FLAKE8_OPT} ${TARGETS}
16-
mypy ${MYPY_OPT} ${TARGETS}
8+
isort --check-only ${TARGETS}
9+
black --check ${TARGETS}
10+
flake8 --config format.ini ${TARGETS}
11+
mypy --config-file format.ini ${TARGETS}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Agraffe, build API with ASGI in Serverless services (e.g AWS lambda, Google Clou
55

66
## Support Services
77
- [x] Google Cloud Functions
8-
- [x] AWS lambda (with API Gateway REST API)
8+
- [x] AWS lambda (with API Gateway HTTP API or REST API)
99
- [ ] Azure Functions
1010

1111
## Requirements

agraffe/__init__.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
""" Agraffe, build API with ASGI in Serverless services (e.g AWS lambda, Google Cloud Functions). """
1+
""" Agraffe, build API with ASGI in Serverless services (e.g AWS lambda, Google Cloud Functions). """ # noqa: E501
22

3-
__version__ = "0.2.0"
3+
__version__ = "0.3.0"
44

55
import asyncio
66
from enum import Enum
@@ -15,27 +15,14 @@ class Service(str, Enum):
1515

1616

1717
class Agraffe:
18-
_HttpCycle: Type[ASGICycle]
19-
20-
def __init__(self, app: ASGIApp, service: Union[str, Service]):
21-
if service == Service.google_cloud_functions:
22-
from .services import google_cloud_functions
23-
24-
self._HttpCycle = google_cloud_functions.HttpCycle
25-
elif service == Service.aws_lambda:
26-
from .services import aws_lambda
27-
28-
self._HttpCycle = aws_lambda.HttpCycle
29-
else:
30-
service = ', '.join(map(lambda x: x.value, Service))
31-
raise ValueError(f'Please set service either {service}.')
32-
18+
def __init__(self, app: ASGIApp, http_cycle: Type[ASGICycle]):
3319
self.app = app
20+
self._http_cycle = http_cycle
3421
loop = asyncio.new_event_loop()
3522
asyncio.set_event_loop(loop)
3623

37-
def __call__(self, request: Any) -> Response:
38-
cycle = self._HttpCycle(request)
24+
def __call__(self, request: Any) -> Any:
25+
cycle = self._http_cycle(request)
3926
cycle(app=self.app)
4027
return cycle.response
4128

@@ -44,24 +31,20 @@ def entry_point(
4431
cls, app: ASGIApp, service: Union[str, Service]
4532
) -> Callable[..., Any]:
4633
if service == Service.google_cloud_functions:
34+
from .services.google_cloud_functions import HttpCycle as GCPHttpCycle
4735

4836
def _entry_point4gcf(request: Any) -> Response:
49-
return cls(app, service)(request=request)
37+
return cls(app, GCPHttpCycle)(request=request)
5038

5139
return _entry_point4gcf
5240

5341
elif service == Service.aws_lambda:
42+
from .services.aws_lambda import HttpCycle as AWSHttpCycle
5443

5544
def _entry_point4aws_lambda(event: Any, context: Any) -> Any:
56-
body, status_code, headers = cls(app, service)(
45+
return cls(app, AWSHttpCycle)(
5746
request={'event': event, 'context': context}
5847
)
59-
return {
60-
'statusCode': status_code,
61-
'headers': dict(headers),
62-
'body': body,
63-
'isBase64Encoded': True,
64-
}
6548

6649
return _entry_point4aws_lambda
6750
else:

agraffe/services/aws_lambda.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from base64 import b64decode, b64encode
12
from typing import Any, Dict, Iterator
23
from urllib.parse import urlencode
34

@@ -6,41 +7,100 @@
67

78
Request = Dict[str, Dict[str, Any]]
89

10+
Response = Dict[str, Any]
911

10-
class HttpCycle(HttpCycleBase[Request]):
12+
13+
class HttpCycle(HttpCycleBase[Request, Response]):
1114
@property
1215
def scope(self) -> Scope:
1316
event = self.request['event']
1417

1518
def gene_query_string() -> Iterator[str]:
16-
params = event['multiValueQueryStringParameters'] or {}
17-
for key, values in params.items():
18-
for vale in values:
19-
yield urlencode({key: vale})
19+
if 'multiValueQueryStringParameters' in event:
20+
params = event['multiValueQueryStringParameters'] or {}
21+
for key, values in params.items():
22+
for vale in values:
23+
yield urlencode({key: vale})
24+
elif 'queryStringParameters' in event:
25+
params = event['queryStringParameters'] or {}
26+
for key, values in params.items():
27+
for vale in values.split(','):
28+
yield urlencode({key: vale})
29+
return
2030

2131
query_string = '&'.join(gene_query_string()).encode()
2232

33+
if 'httpMethod' in event:
34+
method = event['httpMethod']
35+
else:
36+
method = event['requestContext']['http']['method']
37+
38+
if 'path' in event:
39+
path = event['path']
40+
else:
41+
path = event['requestContext']['http']['path']
42+
43+
if 'multiValueHeaders' in event:
44+
headers = tuple(
45+
(k.lower().encode('latin-1'), (','.join(vs)).encode('latin-1'))
46+
for k, vs in event['multiValueHeaders'].items()
47+
)
48+
else:
49+
headers = tuple(
50+
(k.lower().encode('latin-1'), v.encode('latin-1'))
51+
for k, v in event['headers'].items()
52+
)
53+
54+
if 'cookies' in event:
55+
cookies = ';'.join(event['cookies'])
56+
headers = headers + (
57+
('cookie'.encode('latin-1'), cookies.encode('latin-1')),
58+
)
59+
2360
return {
2461
'type': 'http',
2562
'asgi': {'version': '3.0'},
2663
'http_version': '1.1',
27-
'method': event['httpMethod'],
64+
'method': method,
2865
'scheme': 'http',
29-
'path': event['path'],
66+
'path': path,
3067
'query_string': query_string,
3168
'root_path': '',
32-
'headers': tuple(
33-
(k.lower().encode('latin-1'), v.encode('latin-1'))
34-
for k, v in event['headers'].items()
35-
),
69+
'headers': headers,
3670
'server': None,
3771
'client': None,
3872
}
3973

4074
async def receive(self) -> Message:
4175
event = self.request['event']
76+
body = event.get('body', '')
77+
78+
if event.get('isBase64Encoded', False):
79+
body = b64decode(body)
80+
else:
81+
body = body.encode()
4282
return {
4383
'type': 'http.request',
44-
'body': event.get('body', '').encode(),
84+
'body': body,
4585
'more_body': False,
4686
}
87+
88+
@property
89+
def response(self) -> Response:
90+
event = self.request['event']
91+
if 'version' in event:
92+
is_base64_encoded = True
93+
body = b64encode(self.body).decode()
94+
else:
95+
is_base64_encoded = False
96+
try:
97+
body = self.body.decode()
98+
except UnicodeDecodeError:
99+
is_base64_encoded = True
100+
body = b64encode(self.body).decode()
101+
return {
102+
'statusCode': self.status_code,
103+
'headers': dict(self.headers),
104+
'body': body,
105+
'isBase64Encoded': is_base64_encoded,
106+
}

agraffe/services/base.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import asyncio
22
from typing import Generic, Iterable, Tuple, TypeVar
33

4-
from agraffe.types import ASGIApp, Message, Response, Scope
4+
from agraffe.types import ASGIApp, Message, Scope
55

6-
T = TypeVar('T')
6+
Req = TypeVar('Req')
7+
Res = TypeVar('Res')
78

89

9-
class HttpCycleBase(Generic[T]):
10-
def __init__(self, request: T):
10+
class HttpCycleBase(Generic[Req, Res]):
11+
def __init__(self, request: Req):
1112
self.request = request
1213
self.status_code = 200
1314
self.headers: Iterable[Tuple[str, str]] = ()
@@ -20,8 +21,8 @@ def __call__(self, app: ASGIApp) -> None:
2021
loop.run_until_complete(task)
2122

2223
@property
23-
def response(self) -> Response:
24-
return self.body, self.status_code, self.headers
24+
def response(self) -> Res:
25+
raise NotImplementedError
2526

2627
async def send(self, message: Message) -> None:
2728
if message['type'] == 'http.response.start':

agraffe/services/google_cloud_functions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ def get_data(self, cache: bool, as_text: bool, parse_form_data: bool) -> bytes:
1818
...
1919

2020

21-
class HttpCycle(HttpCycleBase[Request]):
21+
Response = Tuple[bytes, int, Iterable[Tuple[str, str]]]
22+
23+
24+
class HttpCycle(HttpCycleBase[Request, Response]):
2225
@property
2326
def scope(self) -> Scope:
2427
return {
@@ -51,3 +54,7 @@ async def receive(self) -> Message:
5154
or b'',
5255
'more_body': False,
5356
}
57+
58+
@property
59+
def response(self) -> Response:
60+
return self.body, self.status_code, self.headers

agraffe/types.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
from typing import Any, Awaitable, Callable, Iterable, MutableMapping, Tuple
1+
from typing import Any, Awaitable, Callable, MutableMapping
22

33
from typing_extensions import Protocol
44

55
Message = MutableMapping[str, Any]
66
Scope = MutableMapping[str, Any]
77
Receive = Callable[[], Awaitable[Message]]
88
Send = Callable[[Message], Awaitable[None]]
9-
Response = Tuple[bytes, int, Iterable[Tuple[str, str]]]
9+
Request = Any
10+
Response = Any
1011

1112

1213
class ASGIApp(Protocol):
@@ -15,7 +16,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
1516

1617

1718
class ASGICycle(Protocol):
18-
def __init__(self, request: Any) -> None:
19+
def __init__(self, request: Request) -> None:
1920
...
2021

2122
def __call__(self, app: ASGIApp) -> None:

example/.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
node_modules/
22
agraffe/
33
.serverless/
4-
serverless_http.yml
54
package-lock.json

example/app.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Optional
2+
3+
from fastapi import Cookie, FastAPI, Header, HTTPException
4+
from fastapi.responses import PlainTextResponse, Response
5+
6+
from models import Item
7+
8+
9+
app = FastAPI()
10+
11+
12+
@app.get('/')
13+
def root():
14+
return {'Hello': 'World'}
15+
16+
17+
@app.get('/empty')
18+
def empty():
19+
return {}
20+
21+
22+
@app.get('/empty/text')
23+
def empty_text():
24+
return ''
25+
26+
27+
@app.get('/none')
28+
def none():
29+
return None
30+
31+
32+
@app.get('/items/{item_id}')
33+
def read_item(item_id: int, q: Optional[str] = None):
34+
return {'item_id': item_id, 'q': q}
35+
36+
37+
@app.post('/items')
38+
def post_item(item: Item, authorization: Optional[str] = Header(None)):
39+
if authorization is None or authorization != 'Bearer foobar':
40+
raise HTTPException(status_code=401)
41+
return item
42+
43+
44+
@app.get('/cookies')
45+
def cookies(c1: Optional[str] = Cookie(None), c2: Optional[str] = Cookie(None)):
46+
return {
47+
'c1': c1,
48+
'c2': c2,
49+
}
50+
51+
52+
@app.get('/text')
53+
def text():
54+
return PlainTextResponse('test message!')
55+
56+
57+
@app.get('/image')
58+
def image():
59+
return Response(
60+
content=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x0c\x00\x00\x00\x0c\x08\x02\x00\x00\x00\xd9\x17\xcb\xb0\x00\x00\x00\x16IDATx\x9ccLIIa \x04\x98\x08\xaa\x18U4\x00\x8a\x00\x1c\xa2\x01D2\xdd\xa6B\x00\x00\x00\x00IEND\xaeB`\x82',
61+
media_type='image/png'
62+
)

0 commit comments

Comments
 (0)