]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/v1/server_glue.py
925bfdd04c838809995b441aee29b84d341134ae
[nominatim.git] / src / nominatim_api / v1 / server_glue.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 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, Dict, cast
12 from functools import reduce
13 import dataclasses
14 from urllib.parse import urlencode
15
16 import sqlalchemy as sa
17
18 from ..errors import UsageError
19 from .. import logging as loglib
20 from ..core import NominatimAPIAsync
21 from .format import RawDataList
22 from ..types import DataLayer, GeometryFormat, PlaceRef, PlaceID, OsmID, Point
23 from ..status import StatusResult
24 from ..results import DetailedResult, ReverseResults, SearchResult, SearchResults
25 from ..localization import Locales
26 from . import helpers
27 from ..server.asgi_adaptor import CONTENT_HTML, CONTENT_JSON, CONTENT_TYPE, ASGIAdaptor
28
29 def build_response(adaptor: ASGIAdaptor, output: str, status: int = 200,
30                    num_results: int = 0) -> Any:
31     """ Create a response from the given output. Wraps a JSONP function
32         around the response, if necessary.
33     """
34     if adaptor.content_type == CONTENT_JSON and status == 200:
35         jsonp = adaptor.get('json_callback')
36         if jsonp is not None:
37             if any(not part.isidentifier() for part in jsonp.split('.')):
38                 adaptor.raise_error('Invalid json_callback value')
39             output = f"{jsonp}({output})"
40             adaptor.content_type = 'application/javascript; charset=utf-8'
41
42     return adaptor.create_response(status, output, num_results)
43
44
45 def get_accepted_languages(adaptor: ASGIAdaptor) -> str:
46     """ Return the accepted languages.
47     """
48     return adaptor.get('accept-language')\
49            or adaptor.get_header('accept-language')\
50            or adaptor.config().DEFAULT_LANGUAGE
51
52
53 def setup_debugging(adaptor: ASGIAdaptor) -> bool:
54     """ Set up collection of debug information if requested.
55
56         Return True when debugging was requested.
57     """
58     if adaptor.get_bool('debug', False):
59         loglib.set_log_output('html')
60         adaptor.content_type = CONTENT_HTML
61         return True
62
63     return False
64
65
66 def get_layers(adaptor: ASGIAdaptor) -> Optional[DataLayer]:
67     """ Return a parsed version of the layer parameter.
68     """
69     param = adaptor.get('layer', None)
70     if param is None:
71         return None
72
73     return cast(DataLayer,
74                 reduce(DataLayer.__or__,
75                        (getattr(DataLayer, s.upper()) for s in param.split(','))))
76
77
78 def parse_format(adaptor: ASGIAdaptor, result_type: Type[Any], default: str) -> str:
79     """ Get and check the 'format' parameter and prepare the formatter.
80         `result_type` is the type of result to be returned by the function
81         and `default` the format value to assume when no parameter is present.
82     """
83     fmt = adaptor.get('format', default=default)
84     assert fmt is not None
85
86     if not adaptor.formatting().supports_format(result_type, fmt):
87         adaptor.raise_error("Parameter 'format' must be one of: " +
88                           ', '.join(adaptor.formatting().list_formats(result_type)))
89
90     adaptor.content_type = CONTENT_TYPE.get(fmt, CONTENT_JSON)
91     return fmt
92
93
94 def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]:
95     """ Create details structure from the supplied geometry parameters.
96     """
97     numgeoms = 0
98     output = GeometryFormat.NONE
99     if adaptor.get_bool('polygon_geojson', False):
100         output |= GeometryFormat.GEOJSON
101         numgeoms += 1
102     if fmt not in ('geojson', 'geocodejson'):
103         if adaptor.get_bool('polygon_text', False):
104             output |= GeometryFormat.TEXT
105             numgeoms += 1
106         if adaptor.get_bool('polygon_kml', False):
107             output |= GeometryFormat.KML
108             numgeoms += 1
109         if adaptor.get_bool('polygon_svg', False):
110             output |= GeometryFormat.SVG
111             numgeoms += 1
112
113     if numgeoms > adaptor.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
114         adaptor.raise_error('Too many polygon output options selected.')
115
116     return {'address_details': True,
117             'geometry_simplification': adaptor.get_float('polygon_threshold', 0.0),
118             'geometry_output': output
119            }
120
121
122 async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
123     """ Server glue for /status endpoint. See API docs for details.
124     """
125     result = await api.status()
126
127     fmt = parse_format(params, StatusResult, 'text')
128
129     if fmt == 'text' and result.status:
130         status_code = 500
131     else:
132         status_code = 200
133
134     return build_response(params, params.formatting().format_result(result, fmt, {}),
135                                  status=status_code)
136
137
138 async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
139     """ Server glue for /details endpoint. See API docs for details.
140     """
141     fmt = parse_format(params, DetailedResult, 'json')
142     place_id = params.get_int('place_id', 0)
143     place: PlaceRef
144     if place_id:
145         place = PlaceID(place_id)
146     else:
147         osmtype = params.get('osmtype')
148         if osmtype is None:
149             params.raise_error("Missing ID parameter 'place_id' or 'osmtype'.")
150         place = OsmID(osmtype, params.get_int('osmid'), params.get('class'))
151
152     debug = setup_debugging(params)
153
154     locales = Locales.from_accept_languages(get_accepted_languages(params))
155
156     result = await api.details(place,
157                                address_details=params.get_bool('addressdetails', False),
158                                linked_places=params.get_bool('linkedplaces', True),
159                                parented_places=params.get_bool('hierarchy', False),
160                                keywords=params.get_bool('keywords', False),
161                                geometry_output = GeometryFormat.GEOJSON
162                                                  if params.get_bool('polygon_geojson', False)
163                                                  else GeometryFormat.NONE,
164                                locales=locales
165                               )
166
167     if debug:
168         return build_response(params, loglib.get_and_disable())
169
170     if result is None:
171         params.raise_error('No place with that OSM ID found.', status=404)
172
173     output = params.formatting().format_result(result, fmt,
174                  {'locales': locales,
175                   'group_hierarchy': params.get_bool('group_hierarchy', False),
176                   'icon_base_url': params.config().MAPICON_URL})
177
178     return build_response(params, output, num_results=1)
179
180
181 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
182     """ Server glue for /reverse endpoint. See API docs for details.
183     """
184     fmt = parse_format(params, ReverseResults, 'xml')
185     debug = setup_debugging(params)
186     coord = Point(params.get_float('lon'), params.get_float('lat'))
187
188     details = parse_geometry_details(params, fmt)
189     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
190     details['layers'] = get_layers(params)
191     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
192
193     result = await api.reverse(coord, **details)
194
195     if debug:
196         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
197
198     if fmt == 'xml':
199         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
200         zoom = params.get('zoom', None)
201         if zoom:
202             queryparts['zoom'] = zoom
203         query = urlencode(queryparts)
204     else:
205         query = ''
206
207     fmt_options = {'query': query,
208                    'extratags': params.get_bool('extratags', False),
209                    'namedetails': params.get_bool('namedetails', False),
210                    'addressdetails': params.get_bool('addressdetails', True)}
211
212     output = params.formatting().format_result(ReverseResults([result] if result else []),
213                                                fmt, fmt_options)
214
215     return build_response(params, output, num_results=1 if result else 0)
216
217
218 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
219     """ Server glue for /lookup endpoint. See API docs for details.
220     """
221     fmt = parse_format(params, SearchResults, 'xml')
222     debug = setup_debugging(params)
223     details = parse_geometry_details(params, fmt)
224     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
225
226     places = []
227     for oid in (params.get('osm_ids') or '').split(','):
228         oid = oid.strip()
229         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
230             places.append(OsmID(oid[0].upper(), int(oid[1:])))
231
232     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
233         params.raise_error('Too many object IDs.')
234
235     if places:
236         results = await api.lookup(places, **details)
237     else:
238         results = SearchResults()
239
240     if debug:
241         return build_response(params, loglib.get_and_disable(), num_results=len(results))
242
243     fmt_options = {'extratags': params.get_bool('extratags', False),
244                    'namedetails': params.get_bool('namedetails', False),
245                    'addressdetails': params.get_bool('addressdetails', True)}
246
247     output = params.formatting().format_result(results, fmt, fmt_options)
248
249     return build_response(params, output, num_results=len(results))
250
251
252 async def _unstructured_search(query: str, api: NominatimAPIAsync,
253                               details: Dict[str, Any]) -> SearchResults:
254     if not query:
255         return SearchResults()
256
257     # Extract special format for coordinates from query.
258     query, x, y = helpers.extract_coords_from_query(query)
259     if x is not None:
260         assert y is not None
261         details['near'] = Point(x, y)
262         details['near_radius'] = 0.1
263
264     # If no query is left, revert to reverse search.
265     if x is not None and not query:
266         result = await api.reverse(details['near'], **details)
267         if not result:
268             return SearchResults()
269
270         return SearchResults(
271                   [SearchResult(**{f.name: getattr(result, f.name)
272                                    for f in dataclasses.fields(SearchResult)
273                                    if hasattr(result, f.name)})])
274
275     query, cls, typ = helpers.extract_category_from_query(query)
276     if cls is not None:
277         assert typ is not None
278         return await api.search_category([(cls, typ)], near_query=query, **details)
279
280     return await api.search(query, **details)
281
282
283 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
284     """ Server glue for /search endpoint. See API docs for details.
285     """
286     fmt = parse_format(params, SearchResults, 'jsonv2')
287     debug = setup_debugging(params)
288     details = parse_geometry_details(params, fmt)
289
290     details['countries']  = params.get('countrycodes', None)
291     details['excluded'] = params.get('exclude_place_ids', None)
292     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
293     details['bounded_viewbox'] = params.get_bool('bounded', False)
294     details['dedupe'] = params.get_bool('dedupe', True)
295
296     max_results = max(1, min(50, params.get_int('limit', 10)))
297     details['max_results'] = max_results + min(10, max_results) \
298                              if details['dedupe'] else max_results
299
300     details['min_rank'], details['max_rank'] = \
301         helpers.feature_type_to_rank(params.get('featureType', ''))
302     if params.get('featureType', None) is not None:
303         details['layers'] = DataLayer.ADDRESS
304     else:
305         details['layers'] = get_layers(params)
306
307     details['locales'] = Locales.from_accept_languages(get_accepted_languages(params))
308
309     # unstructured query parameters
310     query = params.get('q', None)
311     # structured query parameters
312     queryparts = {}
313     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
314         details[key] = params.get(key, None)
315         if details[key]:
316             queryparts[key] = details[key]
317
318     try:
319         if query is not None:
320             if queryparts:
321                 params.raise_error("Structured query parameters"
322                                    "(amenity, street, city, county, state, postalcode, country)"
323                                    " cannot be used together with 'q' parameter.")
324             queryparts['q'] = query
325             results = await _unstructured_search(query, api, details)
326         else:
327             query = ', '.join(queryparts.values())
328
329             results = await api.search_address(**details)
330     except UsageError as err:
331         params.raise_error(str(err))
332
333     if details['dedupe'] and len(results) > 1:
334         results = helpers.deduplicate_results(results, max_results)
335
336     if debug:
337         return build_response(params, loglib.get_and_disable(), num_results=len(results))
338
339     if fmt == 'xml':
340         helpers.extend_query_parts(queryparts, details,
341                                    params.get('featureType', ''),
342                                    params.get_bool('namedetails', False),
343                                    params.get_bool('extratags', False),
344                                    (str(r.place_id) for r in results if r.place_id))
345         queryparts['format'] = fmt
346
347         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
348     else:
349         moreurl = ''
350
351     fmt_options = {'query': query, 'more_url': moreurl,
352                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
353                    'viewbox': queryparts.get('viewbox'),
354                    'extratags': params.get_bool('extratags', False),
355                    'namedetails': params.get_bool('namedetails', False),
356                    'addressdetails': params.get_bool('addressdetails', False)}
357
358     output = params.formatting().format_result(results, fmt, fmt_options)
359
360     return build_response(params, output, num_results=len(results))
361
362
363 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
364     """ Server glue for /deletable endpoint.
365         This is a special endpoint that shows polygons that have been
366         deleted or are broken in the OSM data but are kept in the
367         Nominatim database to minimize disruption.
368     """
369     fmt = parse_format(params, RawDataList, 'json')
370
371     async with api.begin() as conn:
372         sql = sa.text(""" SELECT p.place_id, country_code,
373                                  name->'name' as name, i.*
374                           FROM placex p, import_polygon_delete i
375                           WHERE p.osm_id = i.osm_id AND p.osm_type = i.osm_type
376                                 AND p.class = i.class AND p.type = i.type
377                       """)
378         results = RawDataList(r._asdict() for r in await conn.execute(sql))
379
380     return build_response(params, params.formatting().format_result(results, fmt, {}))
381
382
383 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
384     """ Server glue for /polygons endpoint.
385         This is a special endpoint that shows polygons that have changed
386         their size but are kept in the Nominatim database with their
387         old area to minimize disruption.
388     """
389     fmt = parse_format(params, RawDataList, 'json')
390     sql_params: Dict[str, Any] = {
391         'days': params.get_int('days', -1),
392         'cls': params.get('class')
393     }
394     reduced = params.get_bool('reduced', False)
395
396     async with api.begin() as conn:
397         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
398                                    name->'name' as name,
399                                    country_code, errormessage, updated"""))\
400                 .select_from(sa.text('import_polygon_error'))
401         if sql_params['days'] > 0:
402             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
403         if reduced:
404             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
405         if sql_params['cls'] is not None:
406             sql = sql.where(sa.text("class = :cls"))
407
408         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
409
410         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
411
412     return build_response(params, params.formatting().format_result(results, fmt, {}))
413
414
415 ROUTES = [
416     ('status', status_endpoint),
417     ('details', details_endpoint),
418     ('reverse', reverse_endpoint),
419     ('lookup', lookup_endpoint),
420     ('search', search_endpoint),
421     ('deletable', deletable_endpoint),
422     ('polygons', polygons_endpoint),
423 ]