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