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