configurable error formatting and content type in result formatter

This commit is contained in:
Sarah Hoffmann
2024-08-14 11:59:20 +02:00
parent 52ee5dc73c
commit 5a61d3d5f6
6 changed files with 98 additions and 34 deletions

View File

@@ -12,8 +12,11 @@ from collections import defaultdict
from pathlib import Path from pathlib import Path
import importlib import importlib
from .server.content_types import CONTENT_JSON
T = TypeVar('T') # pylint: disable=invalid-name T = TypeVar('T') # pylint: disable=invalid-name
FormatFunc = Callable[[T, Mapping[str, Any]], str] FormatFunc = Callable[[T, Mapping[str, Any]], str]
ErrorFormatFunc = Callable[[str, str, int], str]
class FormatDispatcher: class FormatDispatcher:
@@ -21,7 +24,11 @@ class FormatDispatcher:
a module using decorators. a module using decorators.
""" """
def __init__(self) -> None: def __init__(self, content_types: Optional[Mapping[str, str]] = None) -> None:
self.error_handler: ErrorFormatFunc = lambda ct, msg, status: f"ERROR {status}: {msg}"
self.content_types: Dict[str, str] = {}
if content_types:
self.content_types.update(content_types)
self.format_functions: Dict[Type[Any], Dict[str, FormatFunc[Any]]] = defaultdict(dict) self.format_functions: Dict[Type[Any], Dict[str, FormatFunc[Any]]] = defaultdict(dict)
@@ -37,6 +44,15 @@ class FormatDispatcher:
return decorator return decorator
def error_format_func(self, func: ErrorFormatFunc) -> ErrorFormatFunc:
""" Decorator for a function that formats error messges.
There is only one error formatter per dispatcher. Using
the decorator repeatedly will overwrite previous functions.
"""
self.error_handler = func
return func
def list_formats(self, result_type: Type[Any]) -> List[str]: def list_formats(self, result_type: Type[Any]) -> List[str]:
""" Return a list of formats supported by this formatter. """ Return a list of formats supported by this formatter.
""" """
@@ -58,6 +74,32 @@ class FormatDispatcher:
return self.format_functions[type(result)][fmt](result, options) return self.format_functions[type(result)][fmt](result, options)
def format_error(self, content_type: str, msg: str, status: int) -> str:
""" Convert the given error message into a response string
taking the requested content_type into account.
Change the format using the error_format_func decorator.
"""
return self.error_handler(content_type, msg, status)
def set_content_type(self, fmt: str, content_type: str) -> None:
""" Set the content type for the given format. This is the string
that will be returned in the Content-Type header of the HTML
response, when the given format is choosen.
"""
self.content_types[fmt] = content_type
def get_content_type(self, fmt: str) -> str:
""" Return the content type for the given format.
If no explicit content type has been defined, then
JSON format is assumed.
"""
return self.content_types.get(fmt, CONTENT_JSON)
def load_format_dispatcher(api_name: str, project_dir: Optional[Path]) -> FormatDispatcher: def load_format_dispatcher(api_name: str, project_dir: Optional[Path]) -> FormatDispatcher:
""" Load the dispatcher for the given API. """ Load the dispatcher for the given API.

View File

