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'):
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)