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 starlette webserver framework.
 
  10 from typing import Any, Optional, Mapping, Callable, cast, Coroutine, Dict, Awaitable
 
  11 from pathlib import Path
 
  14 from starlette.applications import Starlette
 
  15 from starlette.routing import Route
 
  16 from starlette.exceptions import HTTPException
 
  17 from starlette.responses import Response, PlainTextResponse
 
  18 from starlette.requests import Request
 
  19 from starlette.middleware import Middleware
 
  20 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
 
  21 from starlette.middleware.cors import CORSMiddleware
 
  23 from nominatim.api import NominatimAPIAsync
 
  24 import nominatim.api.v1 as api_impl
 
  25 from nominatim.config import Configuration
 
  27 class ParamWrapper(api_impl.ASGIAdaptor):
 
  28     """ Adaptor class for server glue to Starlette framework.
 
  31     def __init__(self, request: Request) -> None:
 
  32         self.request = request
 
  35     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  36         return self.request.query_params.get(name, default=default)
 
  39     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
 
  40         return self.request.headers.get(name, default)
 
  43     def error(self, msg: str, status: int = 400) -> HTTPException:
 
  44         return HTTPException(status, detail=msg,
 
  45                              headers={'content-type': self.content_type})
 
  48     def create_response(self, status: int, output: str, num_results: int) -> Response:
 
  49         self.request.state.num_results = num_results
 
  50         return Response(output, status_code=status, media_type=self.content_type)
 
  53     def base_uri(self) -> str:
 
  54         scheme = self.request.url.scheme
 
  55         host = self.request.url.hostname
 
  56         port = self.request.url.port
 
  57         root = self.request.scope['root_path']
 
  58         if (scheme == 'http' and port == 80) or (scheme == 'https' and port == 443):
 
  61             return f"{scheme}://{host}:{port}{root}"
 
  63         return f"{scheme}://{host}{root}"
 
  66     def config(self) -> Configuration:
 
  67         return cast(Configuration, self.request.app.state.API.config)
 
  70 def _wrap_endpoint(func: api_impl.EndpointFunc)\
 
  71         -> Callable[[Request], Coroutine[Any, Any, Response]]:
 
  72     async def _callback(request: Request) -> Response:
 
  73         return cast(Response, await func(request.app.state.API, ParamWrapper(request)))
 
  78 class FileLoggingMiddleware(BaseHTTPMiddleware):
 
  79     """ Middleware to log selected requests into a file.
 
  82     def __init__(self, app: Starlette, file_name: str = ''):
 
  84         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
 
  86     async def dispatch(self, request: Request,
 
  87                        call_next: RequestResponseEndpoint) -> Response:
 
  88         start = dt.datetime.now(tz=dt.timezone.utc)
 
  89         response = await call_next(request)
 
  91         if response.status_code != 200:
 
  94         finish = dt.datetime.now(tz=dt.timezone.utc)
 
  96         for endpoint in ('reverse', 'search', 'lookup', 'details'):
 
  97             if request.url.path.startswith('/' + endpoint):
 
 103         duration = (finish - start).total_seconds()
 
 104         params = request.scope['query_string'].decode('utf8')
 
 106         self.fd.write(f"[{start.replace(tzinfo=None).isoformat(sep=' ', timespec='milliseconds')}] "
 
 107                       f"{duration:.4f} {getattr(request.state, 'num_results', 0)} "
 
 108                       f'{qtype} "{params}"\n')
 
 113 async def timeout_error(request: Request, #pylint: disable=unused-argument
 
 114                         _: Exception) -> Response:
 
 115     """ Error handler for query timeouts.
 
 117     return PlainTextResponse("Query took too long to process.", status_code=503)
 
 120 def get_application(project_dir: Path,
 
 121                     environ: Optional[Mapping[str, str]] = None,
 
 122                     debug: bool = True) -> Starlette:
 
 123     """ Create a Nominatim falcon ASGI application.
 
 125     config = Configuration(project_dir, environ)
 
 128     legacy_urls = config.get_bool('SERVE_LEGACY_URLS')
 
 129     for name, func in api_impl.ROUTES:
 
 130         endpoint = _wrap_endpoint(func)
 
 131         routes.append(Route(f"/{name}", endpoint=endpoint))
 
 133             routes.append(Route(f"/{name}.php", endpoint=endpoint))
 
 136     if config.get_bool('CORS_NOACCESSCONTROL'):
 
 137         middleware.append(Middleware(CORSMiddleware,
 
 139                                      allow_methods=['GET', 'OPTIONS'],
 
 142     log_file = config.LOG_FILE
 
 144         middleware.append(Middleware(FileLoggingMiddleware, file_name=log_file))
 
 146     exceptions: Dict[Any, Callable[[Request, Exception], Awaitable[Response]]] = {
 
 147         TimeoutError: timeout_error
 
 150     async def _shutdown() -> None:
 
 151         await app.state.API.close()
 
 153     app = Starlette(debug=debug, routes=routes, middleware=middleware,
 
 154                     exception_handlers=exceptions,
 
 155                     on_shutdown=[_shutdown])
 
 157     app.state.API = NominatimAPIAsync(project_dir, environ)
 
 162 def run_wsgi() -> Starlette:
 
 163     """ Entry point for uvicorn.
 
 165     return get_application(Path('.'), debug=False)