From 047e9bc4ad86d4bf7c41452a56dc8a9d04cea134 Mon Sep 17 00:00:00 2001 From: Sri CHaRan Date: Sun, 8 Mar 2026 19:29:33 +0530 Subject: [PATCH 1/6] add admin_level to extatags in JSON format --- src/nominatim_api/v1/format_json.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/nominatim_api/v1/format_json.py b/src/nominatim_api/v1/format_json.py index 3c665ace4..1128e592c 100644 --- a/src/nominatim_api/v1/format_json.py +++ b/src/nominatim_api/v1/format_json.py @@ -7,14 +7,22 @@ """ Helper functions for output of results in json formats. """ -from typing import Mapping, Any, Optional, Tuple, Union, List +from typing import Mapping, Any, Optional, Tuple, Dict, Union, List from ..utils.json_writer import JsonWriter -from ..results import AddressLines, ReverseResults, SearchResults +from ..results import AddressLines, ReverseResults, SearchResults, BaseResult from . import classtypes as cl from ..types import EntranceDetails +def _add_admin_level(result: BaseResult) -> Optional[Dict[str, str]]: + tags = result.extratags + if result.category == ('boundary', 'administrative') and result.admin_level < 15: + tags = dict(tags) if tags else {} + tags['admin_level'] = str(result.admin_level) + return tags + + def _write_osm_id(out: JsonWriter, osm_object: Optional[Tuple[str, int]]) -> None: if osm_object is not None: out.keyval_not_none('osm_type', cl.OSM_TYPE_NAME.get(osm_object[0], None))\ @@ -134,7 +142,7 @@ def format_base_json(results: Union[ReverseResults, SearchResults], write_entrances(out, result.entrances) if options.get('extratags', False): - out.keyval('extratags', result.extratags) + out.keyval('extratags', _add_admin_level(result)) if options.get('namedetails', False): out.keyval('namedetails', result.names) @@ -210,7 +218,7 @@ def format_base_geojson(results: Union[ReverseResults, SearchResults], write_entrances(out, result.entrances) if options.get('extratags', False): - out.keyval('extratags', result.extratags) + out.keyval('extratags', _add_admin_level(result)) if options.get('namedetails', False): out.keyval('namedetails', result.names) @@ -284,7 +292,7 @@ def format_base_geocodejson(results: Union[ReverseResults, SearchResults], write_entrances(out, result.entrances) if options.get('extratags', False): - out.keyval('extra', result.extratags) + out.keyval('extra', _add_admin_level(result)) out.end_object().next().end_object().next() From 11e458ca47c80cef3fcf333f0c49cea28ee33577 Mon Sep 17 00:00:00 2001 From: Sri CHaRan Date: Sun, 8 Mar 2026 19:32:01 +0530 Subject: [PATCH 2/6] add admin_level to extratags for XML and details API endpoint --- src/nominatim_api/v1/format.py | 3 ++- src/nominatim_api/v1/format_xml.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/nominatim_api/v1/format.py b/src/nominatim_api/v1/format.py index bd0013124..9cb79154d 100644 --- a/src/nominatim_api/v1/format.py +++ b/src/nominatim_api/v1/format.py @@ -19,6 +19,7 @@ from ..localization import Locales from ..result_formatting import FormatDispatcher from .classtypes import ICONS from . import format_json, format_xml +from .format_json import _add_admin_level from .. import logging as loglib from ..server import content_types as ct @@ -157,7 +158,7 @@ def _format_details_json(result: DetailedResult, options: Mapping[str, Any]) -> .keyval_not_none('indexed_date', result.indexed_date, lambda v: v.isoformat())\ .keyval_not_none('importance', result.importance)\ .keyval('calculated_importance', result.calculated_importance())\ - .keyval('extratags', result.extratags or {})\ + .keyval('extratags', _add_admin_level(result) or {})\ .keyval_not_none('calculated_wikipedia', result.wikipedia)\ .keyval('rank_address', result.rank_address)\ .keyval('rank_search', result.rank_search)\ diff --git a/src/nominatim_api/v1/format_xml.py b/src/nominatim_api/v1/format_xml.py index a28b3ff5b..e4cde139f 100644 --- a/src/nominatim_api/v1/format_xml.py +++ b/src/nominatim_api/v1/format_xml.py @@ -14,6 +14,7 @@ import xml.etree.ElementTree as ET from ..results import AddressLines, ReverseResult, ReverseResults, \ SearchResult, SearchResults from . import classtypes as cl +from .format_json import _add_admin_level from ..types import EntranceDetails @@ -125,8 +126,9 @@ def format_base_xml(results: Union[ReverseResults, SearchResults], if options.get('extratags', False): eroot = ET.SubElement(root if simple else place, 'extratags') - if result.extratags: - for k, v in result.extratags.items(): + tags = _add_admin_level(result) + if tags: + for k, v in tags.items(): ET.SubElement(eroot, 'tag', attrib={'key': k, 'value': v}) if options.get('namedetails', False): From b195550c0745062c2a72b07de39ed91e1cb24251 Mon Sep 17 00:00:00 2001 From: Sri Charan Chittineni Date: Mon, 9 Mar 2026 04:59:20 +0000 Subject: [PATCH 3/6] add _add_admin_level helper function to helpers.py --- src/nominatim_api/v1/helpers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/nominatim_api/v1/helpers.py b/src/nominatim_api/v1/helpers.py index d2da412ba..794270440 100644 --- a/src/nominatim_api/v1/helpers.py +++ b/src/nominatim_api/v1/helpers.py @@ -12,10 +12,20 @@ from typing import Tuple, Optional, Any, Dict, Iterable from itertools import chain import re -from ..results import SearchResults, SourceTable +from ..results import SearchResults, SourceTable, BaseResult from ..types import SearchDetails, GeometryFormat +def _add_admin_level(result: BaseResult) -> Optional[Dict[str, str]]: + """ Inject admin_level into extratags for boundary=administrative results. + """ + tags = result.extratags + if result.category == ('boundary', 'administrative') and result.admin_level < 15: + tags = dict(tags) if tags else {} + tags['admin_level'] = str(result.admin_level) + return tags + + REVERSE_MAX_RANKS = [2, 2, 2, # 0-2 Continent/Sea 4, 4, # 3-4 Country 8, # 5 State From 2b0c18d333282f6db6acb7130678a270e22522ea Mon Sep 17 00:00:00 2001 From: Sri Charan Chittineni Date: Mon, 9 Mar 2026 05:01:47 +0000 Subject: [PATCH 4/6] modify import paths to helpers.py --- src/nominatim_api/v1/format.py | 2 +- src/nominatim_api/v1/format_json.py | 13 +++---------- src/nominatim_api/v1/format_xml.py | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/nominatim_api/v1/format.py b/src/nominatim_api/v1/format.py index 9cb79154d..c90301a95 100644 --- a/src/nominatim_api/v1/format.py +++ b/src/nominatim_api/v1/format.py @@ -19,7 +19,7 @@ from ..localization import Locales from ..result_formatting import FormatDispatcher from .classtypes import ICONS from . import format_json, format_xml -from .format_json import _add_admin_level +from .helpers import _add_admin_level from .. import logging as loglib from ..server import content_types as ct diff --git a/src/nominatim_api/v1/format_json.py b/src/nominatim_api/v1/format_json.py index 1128e592c..2b4a6ca86 100644 --- a/src/nominatim_api/v1/format_json.py +++ b/src/nominatim_api/v1/format_json.py @@ -7,22 +7,15 @@ """ Helper functions for output of results in json formats. """ -from typing import Mapping, Any, Optional, Tuple, Dict, Union, List +from typing import Mapping, Any, Optional, Tuple, Union, List from ..utils.json_writer import JsonWriter -from ..results import AddressLines, ReverseResults, SearchResults, BaseResult +from ..results import AddressLines, ReverseResults, SearchResults from . import classtypes as cl +from .helpers import _add_admin_level from ..types import EntranceDetails -def _add_admin_level(result: BaseResult) -> Optional[Dict[str, str]]: - tags = result.extratags - if result.category == ('boundary', 'administrative') and result.admin_level < 15: - tags = dict(tags) if tags else {} - tags['admin_level'] = str(result.admin_level) - return tags - - def _write_osm_id(out: JsonWriter, osm_object: Optional[Tuple[str, int]]) -> None: if osm_object is not None: out.keyval_not_none('osm_type', cl.OSM_TYPE_NAME.get(osm_object[0], None))\ diff --git a/src/nominatim_api/v1/format_xml.py b/src/nominatim_api/v1/format_xml.py index e4cde139f..19bea01d1 100644 --- a/src/nominatim_api/v1/format_xml.py +++ b/src/nominatim_api/v1/format_xml.py @@ -14,7 +14,7 @@ import xml.etree.ElementTree as ET from ..results import AddressLines, ReverseResult, ReverseResults, \ SearchResult, SearchResults from . import classtypes as cl -from .format_json import _add_admin_level +from .helpers import _add_admin_level from ..types import EntranceDetails From c24bc292ea0f124b531d8995c43f926291142d50 Mon Sep 17 00:00:00 2001 From: Sri Charan Chittineni Date: Mon, 9 Mar 2026 20:00:32 +0000 Subject: [PATCH 5/6] add unit tests for admin_level when boundary=administrative --- test/python/api/test_result_formatting_v1.py | 96 ++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/python/api/test_result_formatting_v1.py b/test/python/api/test_result_formatting_v1.py index 9d4293a4f..0d7489274 100644 --- a/test/python/api/test_result_formatting_v1.py +++ b/test/python/api/test_result_formatting_v1.py @@ -12,6 +12,7 @@ For functional tests see BDD test suite. """ import datetime as dt import json +import xml.etree.ElementTree as ET import pytest @@ -332,3 +333,98 @@ def test_search_details_keywords_address(): assert js['keywords'] == {'address': [{'id': 23, 'token': 'foo'}, {'id': 24, 'token': 'foo'}], 'name': []} + + +# admin_level injection into extratags + +SEARCH_FORMATS = ['json', 'jsonv2', 'geojson', 'geocodejson', 'xml'] + + +@pytest.mark.parametrize('fmt', SEARCH_FORMATS) +def test_search_extratags_boundary_administrative_injects_admin_level(fmt): + search = napi.SearchResult(napi.SourceTable.PLACEX, + ('boundary', 'administrative'), + napi.Point(1.0, 2.0), + admin_level=6, + extratags={'place': 'city'}) + + raw = v1_format.format_result(napi.SearchResults([search]), fmt, + {'extratags': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + tags = {tag.attrib['key']: tag.attrib['value'] + for tag in root.find('.//extratags').findall('tag')} + assert tags['admin_level'] == '6' + assert tags['place'] == 'city' + else: + result = json.loads(raw) + if fmt == 'geocodejson': + extra = result['features'][0]['properties']['geocoding']['extra'] + elif fmt == 'geojson': + extra = result['features'][0]['properties']['extratags'] + else: + extra = result[0]['extratags'] + + assert extra['admin_level'] == '6' + assert extra['place'] == 'city' + + +@pytest.mark.parametrize('fmt', SEARCH_FORMATS) +def test_search_extratags_non_boundary_no_admin_level_injection(fmt): + search = napi.SearchResult(napi.SourceTable.PLACEX, + ('place', 'city'), + napi.Point(1.0, 2.0), + admin_level=8, + extratags={'place': 'city'}) + + raw = v1_format.format_result(napi.SearchResults([search]), fmt, + {'extratags': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + tags = {tag.attrib['key']: tag.attrib['value'] + for tag in root.find('.//extratags').findall('tag')} + assert 'admin_level' not in tags + assert tags['place'] == 'city' + else: + result = json.loads(raw) + if fmt == 'geocodejson': + extra = result['features'][0]['properties']['geocoding']['extra'] + elif fmt == 'geojson': + extra = result['features'][0]['properties']['extratags'] + else: + extra = result[0]['extratags'] + + assert 'admin_level' not in extra + assert extra['place'] == 'city' + + +@pytest.mark.parametrize('fmt', SEARCH_FORMATS) +def test_search_extratags_boundary_admin_level_15_no_injection(fmt): + search = napi.SearchResult(napi.SourceTable.PLACEX, + ('boundary', 'administrative'), + napi.Point(1.0, 2.0), + admin_level=15, + extratags={'place': 'city'}) + + raw = v1_format.format_result(napi.SearchResults([search]), fmt, + {'extratags': True}) + + if fmt == 'xml': + root = ET.fromstring(raw) + tags = {tag.attrib['key']: tag.attrib['value'] + for tag in root.find('.//extratags').findall('tag')} + assert 'admin_level' not in tags + assert tags['place'] == 'city' + else: + result = json.loads(raw) + if fmt == 'geocodejson': + extra = result['features'][0]['properties']['geocoding']['extra'] + elif fmt == 'geojson': + extra = result['features'][0]['properties']['extratags'] + else: + extra = result[0]['extratags'] + + assert 'admin_level' not in extra + assert extra['place'] == 'city' From b71389274b6a9f4595d99a716447bfbd488a3397 Mon Sep 17 00:00:00 2001 From: Sri Charan Chittineni Date: Tue, 10 Mar 2026 06:49:19 +0000 Subject: [PATCH 6/6] add BDD scenario for admin_level when boundary=administrative --- test/bdd/features/api/search/params.feature | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/bdd/features/api/search/params.feature b/test/bdd/features/api/search/params.feature index 04d6dc1e3..fcc02c25a 100644 --- a/test/bdd/features/api/search/params.feature +++ b/test/bdd/features/api/search/params.feature @@ -318,6 +318,28 @@ Feature: Search queries | jsonv2 | json | | geojson | geojson | + Scenario Outline: Search boundary=administrative with extratags=1 returns admin_level + When sending v1/search with format + | q | featureType | extratags | + | Triesenberg | city | 1 | + Then a HTTP 200 is returned + And the result is valid + And more than 0 results are returned + And result 0 contains + | | | + | boundary | administrative | + And result 0 contains in field + | param | value | + | admin_level | 8 | + + Examples: + | format | outformat | cname | tname | ename | + | xml | xml | class | type | extratags | + | json | json | class | type | extratags | + | jsonv2 | json | category | type | extratags | + | geojson | geojson | category | type | extratags | + | geocodejson | geocodejson | osm_key | osm_value | extra | + Scenario Outline: Search with namedetails When sending v1/search with format | q | namedetails |