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