1 # SPDX-License-Identifier: GPL-2.0-only
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2023 by the Nominatim developer community.
 
   6 # For a full list of authors see the git log.
 
   8 Server implementation using the falcon webserver framework.
 
  10 from typing import Optional, Mapping, cast, Any
 
  11 from pathlib import Path
 
  14 from falcon.asgi import App, Request, Response
 
  16 from nominatim.api import NominatimAPIAsync
 
  17 import nominatim.api.v1 as api_impl
 
  18 from nominatim.config import Configuration
 
  20 class HTTPNominatimError(Exception):
 
  21     """ A special exception class for errors raised during processing.
 
  23     def __init__(self, msg: str, status: int, content_type: str) -> None:
 
  26         self.content_type = content_type
 
  29 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  30                                   exception: HTTPNominatimError,
 
  32     """ Special error handler that passes message and content type as
 
  35     resp.status = exception.status
 
  36     resp.text = exception.msg
 
  37     resp.content_type = exception.content_type
 
  40 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  41                                 exception: TimeoutError, #pylint: disable=unused-argument
 
  43     """ Special error handler that passes message and content type as
 
  47     resp.text = "Query took too long to process."
 
  48     resp.content_type = 'text/plain; charset=utf-8'
 
  51 class ParamWrapper(api_impl.ASGIAdaptor):
 
  52     """ Adaptor class for server glue to Falcon framework.
 
  55     def __init__(self, req: Request, resp: Response,
 
  56                  config: Configuration) -> None:
 
  62     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  63         return cast(Optional[str], self.request.get_param(name, default=default))
 
  66     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  67         return cast(Optional[str], self.request.get_header(name, default=default))
 
  70     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
 
  71         return HTTPNominatimError(msg, status, self.content_type)
 
  74     def create_response(self, status: int, output: str, num_results: int) -> None:
 
  75         self.response.context.num_results = num_results
 
  76         self.response.status = status
 
  77         self.response.text = output
 
  78         self.response.content_type = self.content_type
 
  81     def base_uri(self) -> str:
 
  82         return cast (str, self.request.forwarded_prefix)
 
  84     def config(self) -> Configuration:
 
  88 class EndpointWrapper:
 
  89     """ Converter for server glue endpoint functions to Falcon request handlers.
 
  92     def __init__(self, name: str, func: api_impl.EndpointFunc, api: NominatimAPIAsync) -> None:
 
  98     async def on_get(self, req: Request, resp: Response) -> None:
 
  99         """ Implementation of the endpoint.
 
 101         await self.func(self.api, ParamWrapper(req, resp, self.api.config))
 
 104 class FileLoggingMiddleware:
 
 105     """ Middleware to log selected requests into a file.
 
 108     def __init__(self, file_name: str):
 
 109         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
 
 112     async def process_request(self, req: Request, _: Response) -> None:
 
 113         """ Callback before the request starts timing.
 
 115         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
 
 118     async def process_response(self, req: Request, resp: Response,
 
 119                                resource: Optional[EndpointWrapper],
 
 120                                req_succeeded: bool) -> None:
 
 121         """ Callback after requests writes to the logfile. It only
 
 122             writes logs for sucessful requests for search, reverse and lookup.
 
 124         if not req_succeeded or resource is None or resp.status != 200\
 
 125             or resource.name not in ('reverse', 'search', 'lookup'):
 
 128         finish = dt.datetime.now(tz=dt.timezone.utc)
 
 129         duration = (finish - req.context.start).total_seconds()
 
 130         params = req.scope['query_string'].decode('utf8')
 
 131         start = req.context.start.replace(tzinfo=None)\
 
 132                                  .isoformat(sep=' ', timespec='milliseconds')
 
 134         self.fd.write(f"[{start}] "
 
 135                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
 
 136                       f'{resource.name} "{params}"\n')
 
 139 def get_application(project_dir: Path,
 
 140                     environ: Optional[Mapping[str, str]] = None) -> App:
 
 141     """ Create a Nominatim Falcon ASGI application.
 
 143     api = NominatimAPIAsync(project_dir, environ)
 
 145     middleware: Optional[object] = None
 
 146     log_file = api.config.LOG_FILE
 
 148         middleware = FileLoggingMiddleware(log_file)
 
 150     app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
 
 151               middleware=middleware)
 
 152     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
 
 153     app.add_error_handler(TimeoutError, timeout_error_handler)
 
 155     legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
 
 156     for name, func in api_impl.ROUTES:
 
 157         endpoint = EndpointWrapper(name, func, api)
 
 158         app.add_route(f"/{name}", endpoint)
 
 160             app.add_route(f"/{name}.php", endpoint)
 
 165 def run_wsgi() -> App:
 
 166     """ Entry point for uvicorn.
 
 168         Make sure uvicorn is run from the project directory.
 
 170     return get_application(Path('.'))