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