1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Server implementation using the falcon webserver framework.
10 from typing import Optional, Mapping, cast, Any, List
11 from pathlib import Path
15 from falcon.asgi import App, Request, Response
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
24 class HTTPNominatimError(Exception):
25 """ A special exception class for errors raised during processing.
27 def __init__(self, msg: str, status: int, content_type: str) -> None:
30 self.content_type = content_type
33 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
34 exception: HTTPNominatimError,
36 """ Special error handler that passes message and content type as
39 resp.status = exception.status
40 resp.text = exception.msg
41 resp.content_type = exception.content_type
44 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
45 exception: TimeoutError, #pylint: disable=unused-argument
47 """ Special error handler that passes message and content type as
52 loglib.log().comment('Aborted: Query took too long to process.')
53 logdata = loglib.get_and_disable()
56 resp.content_type = 'text/html; charset=utf-8'
58 resp.text = "Query took too long to process."
59 resp.content_type = 'text/plain; charset=utf-8'
62 class ParamWrapper(ASGIAdaptor):
63 """ Adaptor class for server glue to Falcon framework.
66 def __init__(self, req: Request, resp: Response,
67 config: Configuration, formatter: FormatDispatcher) -> None:
71 self._formatter = formatter
74 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
75 return cast(Optional[str], self.request.get_param(name, default=default))
78 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
79 return cast(Optional[str], self.request.get_header(name, default=default))
82 def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
83 return HTTPNominatimError(msg, status, self.content_type)
86 def create_response(self, status: int, output: str, num_results: int) -> None:
87 self.response.context.num_results = num_results
88 self.response.status = status
89 self.response.text = output
90 self.response.content_type = self.content_type
93 def base_uri(self) -> str:
94 return cast (str, self.request.forwarded_prefix)
96 def config(self) -> Configuration:
99 def formatting(self) -> FormatDispatcher:
100 return self._formatter
103 class EndpointWrapper:
104 """ Converter for server glue endpoint functions to Falcon request handlers.
107 def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync,
108 formatter: FormatDispatcher) -> None:
112 self.formatter = formatter
115 async def on_get(self, req: Request, resp: Response) -> None:
116 """ Implementation of the endpoint.
118 await self.func(self.api, ParamWrapper(req, resp, self.api.config,
122 class FileLoggingMiddleware:
123 """ Middleware to log selected requests into a file.
126 def __init__(self, file_name: str):
127 self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
130 async def process_request(self, req: Request, _: Response) -> None:
131 """ Callback before the request starts timing.
133 req.context.start = dt.datetime.now(tz=dt.timezone.utc)
136 async def process_response(self, req: Request, resp: Response,
137 resource: Optional[EndpointWrapper],
138 req_succeeded: bool) -> None:
139 """ Callback after requests writes to the logfile. It only
140 writes logs for successful requests for search, reverse and lookup.
142 if not req_succeeded or resource is None or resp.status != 200\
143 or resource.name not in ('reverse', 'search', 'lookup', 'details'):
146 finish = dt.datetime.now(tz=dt.timezone.utc)
147 duration = (finish - req.context.start).total_seconds()
148 params = req.scope['query_string'].decode('utf8')
149 start = req.context.start.replace(tzinfo=None)\
150 .isoformat(sep=' ', timespec='milliseconds')
152 self.fd.write(f"[{start}] "
153 f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
154 f'{resource.name} "{params}"\n')
158 """ Middleware that closes any open database connections.
161 def __init__(self, api: NominatimAPIAsync) -> None:
164 async def process_shutdown(self, *_: Any) -> None:
165 """Process the ASGI lifespan shutdown event.
167 await self.api.close()
170 def get_application(project_dir: Path,
171 environ: Optional[Mapping[str, str]] = None) -> App:
172 """ Create a Nominatim Falcon ASGI application.
174 api = NominatimAPIAsync(project_dir, environ)
176 middleware: List[object] = [APIShutdown(api)]
177 log_file = api.config.LOG_FILE
179 middleware.append(FileLoggingMiddleware(log_file))
181 app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
182 middleware=middleware)
183 app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
184 app.add_error_handler(TimeoutError, timeout_error_handler)
185 # different from TimeoutError in Python <= 3.10
186 app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)
188 legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
189 formatter = load_format_dispatcher('v1', project_dir)
190 for name, func in api_impl.ROUTES:
191 endpoint = EndpointWrapper(name, func, api, formatter)
192 app.add_route(f"/{name}", endpoint)
194 app.add_route(f"/{name}.php", endpoint)
199 def run_wsgi() -> App:
200 """ Entry point for uvicorn.
202 Make sure uvicorn is run from the project directory.
204 return get_application(Path('.'))