]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/server/starlette/server.py
translate query timeouts into proper HTTP responses
[nominatim.git] / nominatim / server / starlette / server.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Server implementation using the starlette webserver framework.
9 """
10 from typing import Any, Optional, Mapping, Callable, cast, Coroutine, Dict, Awaitable
11 from pathlib import Path
12 import datetime as dt
13
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
22
23 from nominatim.api import NominatimAPIAsync
24 import nominatim.api.v1 as api_impl
25 from nominatim.config import Configuration
26
27 class ParamWrapper(api_impl.ASGIAdaptor):
28     """ Adaptor class for server glue to Starlette framework.
29     """
30
31     def __init__(self, request: Request) -> None:
32         self.request = request
33
34
35     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
36         return self.request.query_params.get(name, default=default)
37
38
39     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
40         return self.request.headers.get(name, default)
41
42
43     def error(self, msg: str, status: int = 400) -> HTTPException:
44         return HTTPException(status, detail=msg,
45                              headers={'content-type': self.content_type})
46
47
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)
51
52
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):
59             port = None
60         if port is not None:
61             return f"{scheme}://{host}:{port}{root}"
62
63         return f"{scheme}://{host}{root}"
64
65
66     def config(self) -> Configuration:
67         return cast(Configuration, self.request.app.state.API.config)
68
69
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)))
74
75     return _callback
76
77
78 class FileLoggingMiddleware(BaseHTTPMiddleware):
79     """ Middleware to log selected requests into a file.
80     """
81
82     def __init__(self, app: Starlette, file_name: str = ''):
83         super().__init__(app)
84         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
85
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)
90
91         if response.status_code != 200:
92             return response
93
94         finish = dt.datetime.now(tz=dt.timezone.utc)
95
96         for endpoint in ('reverse', 'search', 'lookup'):
97             if request.url.path.startswith('/' + endpoint):
98                 qtype = endpoint
99                 break
100         else:
101             return response
102
103         duration = (finish - start).total_seconds()
104         params = request.scope['query_string'].decode('utf8')
105
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')
109
110         return response
111
112
113 async def timeout_error(request: Request, #pylint: disable=unused-argument
114                         _: Exception) -> Response:
115     """ Error handler for query timeouts.
116     """
117     return PlainTextResponse("Query took too long to process.", status_code=503)
118
119
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.
124     """
125     config = Configuration(project_dir, environ)
126
127     routes = []
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))
132         if legacy_urls:
133             routes.append(Route(f"/{name}.php", endpoint=endpoint))
134
135     middleware = []
136     if config.get_bool('CORS_NOACCESSCONTROL'):
137         middleware.append(Middleware(CORSMiddleware,
138                                      allow_origins=['*'],
139                                      allow_methods=['GET', 'OPTIONS'],
140                                      max_age=86400))
141
142     log_file = config.LOG_FILE
143     if log_file:
144         middleware.append(Middleware(FileLoggingMiddleware, file_name=log_file))
145
146     exceptions: Dict[Any, Callable[[Request, Exception], Awaitable[Response]]] = {
147         TimeoutError: timeout_error
148     }
149
150     async def _shutdown() -> None:
151         await app.state.API.close()
152
153     app = Starlette(debug=debug, routes=routes, middleware=middleware,
154                     exception_handlers=exceptions,
155                     on_shutdown=[_shutdown])
156
157     app.state.API = NominatimAPIAsync(project_dir, environ)
158
159     return app
160
161
162 def run_wsgi() -> Starlette:
163     """ Entry point for uvicorn.
164     """
165     return get_application(Path('.'), debug=False)