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