Skip to content

Commit 80f4a5f

Browse files
authored
Enable WSGI for Python Function App (#45)
* Scaffolding http wsgi library * Add converter to WSGI request * Add wsgi handler * Clean up syntax * Test out basic scenario * Added unittests * Add exception test case * Add middleware test case * Fix code quality
1 parent 4dfaa8e commit 80f4a5f

File tree

3 files changed

+416
-0
lines changed

3 files changed

+416
-0
lines changed

azure/functions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ._cosmosdb import Document, DocumentList # NoQA
55
from ._http import HttpRequest # NoQA
66
from ._http import HttpResponse # NoQA
7+
from ._http_wsgi import WsgiMiddleware # NoQA
78
from ._queue import QueueMessage # NoQA
89
from ._servicebus import ServiceBusMessage # NoQA
910
from .meta import get_binding_registry # NoQA
@@ -34,6 +35,7 @@
3435
'EventHubEvent',
3536
'HttpRequest',
3637
'HttpResponse',
38+
'WsgiMiddleware',
3739
'InputStream',
3840
'QueueMessage',
3941
'ServiceBusMessage',

azure/functions/_http_wsgi.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from typing import Callable, Dict, List, Optional, Any
2+
from io import BytesIO, StringIO
3+
from os import linesep
4+
from urllib.parse import urlparse
5+
from wsgiref.headers import Headers
6+
7+
from ._abc import Context
8+
from ._http import HttpRequest, HttpResponse
9+
from ._thirdparty.werkzeug._compat import string_types, wsgi_encoding_dance
10+
11+
12+
class WsgiRequest:
13+
_environ_cache: Dict[str, Any] = None
14+
15+
def __init__(self,
16+
func_req: HttpRequest,
17+
func_ctx: Optional[Context] = None):
18+
url = urlparse(func_req.url)
19+
func_req_body = func_req.get_body() or b''
20+
21+
# Convert function request headers to lowercase header
22+
self._lowercased_headers = {
23+
k.lower(): v for k, v in func_req.headers.items()
24+
}
25+
26+
# Implement interfaces for PEP 3333 environ
27+
self.request_method = getattr(func_req, 'method', None)
28+
self.script_name = ''
29+
self.path_info = getattr(url, 'path', None)
30+
self.query_string = getattr(url, 'query', None)
31+
self.content_type = self._lowercased_headers.get('content-type')
32+
self.content_length = str(len(func_req_body))
33+
self.server_name = getattr(url, 'hostname', None)
34+
self.server_port = str(self._get_port(url, self._lowercased_headers))
35+
self.server_protocol = 'HTTP/1.1'
36+
37+
# Propagate http request headers into HTTP_ environ
38+
self._http_environ: Dict[str, str] = self._get_http_headers(
39+
func_req.headers
40+
)
41+
42+
# Wsgi environ
43+
self.wsgi_version = (1, 0)
44+
self.wsgi_url_scheme = url.scheme
45+
self.wsgi_input = BytesIO(func_req_body)
46+
self.wsgi_multithread = False
47+
self.wsgi_multiprocess = False
48+
self.wsgi_run_once = False
49+
50+
# Azure Functions context
51+
self.af_function_directory = getattr(func_ctx,
52+
'function_directory', None)
53+
self.af_function_name = getattr(func_ctx, 'function_name', None)
54+
self.af_invocation_id = getattr(func_ctx, 'invocation_id', None)
55+
56+
def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]:
57+
if self._environ_cache is not None:
58+
return self._environ_cache
59+
60+
environ = {
61+
'REQUEST_METHOD': self.request_method,
62+
'SCRIPT_NAME': self.script_name,
63+
'PATH_INFO': self.path_info,
64+
'QUERY_STRING': self.query_string,
65+
'CONTENT_TYPE': self.content_type,
66+
'CONTENT_LENGTH': self.content_length,
67+
'SERVER_NAME': self.server_name,
68+
'SERVER_PORT': self.server_port,
69+
'SERVER_PROTOCOL': self.server_protocol,
70+
'wsgi.version': self.wsgi_version,
71+
'wsgi.url_scheme': self.wsgi_url_scheme,
72+
'wsgi.input': self.wsgi_input,
73+
'wsgi.errors': errors_buffer,
74+
'wsgi.multithread': self.wsgi_multithread,
75+
'wsgi.multiprocess': self.wsgi_multiprocess,
76+
'wsgi.run_once': self.wsgi_run_once,
77+
'azure_functions.function_directory': self.af_function_directory,
78+
'azure_functions.function_name': self.af_function_name,
79+
'azure_functions.invocation_id': self.af_invocation_id
80+
}
81+
environ.update(self._http_environ)
82+
83+
# Ensure WSGI string fits in IOS-8859-1 code points
84+
for k, v in environ.items():
85+
if isinstance(v, string_types):
86+
environ[k] = wsgi_encoding_dance(v)
87+
88+
# Remove None values
89+
self._environ_cache = {
90+
k: v for k, v in environ.items() if v is not None
91+
}
92+
return self._environ_cache
93+
94+
def _get_port(self, parsed_url, lowercased_headers: Dict[str, str]) -> int:
95+
port: int = 80
96+
if lowercased_headers.get('x-forwarded-port'):
97+
return int(lowercased_headers['x-forwarded-port'])
98+
elif getattr(parsed_url, 'port', None):
99+
return parsed_url.port
100+
elif parsed_url.scheme == 'https':
101+
return 443
102+
return port
103+
104+
def _get_http_headers(self,
105+
func_headers: Dict[str, str]) -> Dict[str, str]:
106+
# Content-Type -> HTTP_CONTENT_TYPE
107+
return {f'HTTP_{k.upper().replace("-", "_")}': v for k, v in
108+
func_headers.items()}
109+
110+
111+
class WsgiResponse:
112+
def __init__(self):
113+
self._status = ''
114+
self._status_code = 0
115+
self._headers = {}
116+
self._buffer: List[bytes] = []
117+
118+
@classmethod
119+
def from_app(cls, app, environ) -> 'WsgiResponse':
120+
res = cls()
121+
res._buffer = [x or b'' for x in app(environ, res._start_response)]
122+
return res
123+
124+
def to_func_response(self) -> HttpResponse:
125+
lowercased_headers = {k.lower(): v for k, v in self._headers.items()}
126+
return HttpResponse(
127+
body=b''.join(self._buffer),
128+
status_code=self._status_code,
129+
headers=self._headers,
130+
mimetype=lowercased_headers.get('content-type'),
131+
charset=lowercased_headers.get('content-encoding')
132+
)
133+
134+
# PEP 3333 start response implementation
135+
def _start_response(self, status: str, response_headers: List[Any]):
136+
self._status = status
137+
self._headers = Headers(response_headers)
138+
self._status_code = int(self._status.split(' ')[0]) # 200 OK
139+
140+
141+
class WsgiMiddleware:
142+
def __init__(self, app):
143+
self._app = app
144+
self._wsgi_error_buffer = StringIO()
145+
146+
# Usage
147+
# main = func.WsgiMiddleware(app).main
148+
@property
149+
def main(self) -> Callable[[HttpRequest, Optional[Context]], HttpResponse]:
150+
return self._handle
151+
152+
# Usage
153+
# return func.WsgiMiddlewawre(app).handle(req, context)
154+
def handle(self,
155+
req: HttpRequest,
156+
context: Optional[Context] = None) -> HttpResponse:
157+
return self._handle(req, context)
158+
159+
def _handle(self,
160+
req: HttpRequest,
161+
context: Context) -> HttpResponse:
162+
wsgi_request = WsgiRequest(req, context)
163+
environ = wsgi_request.to_environ(self._wsgi_error_buffer)
164+
wsgi_response = WsgiResponse.from_app(self._app, environ)
165+
self._handle_errors()
166+
return wsgi_response.to_func_response()
167+
168+
def _handle_errors(self):
169+
if self._wsgi_error_buffer.tell() > 0:
170+
self._wsgi_error_buffer.seek(0)
171+
error_message = linesep.join(
172+
self._wsgi_error_buffer.readline()
173+
)
174+
raise Exception(error_message)

0 commit comments

Comments
 (0)