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