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