1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2025 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 __future__ import annotations
12 from typing import Optional, Mapping, Any, List, cast
13 from pathlib import Path
17 from falcon.asgi import App, Request, Response
19 from ...config import Configuration
20 from ...core import NominatimAPIAsync
21 from ...types import QueryStatistics
22 from ... import v1 as api_impl
23 from ...result_formatting import FormatDispatcher, load_format_dispatcher
24 from ... import logging as loglib
25 from ..asgi_adaptor import ASGIAdaptor, EndpointFunc
28 class HTTPNominatimError(Exception):
29 """ A special exception class for errors raised during processing.
31 def __init__(self, msg: str, status: int, content_type: str) -> None:
34 self.content_type = content_type
37 async def nominatim_error_handler(req: Request, resp: Response,
38 exception: HTTPNominatimError,
40 """ Special error handler that passes message and content type as
43 resp.status = exception.status
44 resp.text = exception.msg
45 resp.content_type = exception.content_type
48 async def timeout_error_handler(req: Request, resp: Response,
49 exception: TimeoutError,
51 """ Special error handler that passes message and content type as
56 loglib.log().comment('Aborted: Query took too long to process.')
57 logdata = loglib.get_and_disable()
60 resp.content_type = 'text/html; charset=utf-8'
62 resp.text = "Query took too long to process."
63 resp.content_type = 'text/plain; charset=utf-8'
66 class ParamWrapper(ASGIAdaptor):
67 """ Adaptor class for server glue to Falcon framework.
70 def __init__(self, req: Request, resp: Response,
71 config: Configuration, formatter: FormatDispatcher) -> None:
75 self._formatter = formatter
77 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
78 return self.request.get_param(name, default=default)
80 def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
81 return self.request.get_header(name, default=default)
83 def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
84 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
92 def base_uri(self) -> str:
93 return self.request.forwarded_prefix
95 def config(self) -> Configuration:
98 def formatting(self) -> FormatDispatcher:
99 return self._formatter
101 def query_stats(self) -> Optional[QueryStatistics]:
102 return cast(Optional[QueryStatistics], getattr(self.request.context, 'query_stats', None))
105 class EndpointWrapper:
106 """ Converter for server glue endpoint functions to Falcon request handlers.
109 def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync,
110 formatter: FormatDispatcher) -> None:
114 self.formatter = formatter
116 async def on_get(self, req: Request, resp: Response) -> None:
117 """ Implementation of the endpoint.
119 await self.func(self.api, ParamWrapper(req, resp, self.api.config,
123 class FileLoggingMiddleware:
124 """ Middleware to log selected requests into a file.
127 def __init__(self, file_name: str, logstr: str):
128 self.logstr = logstr + '\n'
129 self.fd = open(file_name, 'a', buffering=1, encoding='utf8')
131 async def process_request(self, req: Request, _: Response) -> None:
132 """ Callback before the request starts timing.
134 req.context.query_stats = QueryStatistics()
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 qs = req.context.query_stats
144 if not req_succeeded or 'start' not in qs\
145 or resource is None or resp.status != 200\
146 or resource.name not in ('reverse', 'search', 'lookup', 'details'):
149 qs['endpoint'] = resource.name
150 qs['query_string'] = req.scope['query_string'].decode('utf8')
151 qs['results_total'] = getattr(resp.context, 'num_results', 0)
152 for param in ('start', 'end', 'start_query'):
153 if isinstance(qs.get(param), dt.datetime):
154 qs[param] = qs[param].replace(tzinfo=None)\
155 .isoformat(sep=' ', timespec='milliseconds')
157 self.fd.write(self.logstr.format_map(qs))
161 """ Middleware managing the Nominatim database connection.
164 def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
165 self.api = NominatimAPIAsync(project_dir, environ)
166 self.app: Optional[App[Request, Response]] = None
169 def config(self) -> Configuration:
170 """ Get the configuration for Nominatim.
172 return self.api.config
174 def set_app(self, app: App[Request, Response]) -> None:
175 """ Set the Falcon application this middleware is connected to.
179 async def process_startup(self, *_: Any) -> None:
180 """ Process the ASGI lifespan startup event.
182 assert self.app is not None
183 legacy_urls = self.api.config.get_bool('SERVE_LEGACY_URLS')
184 formatter = load_format_dispatcher('v1', self.api.config.project_dir)
185 for name, func in await api_impl.get_routes(self.api):
186 endpoint = EndpointWrapper(name, func, self.api, formatter)
187 self.app.add_route(f"/{name}", endpoint)
189 self.app.add_route(f"/{name}.php", endpoint)
191 async def process_shutdown(self, *_: Any) -> None:
192 """Process the ASGI lifespan shutdown event.
194 await self.api.close()
197 def get_application(project_dir: Path,
198 environ: Optional[Mapping[str, str]] = None) -> App[Request, Response]:
199 """ Create a Nominatim Falcon ASGI application.
201 apimw = APIMiddleware(project_dir, environ)
203 middleware: List[Any] = [apimw]
204 log_file = apimw.config.LOG_FILE
206 middleware.append(FileLoggingMiddleware(log_file, apimw.config.LOG_FORMAT))
208 app = App(cors_enable=apimw.config.get_bool('CORS_NOACCESSCONTROL'),
209 middleware=middleware)
212 app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
213 app.add_error_handler(TimeoutError, timeout_error_handler)
214 # different from TimeoutError in Python <= 3.10
215 app.add_error_handler(asyncio.TimeoutError, timeout_error_handler) # type: ignore[arg-type]
220 def run_wsgi() -> App[Request, Response]:
221 """ Entry point for uvicorn.
223 Make sure uvicorn is run from the project directory.
225 return get_application(Path('.'))