]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/server/starlette/server.py
Merge pull request #3127 from lonvia/file-logging
[nominatim.git] / nominatim / server / starlette / server.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Server implementation using the starlette webserver framework.
9 """
10 from typing import Any, Optional, Mapping, Callable, cast, Coroutine
11 from pathlib import Path
12 import datetime as dt
13
14 from starlette.applications import Starlette
15 from starlette.routing import Route
16 from starlette.exceptions import HTTPException
17 from starlette.responses import Response
18 from starlette.requests import Request
19 from starlette.middleware import Middleware
20 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
21 from starlette.middleware.cors import CORSMiddleware
22
23 from nominatim.api import NominatimAPIAsync
24 import nominatim.api.v1 as api_impl
25 from nominatim.config import Configuration
26
27 class ParamWrapper(api_impl.ASGIAdaptor):
28     """ Adaptor class for server glue to Starlette framework.
29     """
30
31     def __init__(self, request: Request) -> None:
32         self.request = request
33
34
35     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
36         return self.request.query_params.get(name, default=default)
37
38
39     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
40         return self.request.headers.get(name, default)
41
42
43     def error(self, msg: str, status: int = 400) -> HTTPException:
44         return HTTPException(status, detail=msg,
45                              headers={'content-type': self.content_type})
46
47
48     def create_response(self, status: int, output: str, num_results: int) -> Response:
49         self.request.state.num_results = num_results
50         return Response(output, status_code=status, media_type=self.content_type)
51
52
53     def config(self) -> Configuration:
54         return cast(Configuration, self.request.app.state.API.config)
55
56
57 def _wrap_endpoint(func: api_impl.EndpointFunc)\
58         -> Callable[[Request], Coroutine[Any, Any, Response]]:
59     async def _callback(request: Request) -> Response:
60         return cast(Response, await func(request.app.state.API, ParamWrapper(request)))
61
62     return _callback
63
64
65 class FileLoggingMiddleware(BaseHTTPMiddleware):
66     """ Middleware to log selected requests into a file.
67     """
68
69     def __init__(self, app: Starlette, file_name: str = ''):
70         super().__init__(app)
71         self.fd = open(file_name, 'a', buffering=1, encoding='utf8') # pylint: disable=R1732
72
73     async def dispatch(self, request: Request,
74                        call_next: RequestResponseEndpoint) -> Response:
75         start = dt.datetime.now(tz=dt.timezone.utc)
76         response = await call_next(request)
77
78         if response.status_code != 200:
79             return response
80
81         finish = dt.datetime.now(tz=dt.timezone.utc)
82
83         for endpoint in ('reverse', 'search', 'lookup'):
84             if request.url.path.startswith('/' + endpoint):
85                 qtype = endpoint
86                 break
87         else:
88             return response
89
90         duration = (finish - start).total_seconds()
91         params = request.scope['query_string'].decode('utf8')
92
93         self.fd.write(f"[{start.replace(tzinfo=None).isoformat(sep=' ', timespec='milliseconds')}] "
94                       f"{duration:.4f} {getattr(request.state, 'num_results', 0)} "
95                       f'{qtype} "{params}"\n')
96
97         return response
98
99
100 def get_application(project_dir: Path,
101                     environ: Optional[Mapping[str, str]] = None,
102                     debug: bool = True) -> Starlette:
103     """ Create a Nominatim falcon ASGI application.
104     """
105     config = Configuration(project_dir, environ)
106
107     routes = []
108     legacy_urls = config.get_bool('SERVE_LEGACY_URLS')
109     for name, func in api_impl.ROUTES:
110         endpoint = _wrap_endpoint(func)
111         routes.append(Route(f"/{name}", endpoint=endpoint))
112         if legacy_urls:
113             routes.append(Route(f"/{name}.php", endpoint=endpoint))
114
115     middleware = []
116     if config.get_bool('CORS_NOACCESSCONTROL'):
117         middleware.append(Middleware(CORSMiddleware, allow_origins=['*']))
118
119     log_file = config.LOG_FILE
120     if log_file:
121         middleware.append(Middleware(FileLoggingMiddleware, file_name=log_file))
122
123     async def _shutdown() -> None:
124         await app.state.API.close()
125
126     app = Starlette(debug=debug, routes=routes, middleware=middleware,
127                     on_shutdown=[_shutdown])
128
129     app.state.API = NominatimAPIAsync(project_dir, environ)
130
131     return app
132
133
134 def run_wsgi() -> Starlette:
135     """ Entry point for uvicorn.
136     """
137     return get_application(Path('.'), debug=False)