From e5baa8a118ee645903711a87cd55884604ceb388 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Tue, 11 Feb 2020 10:32:12 -0800 Subject: [PATCH 1/9] Scaffolding http wsgi library --- azure/functions/_http_wsgi.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 azure/functions/_http_wsgi.py diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py new file mode 100644 index 00000000..90a36535 --- /dev/null +++ b/azure/functions/_http_wsgi.py @@ -0,0 +1,32 @@ +from typing import Callable, Dict +from + +from ._abc import Context +from ._http import HttpRequest, HttpResponse + + +class WsgiMiddleware: + def __init__(self, app): + self._app = app + + @property + def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: + return WsgiMiddleware.handle + + def handle(cls, req: HttpRequest, context: Context = None) -> HttpResponse: + req_url = req.url + req_body = req.get_body() + + environ = {} + self._inject_general_environ(environ) + self._inject_http_environ(environ) + self._inject_wsgi_environ(environ) + + def _inject_general_environ(env: Dict[str, str]): + env.update({ + "CONTENT_TYPE": + }) + + def _inject_http_environ(env: Dict[str, str]): + + def _inject_wsgi_environ(env: Dict[str, str]): From c1e91de97bac7b946c35f4cb71274d5bbd9d6fbf Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Tue, 11 Feb 2020 14:32:22 -0800 Subject: [PATCH 2/9] Add converter to WSGI request --- azure/functions/_http_wsgi.py | 62 +++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index 90a36535..d57d1626 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -1,10 +1,51 @@ from typing import Callable, Dict -from +from urllib.parse import urlparse from ._abc import Context from ._http import HttpRequest, HttpResponse +class WsgiRequest: + def __init__(self, func_req: HttpRequest): + url = urlparse(func_req.url) + + # Convert function request headers to lowercase header + self._lowercased_headers = { + k.lower():v for k,v in func_req.headers.items() + } + + # Implement interfaces for PEP 3333 environ + self.request_method = func_req.method + self.script_name = '' + self.path_info = url.path + self.query_string = url.query + self.content_type = self._lowercased_headers.get('content-type') + self.content_length = self._lowercased_headers.get('content-length') + self.server_name = url.hostname + self.server_port = self._get_port(url, self._lowercased_headers) + self.server_protocol = 'HTTP/1.1' + + # Pass http headers Content-Type + self._http_environ: Dict[str, str] = self._get_http_headers(func_req.headers) + + + + def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: + port: int = 80 + if lowercased_headers.get('x-forwarded-port'): + return int(lowercased_headers['x-forwarded-port']) + elif getattr(parsed_url, 'port', None): + return parsed_url.port + elif parsed_url.scheme == 'https': + return 443 + return port + + def _get_http_headers(func_headers: Dict[str, str]) -> Dict[str, str]: + # Content-Type -> HTTP_CONTENT_TYPE + return { + f'HTTP_{k.upper().}':v for k,v in func_headers.items() + } + class WsgiMiddleware: def __init__(self, app): self._app = app @@ -16,15 +57,30 @@ def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: def handle(cls, req: HttpRequest, context: Context = None) -> HttpResponse: req_url = req.url req_body = req.get_body() + req_headers = req.headers environ = {} self._inject_general_environ(environ) self._inject_http_environ(environ) self._inject_wsgi_environ(environ) - def _inject_general_environ(env: Dict[str, str]): + def _inject_general_environ(env: Dict[str, str], req_body: bytes, req_headers: Dict[str, str]): env.update({ - "CONTENT_TYPE": + 'CONTENT_LENGTH': req_headers.get('Content-Length'), + 'CONTENT_TYPE': req_headers.get('Content-Type'), + 'PATH_INFO': url_unquote(path_info), + 'QUERY_STRING': encode_query_string(event), + 'REMOTE_ADDR': event[u'requestContext'] + .get(u'identity', {}) + .get(u'sourceIp', ''), + 'REMOTE_USER': event[u'requestContext'] + .get(u'authorizer', {}) + .get(u'principalId', ''), + 'REQUEST_METHOD': event[u'httpMethod'], + 'SCRIPT_NAME': script_name, + 'SERVER_NAME': headers.get(u'Host', 'lambda'), + 'SERVER_PORT': headers.get(u'X-Forwarded-Port', '80'), + 'SERVER_PROTOCOL': 'HTTP/1.1', }) def _inject_http_environ(env: Dict[str, str]): From 78cef61c1b586f2c14e2d96be864287c441736b1 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Tue, 11 Feb 2020 16:32:02 -0800 Subject: [PATCH 3/9] Add wsgi handler --- azure/functions/__init__.py | 2 + azure/functions/_http_wsgi.py | 166 +++++++++++++++++++++++++--------- 2 files changed, 123 insertions(+), 45 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 0998ee7b..f191e540 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -4,6 +4,7 @@ from ._cosmosdb import Document, DocumentList # NoQA from ._http import HttpRequest # NoQA from ._http import HttpResponse # NoQA +from ._http_wsgi import WsgiMiddleware # NoQA from ._queue import QueueMessage # NoQA from ._servicebus import ServiceBusMessage # NoQA from .meta import get_binding_registry # NoQA @@ -34,6 +35,7 @@ 'EventHubEvent', 'HttpRequest', 'HttpResponse', + 'WsgiMiddleware', 'InputStream', 'QueueMessage', 'ServiceBusMessage', diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index d57d1626..14c5ae7b 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -1,13 +1,21 @@ -from typing import Callable, Dict +from typing import Callable, Dict, List, Any +from io import BytesIO, StringIO +from os import linesep from urllib.parse import urlparse +from wsgiref.hreads import Headers from ._abc import Context from ._http import HttpRequest, HttpResponse +from ._thirdparty.werkzeug._compat import string_types, wsgi_encoding_dance class WsgiRequest: - def __init__(self, func_req: HttpRequest): + + self._environ_cache: Dict[str, Any] = None + + def __init__(self, func_req: HttpRequest, func_ctx: Context = None): url = urlparse(func_req.url) + func_req_body = func_req.get_body() # Convert function request headers to lowercase header self._lowercased_headers = { @@ -15,21 +23,70 @@ def __init__(self, func_req: HttpRequest): } # Implement interfaces for PEP 3333 environ - self.request_method = func_req.method + self.request_method = getattr(func_req, 'method', None) self.script_name = '' - self.path_info = url.path - self.query_string = url.query + self.path_info = getattr(url, 'path', None) + self.query_string = getattr(url, 'query', None) self.content_type = self._lowercased_headers.get('content-type') - self.content_length = self._lowercased_headers.get('content-length') - self.server_name = url.hostname - self.server_port = self._get_port(url, self._lowercased_headers) + self.content_length = str(len(func_req_body)) + self.server_name = getattr(url, 'hostname', None) + self.server_port = str(self._get_port(url, self._lowercased_headers)) self.server_protocol = 'HTTP/1.1' - # Pass http headers Content-Type + # Propagate http request headers into HTTP_ environ self._http_environ: Dict[str, str] = self._get_http_headers(func_req.headers) + # Wsgi environ + self.wsgi_version = (1, 0) + self.wsgi_url_scheme = url.scheme + self.wsgi_input = BytesIO(func_req_body) + self.wsgi_multithread = False + self.wsgi_multiprocess = False + self.wsgi_run_once = False + + # Azure Functions context + self.af_function_directory = getattr(func_ctx, 'function_directory', None) + self.af_function_name = getattr(func_ctx, 'function_name', None) + self.af_invocation_id = getattr(func_ctx, 'invocation_id', None) + + def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: + if self._environ_cache is not None: + return self._environ_cache + + environ = { + 'REQUEST_METHOD': self.request_method, + 'SCRIPT_NAME': self.script_name, + 'PATH_INFO': self.path_info, + 'QUERY_STRING': self.query_string, + 'CONTENT_TYPE': self.content_type, + 'CONTENT_LENGTH': self.content_length, + 'SERVER_NAME': self.server_name, + 'SERVER_PORT': self.server_port, + 'SERVER_PROTOCOL': self.server_protocol, + 'wsgi.version': self.wsgi_version, + 'wsgi.url_scheme': self.wsgi_url_scheme, + 'wsgi.input': self.wsgi_input, + 'wsgi.errors': errors_buffer, + 'wsgi.multithread': self.wsgi_multithread, + 'wsgi.multiprocess': self.wsgi_multiprocess, + 'wsgi.run_once': self.wsgi_run_once + 'azure_functions.function_directory': self.af_function_directory, + 'azure_functions.function_name': self.af_function_name, + 'azure_functions.invocation_id': self.af_invocation_id + } + environ.update(self._http_environ) + + # Ensure WSGI string fits in IOS-8859-1 code points + for k,v in environ.items(): + if isinstance(v, string_types): + environ[k] = wsgi_encoding_dance(v) + + # Remove None values + self._environ_cache = { + k:v for k,v in environ if v is not None + } + return self._environ_cache - def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: port: int = 80 if lowercased_headers.get('x-forwarded-port'): @@ -43,46 +100,65 @@ def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: def _get_http_headers(func_headers: Dict[str, str]) -> Dict[str, str]: # Content-Type -> HTTP_CONTENT_TYPE return { - f'HTTP_{k.upper().}':v for k,v in func_headers.items() + f'HTTP_{k.upper().replace('-', '_')}':v for k,v in func_headers.items() } + +class WsgiResponse: + def __init__(self): + self._status = '' + self._status_code = 0 + self._headers = {} + self._buffer: List[bytes] = [] + + @classmethod + def from_app(cls, app, environ) -> WsgiResponse: + res = cls() + self._buffer = [x for x in app(environ, res._start_response)] + return res + + def to_func_response(self) -> HttpResponse: + lowercased_headers = { k.lower():v for k,v in self._headers } + return HttpResponse( + body=b''.join(self._buffer), + status_code=self.status_code, + headers=self._headers, + mimetype=lowercased_headers.get('content-type'), + charset=lowercased_headers.get('content-encoding') + ) + + # PEP 3333 start response implementation + def _start_response(status: str, response_headers: List[Any]): + self._status = status + self._headers = Headers(response_headers) + self._status_code = int(self._status.split(' ')[0]) + + class WsgiMiddleware: def __init__(self, app): self._app = app + self._wsgi_error_buffer = StringIO() + # Usage + # main = func.WsgiMiddleware(app).main @property def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: - return WsgiMiddleware.handle - - def handle(cls, req: HttpRequest, context: Context = None) -> HttpResponse: - req_url = req.url - req_body = req.get_body() - req_headers = req.headers - - environ = {} - self._inject_general_environ(environ) - self._inject_http_environ(environ) - self._inject_wsgi_environ(environ) - - def _inject_general_environ(env: Dict[str, str], req_body: bytes, req_headers: Dict[str, str]): - env.update({ - 'CONTENT_LENGTH': req_headers.get('Content-Length'), - 'CONTENT_TYPE': req_headers.get('Content-Type'), - 'PATH_INFO': url_unquote(path_info), - 'QUERY_STRING': encode_query_string(event), - 'REMOTE_ADDR': event[u'requestContext'] - .get(u'identity', {}) - .get(u'sourceIp', ''), - 'REMOTE_USER': event[u'requestContext'] - .get(u'authorizer', {}) - .get(u'principalId', ''), - 'REQUEST_METHOD': event[u'httpMethod'], - 'SCRIPT_NAME': script_name, - 'SERVER_NAME': headers.get(u'Host', 'lambda'), - 'SERVER_PORT': headers.get(u'X-Forwarded-Port', '80'), - 'SERVER_PROTOCOL': 'HTTP/1.1', - }) - - def _inject_http_environ(env: Dict[str, str]): - - def _inject_wsgi_environ(env: Dict[str, str]): + return self.handle + + # Usage + # return func.WsgiMiddlewawre(app).handle(req, context) + def handle(self, req: HttpRequest, context: Context = None) -> HttpResponse: + wsgi_request = WsgiRequest(req, context) + environ = wsgi_request.to_environ(self._wsgi_error_buffer) + wsgi_response = WsgiResponse.from_app(self._app, environ) + self._handle_errors() + return wsgi_response.to_func_response() + + @classmethod + def _handle_errors(self): + if self._wsgi_error_buffer.tell() > 0: + self._wsgi_error_buffer.seek(0) + error_message = linesep.join( + self._wsgi_error_buffer.readline() + ) + raise Exception(error_message) From 9afa0661b03052154463b5e16fb5bf4177f50d9c Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Tue, 11 Feb 2020 16:39:30 -0800 Subject: [PATCH 4/9] Clean up syntax --- azure/functions/_http_wsgi.py | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index 14c5ae7b..59c9ddc7 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -10,8 +10,7 @@ class WsgiRequest: - - self._environ_cache: Dict[str, Any] = None + _environ_cache: Dict[str, Any] = None def __init__(self, func_req: HttpRequest, func_ctx: Context = None): url = urlparse(func_req.url) @@ -19,7 +18,7 @@ def __init__(self, func_req: HttpRequest, func_ctx: Context = None): # Convert function request headers to lowercase header self._lowercased_headers = { - k.lower():v for k,v in func_req.headers.items() + k.lower(): v for k, v in func_req.headers.items() } # Implement interfaces for PEP 3333 environ @@ -34,7 +33,9 @@ def __init__(self, func_req: HttpRequest, func_ctx: Context = None): self.server_protocol = 'HTTP/1.1' # Propagate http request headers into HTTP_ environ - self._http_environ: Dict[str, str] = self._get_http_headers(func_req.headers) + self._http_environ: Dict[str, str] = self._get_http_headers( + func_req.headers + ) # Wsgi environ self.wsgi_version = (1, 0) @@ -45,12 +46,13 @@ def __init__(self, func_req: HttpRequest, func_ctx: Context = None): self.wsgi_run_once = False # Azure Functions context - self.af_function_directory = getattr(func_ctx, 'function_directory', None) + self.af_function_directory = getattr( + func_ctx, 'function_directory', None) self.af_function_name = getattr(func_ctx, 'function_name', None) self.af_invocation_id = getattr(func_ctx, 'invocation_id', None) def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: - if self._environ_cache is not None: + if self._environ_cache is None: return self._environ_cache environ = { @@ -69,7 +71,7 @@ def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: 'wsgi.errors': errors_buffer, 'wsgi.multithread': self.wsgi_multithread, 'wsgi.multiprocess': self.wsgi_multiprocess, - 'wsgi.run_once': self.wsgi_run_once + 'wsgi.run_once': self.wsgi_run_once, 'azure_functions.function_directory': self.af_function_directory, 'azure_functions.function_name': self.af_function_name, 'azure_functions.invocation_id': self.af_invocation_id @@ -77,13 +79,13 @@ def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: environ.update(self._http_environ) # Ensure WSGI string fits in IOS-8859-1 code points - for k,v in environ.items(): + for k, v in environ.items(): if isinstance(v, string_types): environ[k] = wsgi_encoding_dance(v) # Remove None values self._environ_cache = { - k:v for k,v in environ if v is not None + k: v for k, v in environ if v is not None } return self._environ_cache @@ -99,9 +101,8 @@ def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: def _get_http_headers(func_headers: Dict[str, str]) -> Dict[str, str]: # Content-Type -> HTTP_CONTENT_TYPE - return { - f'HTTP_{k.upper().replace('-', '_')}':v for k,v in func_headers.items() - } + return {f'HTTP_{k.upper().replace("-", "_")}': v for k, v in + func_headers.items()} class WsgiResponse: @@ -111,14 +112,18 @@ def __init__(self): self._headers = {} self._buffer: List[bytes] = [] + @property + def buffer(self): + return self._buffer + @classmethod - def from_app(cls, app, environ) -> WsgiResponse: + def from_app(cls, app, environ) -> 'WsgiResponse': res = cls() - self._buffer = [x for x in app(environ, res._start_response)] + res._buffer = [x for x in app(environ, res._start_response)] return res def to_func_response(self) -> HttpResponse: - lowercased_headers = { k.lower():v for k,v in self._headers } + lowercased_headers = {k.lower(): v for k, v in self._headers} return HttpResponse( body=b''.join(self._buffer), status_code=self.status_code, @@ -128,7 +133,7 @@ def to_func_response(self) -> HttpResponse: ) # PEP 3333 start response implementation - def _start_response(status: str, response_headers: List[Any]): + def _start_response(self, status: str, response_headers: List[Any]): self._status = status self._headers = Headers(response_headers) self._status_code = int(self._status.split(' ')[0]) @@ -147,7 +152,8 @@ def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: # Usage # return func.WsgiMiddlewawre(app).handle(req, context) - def handle(self, req: HttpRequest, context: Context = None) -> HttpResponse: + def handle(self, + req: HttpRequest, context: Context = None) -> HttpResponse: wsgi_request = WsgiRequest(req, context) environ = wsgi_request.to_environ(self._wsgi_error_buffer) wsgi_response = WsgiResponse.from_app(self._app, environ) From 4837f6a00239f1cb4416e6c442c3de61d0abfa0b Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Tue, 11 Feb 2020 17:23:26 -0800 Subject: [PATCH 5/9] Test out basic scenario --- azure/functions/_http_wsgi.py | 38 +++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index 59c9ddc7..e14392de 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -1,8 +1,8 @@ -from typing import Callable, Dict, List, Any +from typing import Callable, Dict, List, Optional, Any from io import BytesIO, StringIO from os import linesep from urllib.parse import urlparse -from wsgiref.hreads import Headers +from wsgiref.headers import Headers from ._abc import Context from ._http import HttpRequest, HttpResponse @@ -12,7 +12,9 @@ class WsgiRequest: _environ_cache: Dict[str, Any] = None - def __init__(self, func_req: HttpRequest, func_ctx: Context = None): + def __init__(self, + func_req: HttpRequest, + func_ctx: Optional[Context] = None): url = urlparse(func_req.url) func_req_body = func_req.get_body() @@ -52,7 +54,7 @@ def __init__(self, func_req: HttpRequest, func_ctx: Context = None): self.af_invocation_id = getattr(func_ctx, 'invocation_id', None) def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: - if self._environ_cache is None: + if self._environ_cache is not None: return self._environ_cache environ = { @@ -85,11 +87,11 @@ def to_environ(self, errors_buffer: StringIO) -> Dict[str, Any]: # Remove None values self._environ_cache = { - k: v for k, v in environ if v is not None + k: v for k, v in environ.items() if v is not None } return self._environ_cache - def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: + def _get_port(self, parsed_url, lowercased_headers: Dict[str, str]) -> int: port: int = 80 if lowercased_headers.get('x-forwarded-port'): return int(lowercased_headers['x-forwarded-port']) @@ -99,7 +101,8 @@ def _get_port(parsed_url, lowercased_headers: Dict[str, str]) -> int: return 443 return port - def _get_http_headers(func_headers: Dict[str, str]) -> Dict[str, str]: + def _get_http_headers(self, + func_headers: Dict[str, str]) -> Dict[str, str]: # Content-Type -> HTTP_CONTENT_TYPE return {f'HTTP_{k.upper().replace("-", "_")}': v for k, v in func_headers.items()} @@ -112,10 +115,6 @@ def __init__(self): self._headers = {} self._buffer: List[bytes] = [] - @property - def buffer(self): - return self._buffer - @classmethod def from_app(cls, app, environ) -> 'WsgiResponse': res = cls() @@ -123,10 +122,10 @@ def from_app(cls, app, environ) -> 'WsgiResponse': return res def to_func_response(self) -> HttpResponse: - lowercased_headers = {k.lower(): v for k, v in self._headers} + lowercased_headers = {k.lower(): v for k, v in self._headers.items()} return HttpResponse( body=b''.join(self._buffer), - status_code=self.status_code, + status_code=self._status_code, headers=self._headers, mimetype=lowercased_headers.get('content-type'), charset=lowercased_headers.get('content-encoding') @@ -147,20 +146,25 @@ def __init__(self, app): # Usage # main = func.WsgiMiddleware(app).main @property - def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: - return self.handle + def main(self) -> Callable[[HttpRequest, Optional[Context]], HttpResponse]: + return self._handle # Usage # return func.WsgiMiddlewawre(app).handle(req, context) def handle(self, - req: HttpRequest, context: Context = None) -> HttpResponse: + req: HttpRequest, + context: Optional[Context] = None) -> HttpResponse: + return self._handle(req, context) + + def _handle(self, + req: HttpRequest, + context: Context) -> HttpResponse: wsgi_request = WsgiRequest(req, context) environ = wsgi_request.to_environ(self._wsgi_error_buffer) wsgi_response = WsgiResponse.from_app(self._app, environ) self._handle_errors() return wsgi_response.to_func_response() - @classmethod def _handle_errors(self): if self._wsgi_error_buffer.tell() > 0: self._wsgi_error_buffer.seek(0) From b70e90bdcdff3acd53f9ba0e32db68209a93b913 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Thu, 13 Feb 2020 14:59:55 -0800 Subject: [PATCH 6/9] Added unittests --- azure/functions/_http_wsgi.py | 8 +- tests/test_http_wsgi.py | 207 ++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 tests/test_http_wsgi.py diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index e14392de..05b404ba 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -16,7 +16,7 @@ def __init__(self, func_req: HttpRequest, func_ctx: Optional[Context] = None): url = urlparse(func_req.url) - func_req_body = func_req.get_body() + func_req_body = func_req.get_body() or b'' # Convert function request headers to lowercase header self._lowercased_headers = { @@ -48,8 +48,8 @@ def __init__(self, self.wsgi_run_once = False # Azure Functions context - self.af_function_directory = getattr( - func_ctx, 'function_directory', None) + self.af_function_directory = getattr(func_ctx, + 'function_directory', None) self.af_function_name = getattr(func_ctx, 'function_name', None) self.af_invocation_id = getattr(func_ctx, 'invocation_id', None) @@ -118,7 +118,7 @@ def __init__(self): @classmethod def from_app(cls, app, environ) -> 'WsgiResponse': res = cls() - res._buffer = [x for x in app(environ, res._start_response)] + res._buffer = [x or b'' for x in app(environ, res._start_response)] return res def to_func_response(self) -> HttpResponse: diff --git a/tests/test_http_wsgi.py b/tests/test_http_wsgi.py new file mode 100644 index 00000000..3849bcd6 --- /dev/null +++ b/tests/test_http_wsgi.py @@ -0,0 +1,207 @@ +import unittest +from io import StringIO, BytesIO + +import azure.functions as func +from azure.functions._http_wsgi import WsgiRequest, WsgiResponse + + +class TestHttpWsgi(unittest.TestCase): + + def test_request_general_environ_conversion(self): + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['REQUEST_METHOD'], 'POST') + self.assertEqual(environ['SCRIPT_NAME'], '') + self.assertEqual(environ['PATH_INFO'], '/api/http') + self.assertEqual(environ['QUERY_STRING'], 'firstname=rt') + self.assertEqual(environ['CONTENT_TYPE'], 'application/json') + self.assertEqual(environ['CONTENT_LENGTH'], + str(len(b'{ "lastname": "tsang" }'))) + self.assertEqual(environ['SERVER_NAME'], 'function.azurewebsites.net') + self.assertEqual(environ['SERVER_PORT'], '443') + self.assertEqual(environ['SERVER_PROTOCOL'], 'HTTP/1.1') + + def test_request_wsgi_environ_conversion(self): + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['wsgi.version'], (1, 0)) + self.assertEqual(environ['wsgi.url_scheme'], 'https') + + self.assertIsInstance(environ['wsgi.input'], BytesIO) + bytes_io: BytesIO = environ['wsgi.input'] + bytes_io.seek(0) + self.assertEqual(bytes_io.read(), b'{ "lastname": "tsang" }') + + self.assertIsInstance(environ['wsgi.errors'], StringIO) + string_io: StringIO = environ['wsgi.errors'] + string_io.seek(0) + self.assertEqual(string_io.read(), '') + + self.assertEqual(environ['wsgi.multithread'], False) + self.assertEqual(environ['wsgi.multiprocess'], False) + self.assertEqual(environ['wsgi.run_once'], False) + + def test_request_http_environ_conversion(self): + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['HTTP_X_MS_SITE_RESTRICTED_TOKEN'], 'xmsrt') + + def test_request_has_no_query_param(self): + func_request = self._generate_func_request( + url="https://function.azurewebsites.net", + params=None) + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['QUERY_STRING'], '') + + def test_request_has_no_body(self): + func_request = self._generate_func_request(body=None) + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['CONTENT_LENGTH'], str(0)) + + self.assertIsInstance(environ['wsgi.input'], BytesIO) + bytes_io: BytesIO = environ['wsgi.input'] + bytes_io.seek(0) + self.assertEqual(bytes_io.read(), b'') + + def test_request_has_no_headers(self): + func_request = self._generate_func_request(headers=None) + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertNotIn('CONTENT_TYPE', environ) + + def test_request_protocol_by_header(self): + func_request = self._generate_func_request(headers={ + "x-forwarded-port": "8081" + }) + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['SERVER_PORT'], str(8081)) + self.assertEqual(environ['wsgi.url_scheme'], 'https') + + def test_request_protocol_by_scheme(self): + func_request = self._generate_func_request(url="http://a.b.com") + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + self.assertEqual(environ['SERVER_PORT'], str(80)) + self.assertEqual(environ['wsgi.url_scheme'], 'http') + + def test_request_parse_function_context(self): + func_request = self._generate_func_request() + func_context = self._generate_func_context() + error_buffer = StringIO() + environ = WsgiRequest(func_request, + func_context).to_environ(error_buffer) + self.assertEqual(environ['azure_functions.invocation_id'], + '123e4567-e89b-12d3-a456-426655440000') + self.assertEqual(environ['azure_functions.function_name'], + 'httptrigger') + self.assertEqual(environ['azure_functions.function_directory'], + '/home/roger/wwwroot/httptrigger') + + def test_response_from_wsgi_app(self): + app = self._generate_wsgi_app() + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + + wsgi_response: WsgiResponse = WsgiResponse.from_app(app, environ) + func_response: func.HttpResponse = wsgi_response.to_func_response() + + self.assertEqual(func_response.mimetype, 'text/plain') + self.assertEqual(func_response.charset, 'utf-8') + self.assertEqual(func_response.headers['Content-Type'], 'text/plain') + self.assertEqual(func_response.status_code, 200) + self.assertEqual(func_response.get_body(), b'sample string') + + def test_response_no_body(self): + app = self._generate_wsgi_app(response_body=None) + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + + wsgi_response: WsgiResponse = WsgiResponse.from_app(app, environ) + func_response: func.HttpResponse = wsgi_response.to_func_response() + self.assertEqual(func_response.get_body(), b'') + + def test_response_no_headers(self): + app = self._generate_wsgi_app(response_headers=None) + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + + wsgi_response: WsgiResponse = WsgiResponse.from_app(app, environ) + func_response: func.HttpResponse = wsgi_response.to_func_response() + self.assertEqual(func_response.headers, {}) + + def _generate_func_request( + self, + method="POST", + url="https://function.azurewebsites.net/api/http?firstname=rt", + headers={ + "Content-Type": "application/json", + "x-ms-site-restricted-token": "xmsrt" + }, + params={ + "firstname": "roger" + }, + route_params={}, + body=b'{ "lastname": "tsang" }' + ) -> func.HttpRequest: + return func.HttpRequest( + method=method, + url=url, + headers=headers, + params=params, + route_params=route_params, + body=body + ) + + def _generate_func_context( + self, + invocation_id='123e4567-e89b-12d3-a456-426655440000', + function_name='httptrigger', + function_directory='/home/roger/wwwroot/httptrigger' + ) -> func.Context: + class MockContext(func.Context): + def __init__(self, ii, fn, fd): + self._invocation_id = ii + self._function_name = fn + self._function_directory = fd + + @property + def invocation_id(self): + return self._invocation_id + + @property + def function_name(self): + return self._function_name + + @property + def function_directory(self): + return self._function_directory + + return MockContext(invocation_id, function_name, function_directory) + + def _generate_wsgi_app(self, + status='200 OK', + response_headers=[('Content-Type', 'text/plain')], + response_body=b'sample string'): + class MockWsgiApp: + _status = status + _response_headers = response_headers + _response_body = response_body + + def __init__(self, environ, start_response): + self._environ = environ + self._start_response = start_response + + def __iter__(self): + self._start_response(self._status, self._response_headers) + yield self._response_body + + return MockWsgiApp From 45e3bbb1bc6042d3a422f092e48e701b80e684f5 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Thu, 13 Feb 2020 16:12:11 -0800 Subject: [PATCH 7/9] Add exception test case --- tests/test_http_wsgi.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_http_wsgi.py b/tests/test_http_wsgi.py index 3849bcd6..abc16819 100644 --- a/tests/test_http_wsgi.py +++ b/tests/test_http_wsgi.py @@ -5,6 +5,11 @@ from azure.functions._http_wsgi import WsgiRequest, WsgiResponse +class WsgiException(Exception): + def __init__(self, message=''): + self.message = message + + class TestHttpWsgi(unittest.TestCase): def test_request_general_environ_conversion(self): @@ -138,6 +143,19 @@ def test_response_no_headers(self): func_response: func.HttpResponse = wsgi_response.to_func_response() self.assertEqual(func_response.headers, {}) + def test_response_with_exception(self): + app = self._generate_wsgi_app( + exception=WsgiException(message='wsgi excpt')) + func_request = self._generate_func_request() + error_buffer = StringIO() + environ = WsgiRequest(func_request).to_environ(error_buffer) + + with self.assertRaises(WsgiException) as e: + wsgi_response = WsgiResponse.from_app(app, environ) + wsgi_response.to_func_response() + + self.assertEqual(e.exception.message, 'wsgi excpt') + def _generate_func_request( self, method="POST", @@ -190,17 +208,22 @@ def function_directory(self): def _generate_wsgi_app(self, status='200 OK', response_headers=[('Content-Type', 'text/plain')], - response_body=b'sample string'): + response_body=b'sample string', + exception: WsgiException=None): class MockWsgiApp: _status = status _response_headers = response_headers _response_body = response_body + _exception = exception def __init__(self, environ, start_response): self._environ = environ self._start_response = start_response def __iter__(self): + if self._exception is not None: + raise self._exception + self._start_response(self._status, self._response_headers) yield self._response_body From 8ee5c70359d473b34f86a13967adb19fc3e6b972 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Thu, 13 Feb 2020 16:15:38 -0800 Subject: [PATCH 8/9] Add middleware test case --- azure/functions/_http_wsgi.py | 2 +- tests/test_http_wsgi.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index 05b404ba..2eede7ba 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -135,7 +135,7 @@ def to_func_response(self) -> HttpResponse: def _start_response(self, status: str, response_headers: List[Any]): self._status = status self._headers = Headers(response_headers) - self._status_code = int(self._status.split(' ')[0]) + self._status_code = int(self._status.split(' ')[0]) # 200 OK class WsgiMiddleware: diff --git a/tests/test_http_wsgi.py b/tests/test_http_wsgi.py index abc16819..9232c1e8 100644 --- a/tests/test_http_wsgi.py +++ b/tests/test_http_wsgi.py @@ -2,7 +2,11 @@ from io import StringIO, BytesIO import azure.functions as func -from azure.functions._http_wsgi import WsgiRequest, WsgiResponse +from azure.functions._http_wsgi import ( + WsgiRequest, + WsgiResponse, + WsgiMiddleware +) class WsgiException(Exception): @@ -156,6 +160,12 @@ def test_response_with_exception(self): self.assertEqual(e.exception.message, 'wsgi excpt') + def test_middleware_handle(self): + app = self._generate_wsgi_app() + func_request = self._generate_func_request() + func_response = WsgiMiddleware(app).handle(func_request) + self.assertEqual(func_response.status_code, 200) + def _generate_func_request( self, method="POST", From 2cee93353982384a4605a95209b27d7e23f16ef2 Mon Sep 17 00:00:00 2001 From: "Hanzhang Zeng (Roger)" Date: Thu, 13 Feb 2020 16:19:11 -0800 Subject: [PATCH 9/9] Fix code quality --- tests/test_http_wsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_http_wsgi.py b/tests/test_http_wsgi.py index 9232c1e8..c340e50f 100644 --- a/tests/test_http_wsgi.py +++ b/tests/test_http_wsgi.py @@ -219,7 +219,7 @@ def _generate_wsgi_app(self, status='200 OK', response_headers=[('Content-Type', 'text/plain')], response_body=b'sample string', - exception: WsgiException=None): + exception: WsgiException = None): class MockWsgiApp: _status = status _response_headers = response_headers