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