diff --git a/frontik/debug.py b/frontik/debug.py index 5e023f13c..7058c6f63 100644 --- a/frontik/debug.py +++ b/frontik/debug.py @@ -31,6 +31,7 @@ import frontik.xml_util from frontik import media_types, request_context from frontik.auth import check_debug_auth +from frontik.frontik_response import FrontikResponse from frontik.loggers import BufferedHandler from frontik.options import options from frontik.util import get_cookie_or_param_from_request @@ -366,7 +367,7 @@ def _produce_one(self, record: logging.LogRecord) -> etree.Element: DEBUG_XSL = os.path.join(os.path.dirname(__file__), 'debug/debug.xsl') -def _data_to_chunk(data: Any, headers: HTTPHeaders) -> bytes: +def _data_to_chunk(data: Any) -> bytes: result: bytes = b'' if data is None: return result @@ -375,7 +376,6 @@ def _data_to_chunk(data: Any, headers: HTTPHeaders) -> bytes: elif isinstance(data, dict): chunk = json.dumps(data).replace('', '<\\/') result = chunk.encode('utf-8') - headers['Content-Type'] = 'application/json; charset=UTF-8' elif isinstance(data, bytes): result = data else: @@ -395,23 +395,23 @@ def is_inherited(self) -> bool: return self.debug_mode.inherited def transform_chunk( - self, tornado_request: httputil.HTTPServerRequest, status_code: int, original_headers: HTTPHeaders, data: bytes - ) -> tuple[int, HTTPHeaders, bytes]: - chunk = _data_to_chunk(data, original_headers) - + self, tornado_request: httputil.HTTPServerRequest, response: FrontikResponse + ) -> FrontikResponse: if not self.is_enabled(): - return status_code, original_headers, chunk + return response if not self.is_inherited(): wrap_headers = {'Content-Type': media_types.TEXT_HTML} else: wrap_headers = {'Content-Type': media_types.APPLICATION_XML, DEBUG_HEADER_NAME: 'true'} + chunk = b'Streamable response' if response.data_written else _data_to_chunk(response.body) start_time = time.time() + handler_name = request_context.get_handler_name() debug_log_data = request_context.get_log_handler().produce_all() # type: ignore - debug_log_data.set('code', str(int(status_code))) - debug_log_data.set('handler-name', request_context.get_handler_name()) + debug_log_data.set('code', str(int(response.status_code))) + debug_log_data.set('handler-name', handler_name if handler_name else 'unknown handler') debug_log_data.set('started', _format_number(tornado_request._start_time)) debug_log_data.set('request-id', str(tornado_request.request_id)) # type: ignore debug_log_data.set('stages-total', _format_number((time.time() - tornado_request._start_time) * 1000)) @@ -437,12 +437,12 @@ def transform_chunk( ), ) - debug_log_data.append(E.response(_headers_to_xml(original_headers), _cookies_to_xml(original_headers))) + debug_log_data.append(E.response(_headers_to_xml(response.headers), _cookies_to_xml(response.headers))) original_response = { 'buffer': base64.b64encode(chunk), - 'headers': dict(original_headers), - 'code': int(status_code), + 'headers': dict(response.headers), + 'code': int(response.status_code), } debug_log_data.append(dict_to_xml(original_response, 'original-response')) @@ -471,7 +471,7 @@ def transform_chunk( else: log_document = etree.tostring(debug_log_data, encoding='UTF-8', xml_declaration=True) - return 200, HTTPHeaders(wrap_headers), log_document + return FrontikResponse(status_code=200, headers=wrap_headers, body=log_document) class DebugMode: diff --git a/frontik/frontik_response.py b/frontik/frontik_response.py new file mode 100644 index 000000000..e532be4dc --- /dev/null +++ b/frontik/frontik_response.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Mapping + +from tornado import httputil +from tornado.httputil import HTTPHeaders + +from frontik import request_context +from frontik.version import version as frontik_version + + +class FrontikResponse: + def __init__( + self, + status_code: int, + headers: dict[str, str] | None | HTTPHeaders = None, + body: bytes = b'', + ): + self.headers = HTTPHeaders(get_default_headers()) # type: ignore + if headers is not None: + self.headers.update(headers) + self.status_code = status_code + self.body = body + self.data_written = False + + @property + def reason(self) -> str: + return httputil.responses.get(self.status_code, 'Unknown') + + +def get_default_headers() -> Mapping[str, str | None]: + request_id = request_context.get_request_id() or '' + return { + 'Server': f'Frontik/{frontik_version}', + 'X-Request-Id': request_id, + } diff --git a/frontik/handler.py b/frontik/handler.py index 545ed150f..8702312aa 100644 --- a/frontik/handler.py +++ b/frontik/handler.py @@ -1064,11 +1064,3 @@ async def handler_getter(request: Request) -> PageHandlerT: return request['handler'] return Depends(handler_getter) - - -def get_default_headers() -> dict[str, str]: - request_id = request_context.get_request_id() or '' - return { - 'Server': f'Frontik/{frontik_version}', - 'X-Request-Id': request_id, - } diff --git a/frontik/handler_asgi.py b/frontik/handler_asgi.py index 592c63f4e..fd8623e38 100644 --- a/frontik/handler_asgi.py +++ b/frontik/handler_asgi.py @@ -4,17 +4,17 @@ import http.client import logging from functools import partial -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import TYPE_CHECKING, Optional from tornado import httputil -from tornado.httputil import HTTPHeaders, HTTPServerRequest +from tornado.httputil import HTTPServerRequest from frontik import media_types, request_context from frontik.debug import DebugMode, DebugTransform -from frontik.handler import PageHandler, get_default_headers, log_request +from frontik.frontik_response import FrontikResponse +from frontik.handler import PageHandler, log_request from frontik.handler_active_limit import request_limiter from frontik.http_status import CLIENT_CLOSED_REQUEST -from frontik.json_builder import JsonBuilder from frontik.options import options from frontik.routing import find_route, get_allowed_methods, method_not_allowed_router, not_found_router from frontik.util import check_request_id, generate_uniq_timestamp_request_id @@ -46,8 +46,8 @@ async def serve_tornado_request( partial(_on_connection_close, tornado_request, process_request_task) ) - status, reason, headers, data = await process_request_task - log_request(tornado_request, status) + response = await process_request_task + log_request(tornado_request, response.status_code) assert tornado_request.connection is not None tornado_request.connection.set_close_callback(None) # type: ignore @@ -55,34 +55,35 @@ async def serve_tornado_request( if getattr(tornado_request, 'canceled', False): return None - start_line = httputil.ResponseStartLine('', status, reason) - future = tornado_request.connection.write_headers(start_line, headers, data) + if not response.data_written: + start_line = httputil.ResponseStartLine('', response.status_code, response.reason) + await tornado_request.connection.write_headers(start_line, response.headers, response.body) + tornado_request.connection.finish() - return await future async def process_request( frontik_app: FrontikApplication, asgi_app: FrontikAsgiApp, tornado_request: httputil.HTTPServerRequest, -) -> tuple[int, str, HTTPHeaders, bytes]: +) -> FrontikResponse: with request_limiter(frontik_app.statsd_client) as accepted: if not accepted: - status, reason, headers, data = make_not_accepted_response() + response = make_not_accepted_response() else: - status, reason, headers, data = await execute_page(frontik_app, asgi_app, tornado_request) - headers.add( + response = await execute_page(frontik_app, asgi_app, tornado_request) + response.headers.add( 'Server-Timing', f'frontik;desc="frontik execution time";dur={tornado_request.request_time()!s}' ) - return status, reason, headers, data + return response async def execute_page( frontik_app: FrontikApplication, asgi_app: FrontikAsgiApp, tornado_request: HTTPServerRequest, -) -> tuple[int, str, HTTPHeaders, bytes]: +) -> FrontikResponse: debug_mode = make_debug_mode(frontik_app, tornado_request) if debug_mode.auth_failed(): assert debug_mode.failed_auth_header is not None @@ -92,26 +93,19 @@ async def execute_page( assert tornado_request.protocol == 'http' scope = find_route(tornado_request.path, tornado_request.method) - data: bytes if scope['route'] is None: - status, reason, headers, data = await make_not_found_response(frontik_app, tornado_request, debug_mode) + response = await make_not_found_response(frontik_app, tornado_request, debug_mode) elif scope['page_cls'] is not None: - status, reason, headers, data = await execute_tornado_page(frontik_app, tornado_request, scope, debug_mode) + response = await execute_tornado_page(frontik_app, tornado_request, scope, debug_mode) else: - status, reason, headers, data = await execute_asgi_page( - asgi_app, - tornado_request, - scope, - debug_mode, - ) + response = await execute_asgi_page(asgi_app, tornado_request, scope, debug_mode) - if debug_mode.enabled: + if debug_mode.enabled and not response.data_written: debug_transform = DebugTransform(frontik_app, debug_mode) - status, headers, data = debug_transform.transform_chunk(tornado_request, status, headers, data) - reason = httputil.responses.get(status, 'Unknown') + response = debug_transform.transform_chunk(tornado_request, response) - return status, reason, headers, data + return response async def execute_asgi_page( @@ -119,45 +113,73 @@ async def execute_asgi_page( tornado_request: HTTPServerRequest, scope: dict, debug_mode: DebugMode, -) -> tuple[int, str, HTTPHeaders, bytes]: +) -> FrontikResponse: request_context.set_handler_name(scope['route']) - result: dict = {'headers': get_default_headers()} - scope, receive, send = convert_tornado_request_to_asgi( - asgi_app, - tornado_request, - scope, - debug_mode, - result, - ) - await asgi_app(scope, receive, send) - status: int = result['status'] - reason = httputil.responses.get(status, 'Unknown') - headers = HTTPHeaders(result['headers']) - data = result['data'] + response = FrontikResponse(status_code=200) - if not scope['json_builder'].is_empty(): - if data != b'null': - raise RuntimeError('Cant have "return" and "json.put" at the same time') + request_headers = [ + (header.encode(CHARSET).lower(), value.encode(CHARSET)) + for header in tornado_request.headers + for value in tornado_request.headers.get_list(header) + ] - headers['Content-Type'] = media_types.APPLICATION_JSON - data = scope['json_builder'].to_bytes() - headers['Content-Length'] = str(len(data)) + scope.update({ + 'http_version': tornado_request.version, + 'query_string': tornado_request.query.encode(CHARSET), + 'headers': request_headers, + 'client': (tornado_request.remote_ip, 0), + 'http_client_factory': asgi_app.http_client_factory, + 'debug_mode': debug_mode, + 'start_time': tornado_request._start_time, + }) - return status, reason, headers, data + async def receive(): + await asyncio.sleep(0) + return { + 'body': tornado_request.body, + 'type': 'http.request', + 'more_body': False, + } + + async def send(data): + assert tornado_request.connection is not None + + if data['type'] == 'http.response.start': + response.status_code = int(data['status']) + for h in data['headers']: + if len(h) == 2: + response.headers.add(h[0].decode(CHARSET), h[1].decode(CHARSET)) + elif data['type'] == 'http.response.body': + chunk = data['body'] + if debug_mode.enabled or not data.get('more_body'): + response.body += chunk + elif not response.data_written: + await tornado_request.connection.write_headers( + start_line=httputil.ResponseStartLine('', response.status_code, response.reason), + headers=response.headers, + chunk=chunk, + ) + response.data_written = True + else: + await tornado_request.connection.write(chunk) + else: + raise RuntimeError(f'Unsupported response type "{data["type"]}" for asgi app') + + await asgi_app(scope, receive, send) + + return response async def make_not_found_response( frontik_app: FrontikApplication, tornado_request: httputil.HTTPServerRequest, debug_mode: DebugMode, -) -> tuple[int, str, HTTPHeaders, bytes]: +) -> FrontikResponse: allowed_methods = get_allowed_methods(tornado_request.path) - default_headers = get_default_headers() - headers: Any if allowed_methods and len(method_not_allowed_router.routes) != 0: - status, _, headers, data = await execute_tornado_page( + return await execute_tornado_page( frontik_app, tornado_request, { @@ -167,24 +189,18 @@ async def make_not_found_response( }, debug_mode, ) - elif allowed_methods: - status = 405 - headers = {'Allow': ', '.join(allowed_methods)} - data = b'' - elif len(not_found_router.routes) != 0: - status, _, headers, data = await execute_tornado_page( + + if allowed_methods: + return FrontikResponse(status_code=405, headers={'Allow': ', '.join(allowed_methods)}) + + if len(not_found_router.routes) != 0: + return await execute_tornado_page( frontik_app, tornado_request, {'route': not_found_router.routes[0], 'page_cls': not_found_router._cls, 'path_params': {}}, debug_mode, ) - else: - status, headers, data = build_error_data(404, 'Not Found') - - default_headers.update(headers) - - reason = httputil.responses.get(status, 'Unknown') - return status, reason, HTTPHeaders(headers), data + return build_error_data(404, 'Not Found') def make_debug_mode(frontik_app: FrontikApplication, tornado_request: HTTPServerRequest) -> DebugMode: @@ -201,28 +217,18 @@ def make_debug_mode(frontik_app: FrontikApplication, tornado_request: HTTPServer return debug_mode -def make_debug_auth_failed_response(auth_header: str) -> tuple[int, str, HTTPHeaders, bytes]: - status = http.client.UNAUTHORIZED - reason = httputil.responses.get(status, 'Unknown') - headers = get_default_headers() - headers['WWW-Authenticate'] = auth_header - - return status, reason, HTTPHeaders(headers), b'' +def make_debug_auth_failed_response(auth_header: str) -> FrontikResponse: + return FrontikResponse(status_code=http.client.UNAUTHORIZED, headers={'WWW-Authenticate': auth_header}) -def make_not_accepted_response() -> tuple[int, str, HTTPHeaders, bytes]: - status = http.client.SERVICE_UNAVAILABLE - reason = httputil.responses.get(status, 'Unknown') - headers = get_default_headers() - return status, reason, HTTPHeaders(headers), b'' +def make_not_accepted_response() -> FrontikResponse: + return FrontikResponse(status_code=http.client.SERVICE_UNAVAILABLE) -def build_error_data( - status_code: int = 500, message: Optional[str] = 'Internal Server Error' -) -> tuple[int, dict, bytes]: +def build_error_data(status_code: int = 500, message: Optional[str] = 'Internal Server Error') -> FrontikResponse: headers = {'Content-Type': media_types.TEXT_HTML} data = f'