1 # SPDX-License-Identifier: GPL-2.0-only
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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 nominatim.api import NominatimAPIAsync
18 import nominatim.api.v1 as api_impl
19 from nominatim.config import Configuration
21 class HTTPNominatimError(Exception):
22 """ A special exception class for errors raised during processing.
24 def __init__(self, msg: str, status: int, content_type: str) -> None:
27 self.content_type = content_type
30 async def nominatim_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
31 exception: HTTPNominatimError,
33 """ Special error handler that passes message and content type as
36 resp.status = exception.status
37 resp.text = exception.msg
38 resp.content_type = exception.content_type
41 async def timeout_error_handler(req: Request, resp: Response, #pylint: disable=unused-argument
42 exception: TimeoutError, #pylint: disable=unused-argument
44 """ Special error handler that passes message and content type as
48 resp.text = "Query took too long to process."
49 resp.content_type = 'text/plain; charset=utf-8'
52 class ParamWrapper(api_impl.ASGIAdaptor):
53 """ Adaptor class for server glue to Falcon framework.
56 def __init__(self, req: Request, resp: Response,
57 config: Configuration) -> None:
63 def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
64 return cast(Optional[str], self.request.get_param(name, default=default))
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))
71 def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
72 return HTTPNominatimError(msg, status, self.content_type)
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
82 def base_uri(self) -> str:
83 return cast (str, self.request.forwarded_prefix)
85 def config(self) -> Configuration:
89 class EndpointWrapper:
90 """ Converter for server glue endpoint functions to Falcon request handlers.
93 def __init__(self, name: str, func: api_impl.EndpointFunc, api: NominatimAPIAsync) -> None:
99 async def on_get(self, req: Request, resp: Response) -> None:
100 """ Implementation of the endpoint.
102 await self.func(self.api, ParamWrapper(req, resp, self.api.config))
105 class FileLoggingMiddleware:
106 """ Middleware to log selected requests into a file.
109 def __init__(self, file_name: str):
110 self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
113 async def process_request(self, req: Request, _: Response) -> None:
114 """ Callback before the request starts timing.
116 req.context.start = dt.datetime.now(tz=dt.timezone.utc)
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.
125 if not req_succeeded or resource is None or resp.status != 200\
126 or resource.name not in ('reverse', 'search', 'lookup', 'details'):
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')
135 self.fd.write(f"[{start}] "
136 f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
137 f'{resource.name} "{params}"\n')
141 """ Middleware that closes any open database connections.
144 def __init__(self, api: NominatimAPIAsync) -> None:
147 async def process_shutdown(self, *_: Any) -> None:
148 """Process the ASGI lifespan shutdown event.
150 await self.api.close()
153 def get_application(project_dir: Path,
154 environ: Optional[Mapping[str, str]] = None) -> App:
155 """ Create a Nominatim Falcon ASGI application.
157 api = NominatimAPIAsync(project_dir, environ)
159 middleware: List[object] = [APIShutdown(api)]
160 log_file = api.config.LOG_FILE
162 middleware.append(FileLoggingMiddleware(log_file))
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)
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)
176 app.add_route(f"/{name}.php", endpoint)
181 def run_wsgi() -> App:
182 """ Entry point for uvicorn.
184 Make sure uvicorn is run from the project directory.
186 return get_application(Path('.'))