1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2024 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, Any, List
 
  11 from pathlib import Path
 
  15 from falcon.asgi import App, Request, Response
 
  17 from ...config import Configuration
 
  18 from ...core import NominatimAPIAsync
 
  19 from ... import v1 as api_impl
 
  20 from ...result_formatting import FormatDispatcher, load_format_dispatcher
 
  21 from ... import logging as loglib
 
  22 from ..asgi_adaptor import ASGIAdaptor, EndpointFunc
 
  25 class HTTPNominatimError(Exception):
 
  26     """ A special exception class for errors raised during processing.
 
  28     def __init__(self, msg: str, status: int, content_type: str) -> None:
 
  31         self.content_type = content_type
 
  34 async def nominatim_error_handler(req: Request, resp: Response,
 
  35                                   exception: HTTPNominatimError,
 
  37     """ Special error handler that passes message and content type as
 
  40     resp.status = exception.status
 
  41     resp.text = exception.msg
 
  42     resp.content_type = exception.content_type
 
  45 async def timeout_error_handler(req: Request, resp: Response,
 
  46                                 exception: TimeoutError,
 
  48     """ Special error handler that passes message and content type as
 
  53     loglib.log().comment('Aborted: Query took too long to process.')
 
  54     logdata = loglib.get_and_disable()
 
  57         resp.content_type = 'text/html; charset=utf-8'
 
  59         resp.text = "Query took too long to process."
 
  60         resp.content_type = 'text/plain; charset=utf-8'
 
  63 class ParamWrapper(ASGIAdaptor):
 
  64     """ Adaptor class for server glue to Falcon framework.
 
  67     def __init__(self, req: Request, resp: Response,
 
  68                  config: Configuration, formatter: FormatDispatcher) -> None:
 
  72         self._formatter = formatter
 
  74     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  75         return self.request.get_param(name, default=default)
 
  77     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  78         return self.request.get_header(name, default=default)
 
  80     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
 
  81         return HTTPNominatimError(msg, status, self.content_type)
 
  83     def create_response(self, status: int, output: str, num_results: int) -> None:
 
  84         self.response.context.num_results = num_results
 
  85         self.response.status = status
 
  86         self.response.text = output
 
  87         self.response.content_type = self.content_type
 
  89     def base_uri(self) -> str:
 
  90         return self.request.forwarded_prefix
 
  92     def config(self) -> Configuration:
 
  95     def formatting(self) -> FormatDispatcher:
 
  96         return self._formatter
 
  99 class EndpointWrapper:
 
 100     """ Converter for server glue endpoint functions to Falcon request handlers.
 
 103     def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync,
 
 104                  formatter: FormatDispatcher) -> None:
 
 108         self.formatter = formatter
 
 110     async def on_get(self, req: Request, resp: Response) -> None:
 
 111         """ Implementation of the endpoint.
 
 113         await self.func(self.api, ParamWrapper(req, resp, self.api.config,
 
 117 class FileLoggingMiddleware:
 
 118     """ Middleware to log selected requests into a file.
 
 121     def __init__(self, file_name: str):
 
 122         self.fd = open(file_name, 'a', buffering=1, encoding='utf8')
 
 124     async def process_request(self, req: Request, _: Response) -> None:
 
 125         """ Callback before the request starts timing.
 
 127         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
 
 129     async def process_response(self, req: Request, resp: Response,
 
 130                                resource: Optional[EndpointWrapper],
 
 131                                req_succeeded: bool) -> None:
 
 132         """ Callback after requests writes to the logfile. It only
 
 133             writes logs for successful requests for search, reverse and lookup.
 
 135         if not req_succeeded or resource is None or resp.status != 200\
 
 136            or resource.name not in ('reverse', 'search', 'lookup', 'details'):
 
 139         finish = dt.datetime.now(tz=dt.timezone.utc)
 
 140         duration = (finish - req.context.start).total_seconds()
 
 141         params = req.scope['query_string'].decode('utf8')
 
 142         start = req.context.start.replace(tzinfo=None)\
 
 143                                  .isoformat(sep=' ', timespec='milliseconds')
 
 145         self.fd.write(f"[{start}] "
 
 146                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
 
 147                       f'{resource.name} "{params}"\n')
 
 151     """ Middleware managing the Nominatim database connection.
 
 154     def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
 
 155         self.api = NominatimAPIAsync(project_dir, environ)
 
 156         self.app: Optional[App] = None
 
 159     def config(self) -> Configuration:
 
 160         """ Get the configuration for Nominatim.
 
 162         return self.api.config
 
 164     def set_app(self, app: App) -> None:
 
 165         """ Set the Falcon application this middleware is connected to.
 
 169     async def process_startup(self, *_: Any) -> None:
 
 170         """ Process the ASGI lifespan startup event.
 
 172         assert self.app is not None
 
 173         legacy_urls = self.api.config.get_bool('SERVE_LEGACY_URLS')
 
 174         formatter = load_format_dispatcher('v1', self.api.config.project_dir)
 
 175         for name, func in await api_impl.get_routes(self.api):
 
 176             endpoint = EndpointWrapper(name, func, self.api, formatter)
 
 177             self.app.add_route(f"/{name}", endpoint)
 
 179                 self.app.add_route(f"/{name}.php", endpoint)
 
 181     async def process_shutdown(self, *_: Any) -> None:
 
 182         """Process the ASGI lifespan shutdown event.
 
 184         await self.api.close()
 
 187 def get_application(project_dir: Path,
 
 188                     environ: Optional[Mapping[str, str]] = None) -> App:
 
 189     """ Create a Nominatim Falcon ASGI application.
 
 191     apimw = APIMiddleware(project_dir, environ)
 
 193     middleware: List[Any] = [apimw]
 
 194     log_file = apimw.config.LOG_FILE
 
 196         middleware.append(FileLoggingMiddleware(log_file))
 
 198     app = App(cors_enable=apimw.config.get_bool('CORS_NOACCESSCONTROL'),
 
 199               middleware=middleware)
 
 202     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
 
 203     app.add_error_handler(TimeoutError, timeout_error_handler)
 
 204     # different from TimeoutError in Python <= 3.10
 
 205     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)  # type: ignore[arg-type]
 
 210 def run_wsgi() -> App:
 
 211     """ Entry point for uvicorn.
 
 213         Make sure uvicorn is run from the project directory.
 
 215     return get_application(Path('.'))