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