]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/server/falcon/server.py
Merge pull request #3588 from lonvia/optional-reverse-api
[nominatim.git] / src / nominatim_api / server / falcon / server.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Server implementation using the falcon webserver framework.
9 """
10 from typing import Optional, Mapping, Any, List
11 from pathlib import Path
12 import datetime as dt
13 import asyncio
14
15 from falcon.asgi import App, Request, Response
16
17 from ...config import Configuration
18 from ...core import NominatimAPIAsync
19 from ... import v1 as api_impl
20 from ...result_formatting import FormatDispatcher, load_format_dispatcher
21 from ... import logging as loglib
22 from ..asgi_adaptor import ASGIAdaptor, EndpointFunc
23
24
25 class HTTPNominatimError(Exception):
26     """ A special exception class for errors raised during processing.
27     """
28     def __init__(self, msg: str, status: int, content_type: str) -> None:
29         self.msg = msg
30         self.status = status
31         self.content_type = content_type
32
33
34 async def nominatim_error_handler(req: Request, resp: Response,
35                                   exception: HTTPNominatimError,
36                                   _: Any) -> None:
37     """ Special error handler that passes message and content type as
38         per exception info.
39     """
40     resp.status = exception.status
41     resp.text = exception.msg
42     resp.content_type = exception.content_type
43
44
45 async def timeout_error_handler(req: Request, resp: Response,
46                                 exception: TimeoutError,
47                                 _: Any) -> None:
48     """ Special error handler that passes message and content type as
49         per exception info.
50     """
51     resp.status = 503
52
53     loglib.log().comment('Aborted: Query took too long to process.')
54     logdata = loglib.get_and_disable()
55     if logdata:
56         resp.text = logdata
57         resp.content_type = 'text/html; charset=utf-8'
58     else:
59         resp.text = "Query took too long to process."
60         resp.content_type = 'text/plain; charset=utf-8'
61
62
63 class ParamWrapper(ASGIAdaptor):
64     """ Adaptor class for server glue to Falcon framework.
65     """
66
67     def __init__(self, req: Request, resp: Response,
68                  config: Configuration, formatter: FormatDispatcher) -> None:
69         self.request = req
70         self.response = resp
71         self._config = config
72         self._formatter = formatter
73
74     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
75         return self.request.get_param(name, default=default)
76
77     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
78         return self.request.get_header(name, default=default)
79
80     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
81         return HTTPNominatimError(msg, status, self.content_type)
82
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
88
89     def base_uri(self) -> str:
90         return self.request.forwarded_prefix
91
92     def config(self) -> Configuration:
93         return self._config
94
95     def formatting(self) -> FormatDispatcher:
96         return self._formatter
97
98
99 class EndpointWrapper:
100     """ Converter for server glue endpoint functions to Falcon request handlers.
101     """
102
103     def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync,
104                  formatter: FormatDispatcher) -> None:
105         self.name = name
106         self.func = func
107         self.api = api
108         self.formatter = formatter
109
110     async def on_get(self, req: Request, resp: Response) -> None:
111         """ Implementation of the endpoint.
112         """
113         await self.func(self.api, ParamWrapper(req, resp, self.api.config,
114                                                self.formatter))
115
116
117 class FileLoggingMiddleware:
118     """ Middleware to log selected requests into a file.
119     """
120
121     def __init__(self, file_name: str):
122         self.fd = open(file_name, 'a', buffering=1, encoding='utf8')
123
124     async def process_request(self, req: Request, _: Response) -> None:
125         """ Callback before the request starts timing.
126         """
127         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
128
129     async def process_response(self, req: Request, resp: Response,
130                                resource: Optional[EndpointWrapper],
131                                req_succeeded: bool) -> None:
132         """ Callback after requests writes to the logfile. It only
133             writes logs for successful requests for search, reverse and lookup.
134         """
135         if not req_succeeded or resource is None or resp.status != 200\
136            or resource.name not in ('reverse', 'search', 'lookup', 'details'):
137             return
138
139         finish = dt.datetime.now(tz=dt.timezone.utc)
140         duration = (finish - req.context.start).total_seconds()
141         params = req.scope['query_string'].decode('utf8')
142         start = req.context.start.replace(tzinfo=None)\
143                                  .isoformat(sep=' ', timespec='milliseconds')
144
145         self.fd.write(f"[{start}] "
146                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
147                       f'{resource.name} "{params}"\n')
148
149
150 class APIMiddleware:
151     """ Middleware managing the Nominatim database connection.
152     """
153
154     def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
155         self.api = NominatimAPIAsync(project_dir, environ)
156         self.app: Optional[App] = None
157
158     @property
159     def config(self) -> Configuration:
160         """ Get the configuration for Nominatim.
161         """
162         return self.api.config
163
164     def set_app(self, app: App) -> None:
165         """ Set the Falcon application this middleware is connected to.
166         """
167         self.app = app
168
169     async def process_startup(self, *_: Any) -> None:
170         """ Process the ASGI lifespan startup event.
171         """
172         assert self.app is not None
173         legacy_urls = self.api.config.get_bool('SERVE_LEGACY_URLS')
174         formatter = load_format_dispatcher('v1', self.api.config.project_dir)
175         for name, func in await api_impl.get_routes(self.api):
176             endpoint = EndpointWrapper(name, func, self.api, formatter)
177             self.app.add_route(f"/{name}", endpoint)
178             if legacy_urls:
179                 self.app.add_route(f"/{name}.php", endpoint)
180
181     async def process_shutdown(self, *_: Any) -> None:
182         """Process the ASGI lifespan shutdown event.
183         """
184         await self.api.close()
185
186
187 def get_application(project_dir: Path,
188                     environ: Optional[Mapping[str, str]] = None) -> App:
189     """ Create a Nominatim Falcon ASGI application.
190     """
191     apimw = APIMiddleware(project_dir, environ)
192
193     middleware: List[object] = [apimw]
194     log_file = apimw.config.LOG_FILE
195     if log_file:
196         middleware.append(FileLoggingMiddleware(log_file))
197
198     app = App(cors_enable=apimw.config.get_bool('CORS_NOACCESSCONTROL'),
199               middleware=middleware)
200
201     apimw.set_app(app)
202     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
203     app.add_error_handler(TimeoutError, timeout_error_handler)
204     # different from TimeoutError in Python <= 3.10
205     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)  # type: ignore[arg-type]
206
207     return app
208
209
210 def run_wsgi() -> App:
211     """ Entry point for uvicorn.
212
213         Make sure uvicorn is run from the project directory.
214     """
215     return get_application(Path('.'))