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 import nominatim.api.logging as loglib
 
  20 from nominatim.config import Configuration
 
  22 class HTTPNominatimError(Exception):
 
  23     """ A special exception class for errors raised during processing.
 
  25     def __init__(self, msg: str, status: int, content_type: str) -> None:
 
  28         self.content_type = content_type
 
  31 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  32                                   exception: HTTPNominatimError,
 
  34     """ Special error handler that passes message and content type as
 
  37     resp.status = exception.status
 
  38     resp.text = exception.msg
 
  39     resp.content_type = exception.content_type
 
  42 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
 
  43                                 exception: TimeoutError, #pylint: disable=unused-argument
 
  45     """ Special error handler that passes message and content type as
 
  50     loglib.log().comment('Aborted: Query took too long to process.')
 
  51     logdata = loglib.get_and_disable()
 
  54         resp.content_type = 'text/html; charset=utf-8'
 
  56         resp.text = "Query took too long to process."
 
  57         resp.content_type = 'text/plain; charset=utf-8'
 
  60 class ParamWrapper(api_impl.ASGIAdaptor):
 
  61     """ Adaptor class for server glue to Falcon framework.
 
  64     def __init__(self, req: Request, resp: Response,
 
  65                  config: Configuration) -> None:
 
  71     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  72         return cast(Optional[str], self.request.get_param(name, default=default))
 
  75     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  76         return cast(Optional[str], self.request.get_header(name, default=default))
 
  79     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
 
  80         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
 
  90     def base_uri(self) -> str:
 
  91         return cast (str, self.request.forwarded_prefix)
 
  93     def config(self) -> Configuration:
 
  97 class EndpointWrapper:
 
  98     """ Converter for server glue endpoint functions to Falcon request handlers.
 
 101     def __init__(self, name: str, func: api_impl.EndpointFunc, api: NominatimAPIAsync) -> None:
 
 107     async def on_get(self, req: Request, resp: Response) -> None:
 
 108         """ Implementation of the endpoint.
 
 110         await self.func(self.api, ParamWrapper(req, resp, self.api.config))
 
 113 class FileLoggingMiddleware:
 
 114     """ Middleware to log selected requests into a file.
 
 117     def __init__(self, file_name: str):
 
 118         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
 
 121     async def process_request(self, req: Request, _: Response) -> None:
 
 122         """ Callback before the request starts timing.
 
 124         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
 
 127     async def process_response(self, req: Request, resp: Response,
 
 128                                resource: Optional[EndpointWrapper],
 
 129                                req_succeeded: bool) -> None:
 
 130         """ Callback after requests writes to the logfile. It only
 
 131             writes logs for successful requests for search, reverse and lookup.
 
 133         if not req_succeeded or resource is None or resp.status != 200\
 
 134             or resource.name not in ('reverse', 'search', 'lookup', 'details'):
 
 137         finish = dt.datetime.now(tz=dt.timezone.utc)
 
 138         duration = (finish - req.context.start).total_seconds()
 
 139         params = req.scope['query_string'].decode('utf8')
 
 140         start = req.context.start.replace(tzinfo=None)\
 
 141                                  .isoformat(sep=' ', timespec='milliseconds')
 
 143         self.fd.write(f"[{start}] "
 
 144                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
 
 145                       f'{resource.name} "{params}"\n')
 
 149     """ Middleware that closes any open database connections.
 
 152     def __init__(self, api: NominatimAPIAsync) -> None:
 
 155     async def process_shutdown(self, *_: Any) -> None:
 
 156         """Process the ASGI lifespan shutdown event.
 
 158         await self.api.close()
 
 161 def get_application(project_dir: Path,
 
 162                     environ: Optional[Mapping[str, str]] = None) -> App:
 
 163     """ Create a Nominatim Falcon ASGI application.
 
 165     api = NominatimAPIAsync(project_dir, environ)
 
 167     middleware: List[object] = [APIShutdown(api)]
 
 168     log_file = api.config.LOG_FILE
 
 170         middleware.append(FileLoggingMiddleware(log_file))
 
 172     app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
 
 173               middleware=middleware)
 
 174     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
 
 175     app.add_error_handler(TimeoutError, timeout_error_handler)
 
 176     # different from TimeoutError in Python <= 3.10
 
 177     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)
 
 179     legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
 
 180     for name, func in api_impl.ROUTES:
 
 181         endpoint = EndpointWrapper(name, func, api)
 
 182         app.add_route(f"/{name}", endpoint)
 
 184             app.add_route(f"/{name}.php", endpoint)
 
 189 def run_wsgi() -> App:
 
 190     """ Entry point for uvicorn.
 
 192         Make sure uvicorn is run from the project directory.
 
 194     return get_application(Path('.'))