1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2025 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, cast
 
  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 ...types import QueryStatistics
 
  20 from ... import v1 as api_impl
 
  21 from ...result_formatting import FormatDispatcher, load_format_dispatcher
 
  22 from ... import logging as loglib
 
  23 from ..asgi_adaptor import ASGIAdaptor, EndpointFunc
 
  26 class HTTPNominatimError(Exception):
 
  27     """ A special exception class for errors raised during processing.
 
  29     def __init__(self, msg: str, status: int, content_type: str) -> None:
 
  32         self.content_type = content_type
 
  35 async def nominatim_error_handler(req: Request, resp: Response,
 
  36                                   exception: HTTPNominatimError,
 
  38     """ Special error handler that passes message and content type as
 
  41     resp.status = exception.status
 
  42     resp.text = exception.msg
 
  43     resp.content_type = exception.content_type
 
  46 async def timeout_error_handler(req: Request, resp: Response,
 
  47                                 exception: TimeoutError,
 
  49     """ Special error handler that passes message and content type as
 
  54     loglib.log().comment('Aborted: Query took too long to process.')
 
  55     logdata = loglib.get_and_disable()
 
  58         resp.content_type = 'text/html; charset=utf-8'
 
  60         resp.text = "Query took too long to process."
 
  61         resp.content_type = 'text/plain; charset=utf-8'
 
  64 class ParamWrapper(ASGIAdaptor):
 
  65     """ Adaptor class for server glue to Falcon framework.
 
  68     def __init__(self, req: Request, resp: Response,
 
  69                  config: Configuration, formatter: FormatDispatcher) -> None:
 
  73         self._formatter = formatter
 
  75     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  76         return self.request.get_param(name, default=default)
 
  78     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  79         return self.request.get_header(name, default=default)
 
  81     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
 
  82         return HTTPNominatimError(msg, status, self.content_type)
 
  84     def create_response(self, status: int, output: str, num_results: int) -> None:
 
  85         self.response.context.num_results = num_results
 
  86         self.response.status = status
 
  87         self.response.text = output
 
  88         self.response.content_type = self.content_type
 
  90     def base_uri(self) -> str:
 
  91         return self.request.forwarded_prefix
 
  93     def config(self) -> Configuration:
 
  96     def formatting(self) -> FormatDispatcher:
 
  97         return self._formatter
 
  99     def query_stats(self) -> Optional[QueryStatistics]:
 
 100         return cast(Optional[QueryStatistics], getattr(self.request.context, 'query_stats', None))
 
 103 class EndpointWrapper:
 
 104     """ Converter for server glue endpoint functions to Falcon request handlers.
 
 107     def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync,
 
 108                  formatter: FormatDispatcher) -> None:
 
 112         self.formatter = formatter
 
 114     async def on_get(self, req: Request, resp: Response) -> None:
 
 115         """ Implementation of the endpoint.
 
 117         await self.func(self.api, ParamWrapper(req, resp, self.api.config,
 
 121 class FileLoggingMiddleware:
 
 122     """ Middleware to log selected requests into a file.
 
 125     def __init__(self, file_name: str, logstr: str):
 
 126         self.logstr = logstr + '\n'
 
 127         self.fd = open(file_name, 'a', buffering=1, encoding='utf8')
 
 129     async def process_request(self, req: Request, _: Response) -> None:
 
 130         """ Callback before the request starts timing.
 
 132         req.context.query_stats = QueryStatistics()
 
 134     async def process_response(self, req: Request, resp: Response,
 
 135                                resource: Optional[EndpointWrapper],
 
 136                                req_succeeded: bool) -> None:
 
 137         """ Callback after requests writes to the logfile. It only
 
 138             writes logs for successful requests for search, reverse and lookup.
 
 140         qs = req.context.query_stats
 
 142         if not req_succeeded or 'start' not in qs\
 
 143            or resource is None or resp.status != 200\
 
 144            or resource.name not in ('reverse', 'search', 'lookup', 'details'):
 
 147         qs['endpoint'] = resource.name
 
 148         qs['query_string'] = req.scope['query_string'].decode('utf8')
 
 149         qs['results_total'] = getattr(resp.context, 'num_results', 0)
 
 150         for param in ('start', 'end', 'start_query'):
 
 151             if isinstance(qs.get(param), dt.datetime):
 
 152                 qs[param] = qs[param].replace(tzinfo=None)\
 
 153                                      .isoformat(sep=' ', timespec='milliseconds')
 
 155         self.fd.write(self.logstr.format_map(qs))
 
 159     """ Middleware managing the Nominatim database connection.
 
 162     def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
 
 163         self.api = NominatimAPIAsync(project_dir, environ)
 
 164         self.app: Optional[App] = None
 
 167     def config(self) -> Configuration:
 
 168         """ Get the configuration for Nominatim.
 
 170         return self.api.config
 
 172     def set_app(self, app: App) -> None:
 
 173         """ Set the Falcon application this middleware is connected to.
 
 177     async def process_startup(self, *_: Any) -> None:
 
 178         """ Process the ASGI lifespan startup event.
 
 180         assert self.app is not None
 
 181         legacy_urls = self.api.config.get_bool('SERVE_LEGACY_URLS')
 
 182         formatter = load_format_dispatcher('v1', self.api.config.project_dir)
 
 183         for name, func in await api_impl.get_routes(self.api):
 
 184             endpoint = EndpointWrapper(name, func, self.api, formatter)
 
 185             self.app.add_route(f"/{name}", endpoint)
 
 187                 self.app.add_route(f"/{name}.php", endpoint)
 
 189     async def process_shutdown(self, *_: Any) -> None:
 
 190         """Process the ASGI lifespan shutdown event.
 
 192         await self.api.close()
 
 195 def get_application(project_dir: Path,
 
 196                     environ: Optional[Mapping[str, str]] = None) -> App:
 
 197     """ Create a Nominatim Falcon ASGI application.
 
 199     apimw = APIMiddleware(project_dir, environ)
 
 201     middleware: List[Any] = [apimw]
 
 202     log_file = apimw.config.LOG_FILE
 
 204         middleware.append(FileLoggingMiddleware(log_file, apimw.config.LOG_FORMAT))
 
 206     app = App(cors_enable=apimw.config.get_bool('CORS_NOACCESSCONTROL'),
 
 207               middleware=middleware)
 
 210     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
 
 211     app.add_error_handler(TimeoutError, timeout_error_handler)
 
 212     # different from TimeoutError in Python <= 3.10
 
 213     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)  # type: ignore[arg-type]
 
 218 def run_wsgi() -> App:
 
 219     """ Entry point for uvicorn.
 
 221         Make sure uvicorn is run from the project directory.
 
 223     return get_application(Path('.'))