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, List
 
  11 from pathlib import Path
 
  15 from falcon.asgi import App, Request, Response
 
  17 from nominatim.api import NominatimAPIAsync
 
  18 import nominatim.api.v1 as api_impl
 
  19 from nominatim.config import Configuration
 
  21 class HTTPNominatimError(Exception):
 
  22     """ A special exception class for errors raised during processing.
 
  24     def __init__(self, msg: str, status: int, content_type: str) -> None:
 
  27         self.content_type = content_type
 
  30 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  31                                   exception: HTTPNominatimError,
 
  33     """ Special error handler that passes message and content type as
 
  36     resp.status = exception.status
 
  37     resp.text = exception.msg
 
  38     resp.content_type = exception.content_type
 
  41 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  42                                 exception: TimeoutError, #pylint: disable=unused-argument
 
  44     """ Special error handler that passes message and content type as
 
  48     resp.text = "Query took too long to process."
 
  49     resp.content_type = 'text/plain; charset=utf-8'
 
  52 class ParamWrapper(api_impl.ASGIAdaptor):
 
  53     """ Adaptor class for server glue to Falcon framework.
 
  56     def __init__(self, req: Request, resp: Response,
 
  57                  config: Configuration) -> None:
 
  63     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  64         return cast(Optional[str], self.request.get_param(name, default=default))
 
  67     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  68         return cast(Optional[str], self.request.get_header(name, default=default))
 
  71     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
 
  72         return HTTPNominatimError(msg, status, self.content_type)
 
  75     def create_response(self, status: int, output: str, num_results: int) -> None:
 
  76         self.response.context.num_results = num_results
 
  77         self.response.status = status
 
  78         self.response.text = output
 
  79         self.response.content_type = self.content_type
 
  82     def base_uri(self) -> str:
 
  83         return cast (str, self.request.forwarded_prefix)
 
  85     def config(self) -> Configuration:
 
  89 class EndpointWrapper:
 
  90     """ Converter for server glue endpoint functions to Falcon request handlers.
 
  93     def __init__(self, name: str, func: api_impl.EndpointFunc, api: NominatimAPIAsync) -> None:
 
  99     async def on_get(self, req: Request, resp: Response) -> None:
 
 100         """ Implementation of the endpoint.
 
 102         await self.func(self.api, ParamWrapper(req, resp, self.api.config))
 
 105 class FileLoggingMiddleware:
 
 106     """ Middleware to log selected requests into a file.
 
 109     def __init__(self, file_name: str):
 
 110         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
 
 113     async def process_request(self, req: Request, _: Response) -> None:
 
 114         """ Callback before the request starts timing.
 
 116         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
 
 119     async def process_response(self, req: Request, resp: Response,
 
 120                                resource: Optional[EndpointWrapper],
 
 121                                req_succeeded: bool) -> None:
 
 122         """ Callback after requests writes to the logfile. It only
 
 123             writes logs for sucessful requests for search, reverse and lookup.
 
 125         if not req_succeeded or resource is None or resp.status != 200\
 
 126             or resource.name not in ('reverse', 'search', 'lookup', 'details'):
 
 129         finish = dt.datetime.now(tz=dt.timezone.utc)
 
 130         duration = (finish - req.context.start).total_seconds()
 
 131         params = req.scope['query_string'].decode('utf8')
 
 132         start = req.context.start.replace(tzinfo=None)\
 
 133                                  .isoformat(sep=' ', timespec='milliseconds')
 
 135         self.fd.write(f"[{start}] "
 
 136                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
 
 137                       f'{resource.name} "{params}"\n')
 
 141     """ Middleware that closes any open database connections.
 
 144     def __init__(self, api: NominatimAPIAsync) -> None:
 
 147     async def process_shutdown(self, *_: Any) -> None:
 
 148         """Process the ASGI lifespan shutdown event.
 
 150         await self.api.close()
 
 153 def get_application(project_dir: Path,
 
 154                     environ: Optional[Mapping[str, str]] = None) -> App:
 
 155     """ Create a Nominatim Falcon ASGI application.
 
 157     api = NominatimAPIAsync(project_dir, environ)
 
 159     middleware: List[object] = [APIShutdown(api)]
 
 160     log_file = api.config.LOG_FILE
 
 162         middleware.append(FileLoggingMiddleware(log_file))
 
 164     app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
 
 165               middleware=middleware)
 
 166     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
 
 167     app.add_error_handler(TimeoutError, timeout_error_handler)
 
 168     # different from TimeoutError in Python <= 3.10
 
 169     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)
 
 171     legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
 
 172     for name, func in api_impl.ROUTES:
 
 173         endpoint = EndpointWrapper(name, func, api)
 
 174         app.add_route(f"/{name}", endpoint)
 
 176             app.add_route(f"/{name}.php", endpoint)
 
 181 def run_wsgi() -> App:
 
 182     """ Entry point for uvicorn.
 
 184         Make sure uvicorn is run from the project directory.
 
 186     return get_application(Path('.'))