Skip to content

Commit 3e76b29

Browse files
authored
Add ASGI handler (#86)
* An ASGI handler * Remove unused imports * Fix the scope tuples type * Add some basic logging * Add request body support * Squash the lines a bit more * Test implementation against spec * Correct some type specifications * Removed unused variables, add azure properties to scope
1 parent cc1099e commit 3e76b29

File tree

4 files changed

+294
-1
lines changed

4 files changed

+294
-1
lines changed

azure/functions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ._http import HttpRequest
99
from ._http import HttpResponse
1010
from ._http_wsgi import WsgiMiddleware
11+
from ._http_asgi import AsgiMiddleware
1112
from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter
1213
from ._queue import QueueMessage
1314
from ._servicebus import ServiceBusMessage
@@ -57,6 +58,7 @@
5758

5859
# Middlewares
5960
'WsgiMiddleware',
61+
'AsgiMiddleware',
6062

6163
# Extensions
6264
'AppExtensionBase',

azure/functions/_http_asgi.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
from typing import Callable, Dict, List, Tuple, Optional, Any, Union
6+
import logging
7+
from wsgiref.headers import Headers
8+
9+
from ._abc import Context
10+
from ._http import HttpRequest, HttpResponse
11+
from ._http_wsgi import WsgiRequest
12+
13+
14+
class AsgiRequest(WsgiRequest):
15+
def __init__(self, func_req: HttpRequest,
16+
func_ctx: Optional[Context] = None):
17+
self.asgi_version = "2.1"
18+
self.asgi_spec_version = "2.1"
19+
self._headers = func_req.headers
20+
super().__init__(func_req, func_ctx)
21+
22+
def _get_encoded_http_headers(self) -> List[Tuple[bytes, bytes]]:
23+
return [(k.encode("utf8"), v.encode("utf8"))
24+
for k, v in self._headers.items()]
25+
26+
def _get_server_address(self):
27+
if self.server_name is not None:
28+
return (self.server_name, int(self.server_port))
29+
return None
30+
31+
def to_asgi_http_scope(self):
32+
return {
33+
"type": "http",
34+
"asgi.version": self.asgi_version,
35+
"asgi.spec_version": self.asgi_spec_version,
36+
"http_version": "1.1",
37+
"method": self.request_method,
38+
"scheme": "https",
39+
"path": self.path_info,
40+
"raw_path": self.path_info.encode("utf-8"),
41+
"query_string": self.query_string.encode("utf-8"),
42+
"root_path": self.script_name,
43+
"headers": self._get_encoded_http_headers(),
44+
"server": self._get_server_address(),
45+
"client": None,
46+
"azure_functions.function_directory": self.af_function_directory,
47+
"azure_functions.function_name": self.af_function_name,
48+
"azure_functions.invocation_id": self.af_invocation_id
49+
}
50+
# Notes, missing client name, port
51+
52+
53+
class AsgiResponse:
54+
def __init__(self):
55+
self._status_code = 0
56+
self._headers: Union[Headers, Dict] = {}
57+
self._buffer: List[bytes] = []
58+
self._request_body = b""
59+
60+
@classmethod
61+
async def from_app(cls, app, scope: Dict[str, Any],
62+
body: bytes) -> "AsgiResponse":
63+
res = cls()
64+
res._request_body = body
65+
await app(scope, res._receive, res._send)
66+
return res
67+
68+
def to_func_response(self) -> HttpResponse:
69+
lowercased_headers = {k.lower(): v for k, v in self._headers.items()}
70+
return HttpResponse(
71+
body=b"".join(self._buffer),
72+
status_code=self._status_code,
73+
headers=self._headers,
74+
mimetype=lowercased_headers.get("content-type"),
75+
charset=lowercased_headers.get("content-encoding"),
76+
)
77+
78+
def _handle_http_response_start(self, message: Dict[str, Any]):
79+
self._headers = Headers(
80+
[(k.decode(), v.decode())
81+
for k, v in message["headers"]])
82+
self._status_code = message["status"]
83+
84+
def _handle_http_response_body(self, message: Dict[str, Any]):
85+
self._buffer.append(message["body"])
86+
# XXX : Chunked bodies not supported, see
87+
# https://github.com/Azure/azure-functions-host/issues/4926
88+
89+
async def _receive(self):
90+
return {
91+
"type": "http.request",
92+
"body": self._request_body,
93+
"more_body": False,
94+
}
95+
96+
async def _send(self, message):
97+
logging.debug(f"Received {message} from ASGI worker.")
98+
if message["type"] == "http.response.start":
99+
self._handle_http_response_start(message)
100+
elif message["type"] == "http.response.body":
101+
self._handle_http_response_body(message)
102+
elif message["type"] == "http.disconnect":
103+
pass # Nothing todo here
104+
105+
106+
class AsgiMiddleware:
107+
def __init__(self, app):
108+
logging.debug("Instantiating ASGI middleware.")
109+
self._app = app
110+
self.loop = asyncio.new_event_loop()
111+
logging.debug("asyncio event loop initialized.")
112+
113+
# Usage
114+
# main = func.AsgiMiddleware(app).main
115+
@property
116+
def main(self) -> Callable[[HttpRequest, Context], HttpResponse]:
117+
return self._handle
118+
119+
# Usage
120+
# return func.AsgiMiddleware(app).handle(req, context)
121+
def handle(
122+
self, req: HttpRequest, context: Optional[Context] = None
123+
) -> HttpResponse:
124+
logging.info(f"Handling {req.url} as ASGI request.")
125+
return self._handle(req, context)
126+
127+
def _handle(self, req: HttpRequest,
128+
context: Optional[Context]) -> HttpResponse:
129+
asgi_request = AsgiRequest(req, context)
130+
asyncio.set_event_loop(self.loop)
131+
scope = asgi_request.to_asgi_http_scope()
132+
asgi_response = self.loop.run_until_complete(
133+
AsgiResponse.from_app(self._app, scope, req.get_body())
134+
)
135+
136+
return asgi_response.to_func_response()

azure/functions/_http_wsgi.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def main(self) -> Callable[[HttpRequest, Context], HttpResponse]:
153153
return self._handle
154154

155155
# Usage
156-
# return func.WsgiMiddlewawre(app).handle(req, context)
156+
# return func.WsgiMiddleware(app).handle(req, context)
157157
def handle(self,
158158
req: HttpRequest,
159159
context: Optional[Context] = None) -> HttpResponse:

tests/test_http_asgi.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import unittest
5+
6+
import azure.functions as func
7+
from azure.functions._http_asgi import (
8+
AsgiMiddleware
9+
)
10+
11+
12+
class MockAsgiApplication:
13+
response_code = 200
14+
response_body = b''
15+
response_headers = [
16+
[b"content-type", b"text/plain"],
17+
]
18+
19+
async def __call__(self, scope, receive, send):
20+
self.received_scope = scope
21+
# Verify against ASGI specification
22+
assert scope['type'] == 'http'
23+
assert isinstance(scope['type'], str)
24+
25+
assert scope['asgi.spec_version'] in ['2.0', '2.1']
26+
assert isinstance(scope['asgi.spec_version'], str)
27+
28+
assert scope['asgi.version'] in ['2.0', '2.1', '2.2']
29+
assert isinstance(scope['asgi.version'], str)
30+
31+
assert scope['http_version'] in ['1.0', '1.1', '2']
32+
assert isinstance(scope['http_version'], str)
33+
34+
assert scope['method'] in ['POST', 'GET', 'PUT', 'DELETE', 'PATCH']
35+
assert isinstance(scope['method'], str)
36+
37+
assert scope['scheme'] in ['http', 'https']
38+
assert isinstance(scope['scheme'], str)
39+
40+
assert isinstance(scope['path'], str)
41+
assert isinstance(scope['raw_path'], bytes)
42+
assert isinstance(scope['query_string'], bytes)
43+
assert isinstance(scope['root_path'], str)
44+
45+
assert hasattr(scope['headers'], '__iter__')
46+
for k, v in scope['headers']:
47+
assert isinstance(k, bytes)
48+
assert isinstance(v, bytes)
49+
50+
assert scope['client'] is None or hasattr(scope['client'], '__iter__')
51+
if scope['client']:
52+
assert len(scope['client']) == 2
53+
assert isinstance(scope['client'][0], str)
54+
assert isinstance(scope['client'][1], int)
55+
56+
assert scope['server'] is None or hasattr(scope['server'], '__iter__')
57+
if scope['server']:
58+
assert len(scope['server']) == 2
59+
assert isinstance(scope['server'][0], str)
60+
assert isinstance(scope['server'][1], int)
61+
62+
self.received_request = await receive()
63+
assert self.received_request['type'] == 'http.request'
64+
assert isinstance(self.received_request['body'], bytes)
65+
assert isinstance(self.received_request['more_body'], bool)
66+
67+
await send(
68+
{
69+
"type": "http.response.start",
70+
"status": self.response_code,
71+
"headers": self.response_headers,
72+
}
73+
)
74+
await send(
75+
{
76+
"type": "http.response.body",
77+
"body": self.response_body,
78+
}
79+
)
80+
81+
82+
class TestHttpAsgiMiddleware(unittest.TestCase):
83+
def _generate_func_request(
84+
self,
85+
method="POST",
86+
url="https://function.azurewebsites.net/api/http?firstname=rt",
87+
headers={
88+
"Content-Type": "application/json",
89+
"x-ms-site-restricted-token": "xmsrt"
90+
},
91+
params={
92+
"firstname": "roger"
93+
},
94+
route_params={},
95+
body=b'{ "lastname": "tsang" }'
96+
) -> func.HttpRequest:
97+
return func.HttpRequest(
98+
method=method,
99+
url=url,
100+
headers=headers,
101+
params=params,
102+
route_params=route_params,
103+
body=body
104+
)
105+
106+
def _generate_func_context(
107+
self,
108+
invocation_id='123e4567-e89b-12d3-a456-426655440000',
109+
function_name='httptrigger',
110+
function_directory='/home/roger/wwwroot/httptrigger'
111+
) -> func.Context:
112+
class MockContext(func.Context):
113+
def __init__(self, ii, fn, fd):
114+
self._invocation_id = ii
115+
self._function_name = fn
116+
self._function_directory = fd
117+
118+
@property
119+
def invocation_id(self):
120+
return self._invocation_id
121+
122+
@property
123+
def function_name(self):
124+
return self._function_name
125+
126+
@property
127+
def function_directory(self):
128+
return self._function_directory
129+
130+
return MockContext(invocation_id, function_name, function_directory)
131+
132+
def test_middleware_calls_app(self):
133+
app = MockAsgiApplication()
134+
test_body = b'Hello world!'
135+
app.response_body = test_body
136+
app.response_code = 200
137+
req = func.HttpRequest(method='get', url='/test', body=b'')
138+
response = AsgiMiddleware(app).handle(req)
139+
140+
# Verify asserted
141+
self.assertEqual(response.status_code, 200)
142+
self.assertEqual(response.get_body(), test_body)
143+
144+
def test_middleware_calls_app_with_context(self):
145+
app = MockAsgiApplication()
146+
test_body = b'Hello world!'
147+
app.response_body = test_body
148+
app.response_code = 200
149+
req = self._generate_func_request()
150+
ctx = self._generate_func_context()
151+
response = AsgiMiddleware(app).handle(req, ctx)
152+
153+
# Verify asserted
154+
self.assertEqual(response.status_code, 200)
155+
self.assertEqual(response.get_body(), test_body)

0 commit comments

Comments
 (0)