]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/v1/server_glue.py
factor out layer checks in reverse function
[nominatim.git] / nominatim / api / v1 / server_glue.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 Generic part of the server implementation of the v1 API.
9 Combine with the scaffolding provided for the various Python ASGI frameworks.
10 """
11 from typing import Optional, Any, Type, Callable, NoReturn
12 import abc
13
14 from nominatim.config import Configuration
15 import nominatim.api as napi
16 import nominatim.api.logging as loglib
17 from nominatim.api.v1.format import dispatch as formatting
18
19 CONTENT_TYPE = {
20   'text': 'text/plain; charset=utf-8',
21   'xml': 'text/xml; charset=utf-8',
22   'debug': 'text/html; charset=utf-8'
23 }
24
25
26 class ASGIAdaptor(abc.ABC):
27     """ Adapter class for the different ASGI frameworks.
28         Wraps functionality over concrete requests and responses.
29     """
30     content_type: str = 'text/plain; charset=utf-8'
31
32     @abc.abstractmethod
33     def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
34         """ Return an input parameter as a string. If the parameter was
35             not provided, return the 'default' value.
36         """
37
38     @abc.abstractmethod
39     def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
40         """ Return a HTTP header parameter as a string. If the parameter was
41             not provided, return the 'default' value.
42         """
43
44
45     @abc.abstractmethod
46     def error(self, msg: str, status: int = 400) -> Exception:
47         """ Construct an appropriate exception from the given error message.
48             The exception must result in a HTTP error with the given status.
49         """
50
51
52     @abc.abstractmethod
53     def create_response(self, status: int, output: str) -> Any:
54         """ Create a response from the given parameters. The result will
55             be returned by the endpoint functions. The adaptor may also
56             return None when the response is created internally with some
57             different means.
58
59             The response must return the HTTP given status code 'status', set
60             the HTTP content-type headers to the string provided and the
61             body of the response to 'output'.
62         """
63
64
65     @abc.abstractmethod
66     def config(self) -> Configuration:
67         """ Return the current configuration object.
68         """
69
70
71     def build_response(self, output: str, status: int = 200) -> Any:
72         """ Create a response from the given output. Wraps a JSONP function
73             around the response, if necessary.
74         """
75         if self.content_type == 'application/json' and status == 200:
76             jsonp = self.get('json_callback')
77             if jsonp is not None:
78                 if any(not part.isidentifier() for part in jsonp.split('.')):
79                     self.raise_error('Invalid json_callback value')
80                 output = f"{jsonp}({output})"
81                 self.content_type = 'application/javascript'
82
83         return self.create_response(status, output)
84
85
86     def raise_error(self, msg: str, status: int = 400) -> NoReturn:
87         """ Raise an exception resulting in the given HTTP status and
88             message. The message will be formatted according to the
89             output format chosen by the request.
90         """
91         if self.content_type == 'text/xml; charset=utf-8':
92             msg = f"""<?xml version="1.0" encoding="UTF-8" ?>
93                       <error>
94                         <code>{status}</code>
95                         <message>{msg}</message>
96                       </error>
97                    """
98         elif self.content_type == 'application/json':
99             msg = f"""{{"error":{{"code":{status},"message":"{msg}"}}}}"""
100         elif self.content_type == 'text/html; charset=utf-8':
101             loglib.log().section('Execution error')
102             loglib.log().var_dump('Status', status)
103             loglib.log().var_dump('Message', msg)
104             msg = loglib.get_and_disable()
105
106         raise self.error(msg, status)
107
108
109     def get_int(self, name: str, default: Optional[int] = None) -> int:
110         """ Return an input parameter as an int. Raises an exception if
111             the parameter is given but not in an integer format.
112
113             If 'default' is given, then it will be returned when the parameter
114             is missing completely. When 'default' is None, an error will be
115             raised on a missing parameter.
116         """
117         value = self.get(name)
118
119         if value is None:
120             if default is not None:
121                 return default
122
123             self.raise_error(f"Parameter '{name}' missing.")
124
125         try:
126             intval = int(value)
127         except ValueError:
128             self.raise_error(f"Parameter '{name}' must be a number.")
129
130         return intval
131
132     def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
133         """ Return an input parameter as bool. Only '0' is accepted as
134             an input for 'false' all other inputs will be interpreted as 'true'.
135
136             If 'default' is given, then it will be returned when the parameter
137             is missing completely. When 'default' is None, an error will be
138             raised on a missing parameter.
139         """
140         value = self.get(name)
141
142         if value is None:
143             if default is not None:
144                 return default
145
146             self.raise_error(f"Parameter '{name}' missing.")
147
148         return value != '0'
149
150
151     def get_accepted_languages(self) -> str:
152         """ Return the accepted languages.
153         """
154         return self.get('accept-language')\
155                or self.get_header('http_accept_language')\
156                or self.config().DEFAULT_LANGUAGE
157
158
159     def setup_debugging(self) -> bool:
160         """ Set up collection of debug information if requested.
161
162             Return True when debugging was requested.
163         """
164         if self.get_bool('debug', False):
165             loglib.set_log_output('html')
166             self.content_type = 'text/html; charset=utf-8'
167             return True
168
169         return False
170
171
172     def parse_format(self, result_type: Type[Any], default: str) -> str:
173         """ Get and check the 'format' parameter and prepare the formatter.
174             `result_type` is the type of result to be returned by the function
175             and `default` the format value to assume when no parameter is present.
176         """
177         fmt = self.get('format', default=default)
178         assert fmt is not None
179
180         if not formatting.supports_format(result_type, fmt):
181             self.raise_error("Parameter 'format' must be one of: " +
182                               ', '.join(formatting.list_formats(result_type)))
183
184         self.content_type = CONTENT_TYPE.get(fmt, 'application/json')
185         return fmt
186
187
188 async def status_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
189     """ Server glue for /status endpoint. See API docs for details.
190     """
191     result = await api.status()
192
193     fmt = params.parse_format(napi.StatusResult, 'text')
194
195     if fmt == 'text' and result.status:
196         status_code = 500
197     else:
198         status_code = 200
199
200     return params.build_response(formatting.format_result(result, fmt, {}),
201                                  status=status_code)
202
203
204 async def details_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) -> Any:
205     """ Server glue for /details endpoint. See API docs for details.
206     """
207     fmt = params.parse_format(napi.DetailedResult, 'json')
208     place_id = params.get_int('place_id', 0)
209     place: napi.PlaceRef
210     if place_id:
211         place = napi.PlaceID(place_id)
212     else:
213         osmtype = params.get('osmtype')
214         if osmtype is None:
215             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
216         place = napi.OsmID(osmtype, params.get_int('osmid'), params.get('class'))
217
218     debug = params.setup_debugging()
219
220     details = napi.LookupDetails(address_details=params.get_bool('addressdetails', False),
221                                  linked_places=params.get_bool('linkedplaces', False),
222                                  parented_places=params.get_bool('hierarchy', False),
223                                  keywords=params.get_bool('keywords', False))
224
225     if params.get_bool('polygon_geojson', False):
226         details.geometry_output = napi.GeometryFormat.GEOJSON
227
228     locales = napi.Locales.from_accept_languages(params.get_accepted_languages())
229
230     result = await api.lookup(place, details)
231
232     if debug:
233         return params.build_response(loglib.get_and_disable())
234
235     if result is None:
236         params.raise_error('No place with that OSM ID found.', status=404)
237
238     output = formatting.format_result(result, fmt,
239                  {'locales': locales,
240                   'group_hierarchy': params.get_bool('group_hierarchy', False),
241                   'icon_base_url': params.config().MAPICON_URL})
242
243     return params.build_response(output)
244
245
246 EndpointFunc = Callable[[napi.NominatimAPIAsync, ASGIAdaptor], Any]
247
248 ROUTES = [
249     ('status', status_endpoint),
250     ('details', details_endpoint)
251 ]