]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/server/falcon/server.py
make formatting module non-static
[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, cast, 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
21 from ...v1.format import dispatch as formatting
22 from ... import logging as loglib
23 from ..asgi_adaptor import ASGIAdaptor, EndpointFunc
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, #pylint: disable=unused-argument
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, #pylint: disable=unused-argument
46                                 exception: TimeoutError, #pylint: disable=unused-argument
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, config: Configuration) -> None:
68         self.request = req
69         self.response = resp
70         self._config = config
71
72
73     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
74         return cast(Optional[str], self.request.get_param(name, default=default))
75
76
77     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
78         return cast(Optional[str], self.request.get_header(name, default=default))
79
80
81     def error(self, msg: str, status: int = 400) -> HTTPNominatimError:
82         return HTTPNominatimError(msg, status, self.content_type)
83
84
85     def create_response(self, status: int, output: str, num_results: int) -> None:
86         self.response.context.num_results = num_results
87         self.response.status = status
88         self.response.text = output
89         self.response.content_type = self.content_type
90
91
92     def base_uri(self) -> str:
93         return cast (str, self.request.forwarded_prefix)
94
95     def config(self) -> Configuration:
96         return self._config
97
98     def formatting(self) -> FormatDispatcher:
99         return formatting
100
101
102 class EndpointWrapper:
103     """ Converter for server glue endpoint functions to Falcon request handlers.
104     """
105
106     def __init__(self, name: str, func: EndpointFunc, api: NominatimAPIAsync) -> None:
107         self.name = name
108         self.func = func
109         self.api = api
110
111
112     async def on_get(self, req: Request, resp: Response) -> None:
113         """ Implementation of the endpoint.
114         """
115         await self.func(self.api, ParamWrapper(req, resp, self.api.config))
116
117
118 class FileLoggingMiddleware:
119     """ Middleware to log selected requests into a file.
120     """
121
122     def __init__(self, file_name: str):
123         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
124
125
126     async def process_request(self, req: Request, _: Response) -> None:
127         """ Callback before the request starts timing.
128         """
129         req.context.start = dt.datetime.now(tz=dt.timezone.utc)
130
131
132     async def process_response(self, req: Request, resp: Response,
133                                resource: Optional[EndpointWrapper],
134                                req_succeeded: bool) -> None:
135         """ Callback after requests writes to the logfile. It only
136             writes logs for successful requests for search, reverse and lookup.
137         """
138         if not req_succeeded or resource is None or resp.status != 200\
139             or resource.name not in ('reverse', 'search', 'lookup', 'details'):
140             return
141
142         finish = dt.datetime.now(tz=dt.timezone.utc)
143         duration = (finish - req.context.start).total_seconds()
144         params = req.scope['query_string'].decode('utf8')
145         start = req.context.start.replace(tzinfo=None)\
146                                  .isoformat(sep=' ', timespec='milliseconds')
147
148         self.fd.write(f"[{start}] "
149                       f"{duration:.4f} {getattr(resp.context, 'num_results', 0)} "
150                       f'{resource.name} "{params}"\n')
151
152
153 class APIShutdown:
154     """ Middleware that closes any open database connections.
155     """
156
157     def __init__(self, api: NominatimAPIAsync) -> None:
158         self.api = api
159
160     async def process_shutdown(self, *_: Any) -> None:
161         """Process the ASGI lifespan shutdown event.
162         """
163         await self.api.close()
164
165
166 def get_application(project_dir: Path,
167                     environ: Optional[Mapping[str, str]] = None) -> App:
168     """ Create a Nominatim Falcon ASGI application.
169     """
170     api = NominatimAPIAsync(project_dir, environ)
171
172     middleware: List[object] = [APIShutdown(api)]
173     log_file = api.config.LOG_FILE
174     if log_file:
175         middleware.append(FileLoggingMiddleware(log_file))
176
177     app = App(cors_enable=api.config.get_bool('CORS_NOACCESSCONTROL'),
178               middleware=middleware)
179     app.add_error_handler(HTTPNominatimError, nominatim_error_handler)
180     app.add_error_handler(TimeoutError, timeout_error_handler)
181     # different from TimeoutError in Python <= 3.10
182     app.add_error_handler(asyncio.TimeoutError, timeout_error_handler)
183
184     legacy_urls = api.config.get_bool('SERVE_LEGACY_URLS')
185     for name, func in api_impl.ROUTES:
186         endpoint = EndpointWrapper(name, func, api)
187         app.add_route(f"/{name}", endpoint)
188         if legacy_urls:
189             app.add_route(f"/{name}.php", endpoint)
190
191     return app
192
193
194 def run_wsgi() -> App:
195     """ Entry point for uvicorn.
196
197         Make sure uvicorn is run from the project directory.
198     """
199     return get_application(Path('.'))