]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/v1/server_glue.py
add documentation for library API
[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) 2025 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                                entrances=params.get_bool('entrances', False),
162                                linked_places=params.get_bool('linkedplaces', True),
163                                parented_places=params.get_bool('hierarchy', False),
164                                keywords=params.get_bool('keywords', False),
165                                geometry_output=(GeometryFormat.GEOJSON
166                                                 if params.get_bool('polygon_geojson', False)
167                                                 else GeometryFormat.NONE),
168                                query_stats=params.query_stats()
169                                )
170
171     if debug:
172         return build_response(params, loglib.get_and_disable())
173
174     if result is None:
175         params.raise_error('No place with that OSM ID found.', status=404)
176
177     locales = Locales.from_accept_languages(get_accepted_languages(params))
178     locales.localize_results([result])
179
180     output = params.formatting().format_result(
181         result, fmt,
182         {'locales': locales,
183          'group_hierarchy': params.get_bool('group_hierarchy', False),
184          'icon_base_url': params.config().MAPICON_URL,
185          'entrances': params.get_bool('entrances', False),
186          })
187
188     return build_response(params, output, num_results=1)
189
190
191 async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
192     """ Server glue for /reverse endpoint. See API docs for details.
193     """
194     fmt = parse_format(params, ReverseResults, 'xml')
195     debug = setup_debugging(params)
196     coord = Point(params.get_float('lon'), params.get_float('lat'))
197
198     details = parse_geometry_details(params, fmt)
199     details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
200     details['layers'] = get_layers(params)
201     details['query_stats'] = params.query_stats()
202
203     result = await api.reverse(coord, **details)
204
205     if debug:
206         return build_response(params, loglib.get_and_disable(), num_results=1 if result else 0)
207
208     if fmt == 'xml':
209         queryparts = {'lat': str(coord.lat), 'lon': str(coord.lon), 'format': 'xml'}
210         zoom = params.get('zoom', None)
211         if zoom:
212             queryparts['zoom'] = zoom
213         query = urlencode(queryparts)
214     else:
215         query = ''
216
217     if result:
218         Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
219             [result])
220
221     fmt_options = {'query': query,
222                    'extratags': params.get_bool('extratags', False),
223                    'namedetails': params.get_bool('namedetails', False),
224                    'entrances': params.get_bool('entrances', False),
225                    'addressdetails': params.get_bool('addressdetails', True)}
226
227     output = params.formatting().format_result(ReverseResults([result] if result else []),
228                                                fmt, fmt_options)
229
230     return build_response(params, output, num_results=1 if result else 0)
231
232
233 async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
234     """ Server glue for /lookup endpoint. See API docs for details.
235     """
236     fmt = parse_format(params, SearchResults, 'xml')
237     debug = setup_debugging(params)
238     details = parse_geometry_details(params, fmt)
239     details['query_stats'] = params.query_stats()
240
241     places = []
242     for oid in (params.get('osm_ids') or '').split(','):
243         oid = oid.strip()
244         if len(oid) > 1 and oid[0] in 'RNWrnw' and oid[1:].isdigit():
245             places.append(OsmID(oid[0].upper(), int(oid[1:])))
246
247     if len(places) > params.config().get_int('LOOKUP_MAX_COUNT'):
248         params.raise_error('Too many object IDs.')
249
250     if places:
251         results = await api.lookup(places, **details)
252     else:
253         results = SearchResults()
254
255     if debug:
256         return build_response(params, loglib.get_and_disable(), num_results=len(results))
257
258     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
259
260     fmt_options = {'extratags': params.get_bool('extratags', False),
261                    'namedetails': params.get_bool('namedetails', False),
262                    'entrances': params.get_bool('entrances', False),
263                    'addressdetails': params.get_bool('addressdetails', True)}
264
265     output = params.formatting().format_result(results, fmt, fmt_options)
266
267     return build_response(params, output, num_results=len(results))
268
269
270 async def _unstructured_search(query: str, api: NominatimAPIAsync,
271                                details: Dict[str, Any]) -> SearchResults:
272     if not query:
273         return SearchResults()
274
275     # Extract special format for coordinates from query.
276     query, x, y = helpers.extract_coords_from_query(query)
277     if x is not None:
278         assert y is not None
279         details['near'] = Point(x, y)
280         details['near_radius'] = 0.1
281
282     # If no query is left, revert to reverse search.
283     if x is not None and not query:
284         result = await api.reverse(details['near'], **details)
285         if not result:
286             return SearchResults()
287
288         return SearchResults(
289                   [SearchResult(**{f.name: getattr(result, f.name)
290                                    for f in dataclasses.fields(SearchResult)
291                                    if hasattr(result, f.name)})])
292
293     query, cls, typ = helpers.extract_category_from_query(query)
294     if cls is not None:
295         assert typ is not None
296         return await api.search_category([(cls, typ)], near_query=query, **details)
297
298     return await api.search(query, **details)
299
300
301 async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
302     """ Server glue for /search endpoint. See API docs for details.
303     """
304     fmt = parse_format(params, SearchResults, 'jsonv2')
305     debug = setup_debugging(params)
306     details = parse_geometry_details(params, fmt)
307
308     details['query_stats'] = params.query_stats()
309     details['countries'] = params.get('countrycodes', None)
310     details['entrances'] = params.get_bool('entrances', False)
311     details['excluded'] = params.get('exclude_place_ids', None)
312     details['viewbox'] = params.get('viewbox', None) or params.get('viewboxlbrt', None)
313     details['bounded_viewbox'] = params.get_bool('bounded', False)
314     details['dedupe'] = params.get_bool('dedupe', True)
315
316     max_results = max(1, min(50, params.get_int('limit', 10)))
317     details['max_results'] = (max_results + min(10, max_results)
318                               if details['dedupe'] else max_results)
319
320     details['min_rank'], details['max_rank'] = \
321         helpers.feature_type_to_rank(params.get('featureType', ''))
322     if params.get('featureType', None) is not None:
323         details['layers'] = DataLayer.ADDRESS
324     else:
325         details['layers'] = get_layers(params)
326
327     # unstructured query parameters
328     query = params.get('q', None)
329     # structured query parameters
330     queryparts = {}
331     for key in ('amenity', 'street', 'city', 'county', 'state', 'postalcode', 'country'):
332         details[key] = params.get(key, None)
333         if details[key]:
334             queryparts[key] = details[key]
335
336     try:
337         if query is not None:
338             if queryparts:
339                 params.raise_error("Structured query parameters"
340                                    "(amenity, street, city, county, state, postalcode, country)"
341                                    " cannot be used together with 'q' parameter.")
342             queryparts['q'] = query
343             results = await _unstructured_search(query, api, details)
344         else:
345             query = ', '.join(queryparts.values())
346
347             results = await api.search_address(**details)
348     except UsageError as err:
349         params.raise_error(str(err))
350
351     Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
352
353     if details['dedupe'] and len(results) > 1:
354         results = helpers.deduplicate_results(results, max_results)
355
356     if debug:
357         return build_response(params, loglib.get_and_disable(), num_results=len(results))
358
359     if fmt == 'xml':
360         helpers.extend_query_parts(queryparts, details,
361                                    params.get('featureType', ''),
362                                    params.get_bool('namedetails', False),
363                                    params.get_bool('extratags', False),
364                                    (str(r.place_id) for r in results if r.place_id))
365         queryparts['format'] = fmt
366
367         moreurl = params.base_uri() + '/search?' + urlencode(queryparts)
368     else:
369         moreurl = ''
370
371     fmt_options = {'query': query, 'more_url': moreurl,
372                    'exclude_place_ids': queryparts.get('exclude_place_ids'),
373                    'viewbox': queryparts.get('viewbox'),
374                    'extratags': params.get_bool('extratags', False),
375                    'namedetails': params.get_bool('namedetails', False),
376                    'entrances': params.get_bool('entrances', False),
377                    'addressdetails': params.get_bool('addressdetails', False)}
378
379     output = params.formatting().format_result(results, fmt, fmt_options)
380
381     return build_response(params, output, num_results=len(results))
382
383
384 async def deletable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
385     """ Server glue for /deletable endpoint.
386         This is a special endpoint that shows polygons that have been
387         deleted or are broken in the OSM data but are kept in the
388         Nominatim database to minimize disruption.
389     """
390     fmt = parse_format(params, RawDataList, 'json')
391
392     results = RawDataList()
393     async with api.begin() as conn:
394         for osm_type in ('N', 'W', 'R'):
395             sql = sa.text(""" SELECT p.place_id, country_code,
396                                      name->'name' as name, i.*
397                               FROM placex p, import_polygon_delete i
398                               WHERE i.osm_type = :osm_type
399                                     AND p.osm_id = i.osm_id AND p.osm_type = :osm_type
400                                     AND p.class = i.class AND p.type = i.type
401                           """)
402             results.extend(r._asdict() for r in await conn.execute(sql, {'osm_type': osm_type}))
403
404     return build_response(params, params.formatting().format_result(results, fmt, {}))
405
406
407 async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
408     """ Server glue for /polygons endpoint.
409         This is a special endpoint that shows polygons that have changed
410         their size but are kept in the Nominatim database with their
411         old area to minimize disruption.
412     """
413     fmt = parse_format(params, RawDataList, 'json')
414     sql_params: Dict[str, Any] = {
415         'days': params.get_int('days', -1),
416         'cls': params.get('class')
417     }
418     reduced = params.get_bool('reduced', False)
419
420     async with api.begin() as conn:
421         sql = sa.select(sa.text("""osm_type, osm_id, class, type,
422                                    name->'name' as name,
423                                    country_code, errormessage, updated"""))\
424                 .select_from(sa.text('import_polygon_error'))
425         if sql_params['days'] > 0:
426             sql = sql.where(sa.text("updated > 'now'::timestamp - make_interval(days => :days)"))
427         if reduced:
428             sql = sql.where(sa.text("errormessage like 'Area reduced%'"))
429         if sql_params['cls'] is not None:
430             sql = sql.where(sa.text("class = :cls"))
431
432         sql = sql.order_by(sa.literal_column('updated').desc()).limit(1000)
433
434         results = RawDataList(r._asdict() for r in await conn.execute(sql, sql_params))
435
436     return build_response(params, params.formatting().format_result(results, fmt, {}))
437
438
439 async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]:
440     routes = [
441         ('status', status_endpoint),
442         ('details', details_endpoint),
443         ('reverse', reverse_endpoint),
444         ('lookup', lookup_endpoint),
445         ('deletable', deletable_endpoint),
446         ('polygons', polygons_endpoint),
447     ]
448
449     def has_search_name(conn: sa.engine.Connection) -> bool:
450         insp = sa.inspect(conn)
451         return insp.has_table('search_name')
452
453     try:
454         async with api.begin() as conn:
455             if await conn.connection.run_sync(has_search_name):
456                 routes.append(('search', search_endpoint))
457     except (PGCORE_ERROR, sa.exc.OperationalError):
458         pass  # ignored
459
460     return routes