]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/server/falcon/server.py
c1196ddb5ae490547d252350670ea18e49a2eb72
[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, cast, 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 class HTTPNominatimError(Exception):
25     """ A special exception class for errors raised during processing.
26     """
27     def __init__(self, msg: str, status: int, content_type: str) -> None:
28         self.msg = msg
29         self.status = status
30         self.content_type = content_type
31
32
33 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
34                                   exception: HTTPNominatimError,
35                                   _: Any) -> None:
36     """ Special error handler that passes message and content type as
37         per exception info.
38     """
39     resp.status = exception.status
40     resp.text = exception.msg
41     resp.content_type = exception.content_type
42
43
44 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
45                                 exception: TimeoutError, #pylint: disable=unused-argument
46                                 _: Any) -> None:
47     """ Special error handler that passes message and content type as
48         per exception info.
49     """
50     resp.status = 503
51
52     loglib.log().comment('Aborted: Query took too long to process.')
53     logdata = loglib.get_and_disable()
54     if logdata:
55         resp.text = logdata
56         resp.content_type = 'text/html; charset=utf-8'
57     else:
58         resp.text = "Query took too long to process."
59         resp.content_type = 'text/plain; charset=utf-8'
60
61
62 class ParamWrapper(ASGIAdaptor):
63     """ Adaptor class for server glue to Falcon framework.
64     """
65
66     def __init__(self, req: Request, resp: Response,
67                  config: Configuration, formatter: FormatDispatcher) -> None:
68         self.request = req
69         self.response = resp
70         self._config = config
71         self._formatter = formatter
72
73
74     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
75         return cast(Optional[str], self.request.get_param(name, default=default))
76
77
78     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
79         return cast(Optional[str], self.request.get_header(name, default=default))
80
81
82     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
83         return HTTPNominatimError(msg, status, self.content_type)
84
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
93     def base_uri(self) -> str:
94         return cast (str, self.request.forwarded_prefix)
95
96     def config(self) -> Configuration:
97         return self._config
98
99     def formatting(self) -> FormatDispatcher:
100         return self._formatter
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
115     async def on_get(self, req: Request, resp: Response) -> None:
116         """ Implementation of the endpoint.
117         """
118         await self.func(self.api, ParamWrapper(req, resp, self.api.config,
119                                                self.formatter))
120
121
122 class FileLoggingMiddleware:
123     """ Middleware to log selected requests into a file.
124     """
125
126     def __init__(self, file_name: str):
127         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
128
129
130     async def process_request(self, req: Request, _: Response) -> None:
131         """ Callback before the request starts timing.
132         """
133         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
134
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         if not req_succeeded or resource is None or resp.status != 200\
143             or resource.name not in ('reverse', 'search', 'lookup', 'details'):
144             return
145
146         finish = dt.datetime.now(tz=dt.timezone.utc)
147         duration = (finish - req.context.start).total_seconds()
148         params = req.scope['query_string'].decode('utf8')
149         start = req.context.start.replace(tzinfo=None)\
150                                  .isoformat(sep=' ', timespec='milliseconds')
151
152         self.fd.write(f"[{start}] "
153                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
154                       f'{resource.name} "{params}"\n')
155
156
157 class APIShutdown:
158     """ Middleware that closes any open database connections.
159     """
160
161     def __init__(self, api: NominatimAPIAsync) -> None:
162         self.api = api
163
164     async def process_shutdown(self, *_: Any) -> None:
165         """Process the ASGI lifespan shutdown event.
166         """
167         await self.api.close()
168
169
170 def get_application(project_dir: Path,
171                     environ: Optional[Mapping[str, str]] = None) -> App:
172     """ Create a Nominatim Falcon ASGI application.
173     """
174     api = NominatimAPIAsync(project_dir, environ)
175
176     middleware: List[object] = [APIShutdown(api)]
177     log_file = api.config.LOG_FILE
178     if log_file:
179         middleware.append(FileLoggingMiddleware(log_file))
180
181     app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
182               middleware=middleware)
183     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
184     app.add_error_handler(TimeoutError, timeout_error_handler)
185     # different from TimeoutError in Python <= 3.10
186     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)
187
188     legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
189     formatter = load_format_dispatcher('v1', project_dir)
190     for name, func in api_impl.ROUTES:
191         endpoint = EndpointWrapper(name, func, api, formatter)
192         app.add_route(f"/{name}", endpoint)
193         if legacy_urls:
194             app.add_route(f"/{name}.php", endpoint)
195
196     return app
197
198
199 def run_wsgi() -> App:
200     """ Entry point for uvicorn.
201
202         Make sure uvicorn is run from the project directory.
203     """
204     return get_application(Path('.'))