@@ -12,16 +12,9 @@ import abc
import math import math
from ..config import Configuration from ..config import Configuration
from .. import logging as loglib
from ..core import NominatimAPIAsync from ..core import NominatimAPIAsync
from ..result_formatting import FormatDispatcher from ..result_formatting import FormatDispatcher
from .content_types import CONTENT_TEXT
CONTENT_TEXT = 'text/plain; charset=utf-8'
CONTENT_XML = 'text/xml; charset=utf-8'
CONTENT_HTML = 'text/html; charset=utf-8'
CONTENT_JSON = 'application/json; charset=utf-8'
CONTENT_TYPE = {'text': CONTENT_TEXT, 'xml': CONTENT_XML, 'debug': CONTENT_HTML}
class ASGIAdaptor(abc.ABC): class ASGIAdaptor(abc.ABC):
""" Adapter class for the different ASGI frameworks. """ Adapter class for the different ASGI frameworks.
@@ -156,22 +149,8 @@ class ASGIAdaptor(abc.ABC):
message. The message will be formatted according to the message. The message will be formatted according to the
output format chosen by the request. output format chosen by the request.
""" """
if self.content_type == CONTENT_XML: raise self.error(self.formatting().format_error(self.content_type, msg, status),
msg = f"""<?xml version="1.0" encoding="UTF-8" ?> status)
<error>
<code>{status}</code>
<message>{msg}</message>
</error>
"""
elif self.content_type == CONTENT_JSON:
msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
elif self.content_type == CONTENT_HTML:
loglib.log().section('Execution error')
loglib.log().var_dump('Status', status)
loglib.log().var_dump('Message', msg)
msg = loglib.get_and_disable()
raise self.error(msg, status)
EndpointFunc = Callable[[NominatimAPIAsync, ASGIAdaptor], Any] EndpointFunc = Callable[[NominatimAPIAsync, ASGIAdaptor], Any]

View File

@@ -0,0 +1,14 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Constants for various content types for server responses.
"""
CONTENT_TEXT = 'text/plain; charset=utf-8'
CONTENT_XML = 'text/xml; charset=utf-8'
CONTENT_HTML = 'text/html; charset=utf-8'
CONTENT_JSON = 'application/json; charset=utf-8'

View File

@@ -19,12 +19,38 @@ from ..localization import Locales
from ..result_formatting import FormatDispatcher from ..result_formatting import FormatDispatcher
from .classtypes import ICONS from .classtypes import ICONS
from . import format_json, format_xml from . import format_json, format_xml
from .. import logging as loglib
from ..server import content_types as ct
class RawDataList(List[Dict[str, Any]]): class RawDataList(List[Dict[str, Any]]):
""" Data type for formatting raw data lists 'as is' in json. """ Data type for formatting raw data lists 'as is' in json.
""" """
dispatch = FormatDispatcher() dispatch = FormatDispatcher({'text': ct.CONTENT_TEXT,
'xml': ct.CONTENT_XML,
'debug': ct.CONTENT_HTML})
@dispatch.error_format_func
def _format_error(content_type: str, msg: str, status: int) -> str:
if content_type == ct.CONTENT_XML:
return f"""<?xml version="1.0" encoding="UTF-8" ?>
<error>
<code>{status}</code>
<message>{msg}</message>
</error>
"""
if content_type == ct.CONTENT_JSON:
return f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
if content_type == ct.CONTENT_HTML:
loglib.log().section('Execution error')
loglib.log().var_dump('Status', status)
loglib.log().var_dump('Message', msg)
return loglib.get_and_disable()
return f"ERROR {status}: {msg}"
@dispatch.format_func(StatusResult, 'text') @dispatch.format_func(StatusResult, 'text')
def _format_status_text(result: StatusResult, _: Mapping[str, Any]) -> str: def _format_status_text(result: StatusResult, _: Mapping[str, Any]) -> str:

View File

@@ -24,14 +24,15 @@ from ..status import StatusResult
from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
from ..localization import Locales from ..localization import Locales
from . import helpers from . import helpers
from ..server.asgi_adaptor import CONTENT_HTML, CONTENT_JSON, CONTENT_TYPE, ASGIAdaptor from ..server import content_types as ct
from ..server.asgi_adaptor import ASGIAdaptor
def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200, def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
num_results: int = 0) -> Any: num_results: int = 0) -> Any:
""" Create a response from the given output. Wraps a JSONP function """ Create a response from the given output. Wraps a JSONP function
around the response, if necessary. around the response, if necessary.
""" """
if adaptor.content_type == CONTENT_JSON and status == 200: if adaptor.content_type == ct.CONTENT_JSON and status == 200:
jsonp = adaptor.get('json_callback') jsonp = adaptor.get('json_callback')
if jsonp is not None: if jsonp is not None:
if any(not part.isidentifier() for part in jsonp.split('.')): if any(not part.isidentifier() for part in jsonp.split('.')):
@@ -57,7 +58,7 @@ def setup_debugging(adaptor: ASGIAdaptor) -> bool:
""" """
if adaptor.get_bool('debug', False): if adaptor.get_bool('debug', False):
loglib.set_log_output('html') loglib.set_log_output('html')
adaptor.content_type = CONTENT_HTML adaptor.content_type = ct.CONTENT_HTML
return True return True
return False return False
@@ -83,11 +84,13 @@ def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) ->
fmt = adaptor.get('format', default=default) fmt = adaptor.get('format', default=default)
assert fmt is not None assert fmt is not None
if not adaptor.formatting().supports_format(result_type, fmt): formatting = adaptor.formatting()
adaptor.raise_error("Parameter 'format' must be one of: " +
', '.join(adaptor.formatting().list_formats(result_type)))
adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON) if not formatting.supports_format(result_type, fmt):
adaptor.raise_error("Parameter 'format' must be one of: " +
', '.join(formatting.list_formats(result_type)))
adaptor.content_type = formatting.get_content_type(fmt)
return fmt return fmt

View File

@@ -127,7 +127,7 @@ class TestAdaptorRaiseError:
err = self.run_raise_error('TEST', 404) err = self.run_raise_error('TEST', 404)
assert self.adaptor.content_type == 'text/plain; charset=utf-8' assert self.adaptor.content_type == 'text/plain; charset=utf-8'
assert err.msg == 'TEST' assert err.msg == 'ERROR 404: TEST'
assert err.status == 404 assert err.status == 404