diff --git a/lib/netsyms_api.py b/lib/netsyms_api.py index 4a0847f..e530d15 100644 --- a/lib/netsyms_api.py +++ b/lib/netsyms_api.py @@ -4,13 +4,19 @@ RECON Netsyms API + Geocode — Flask Blueprints. GET /api/netsyms/lookup?q=&country= GET /api/netsyms/health GET /api/geocode?q=&limit= (Photon-first search with ranked results) +GET /api/reverse// (localhost-sourced enrichment bundle for Central) """ +import sqlite3 +import threading + +from cachetools import TTLCache from flask import Blueprint, request, jsonify from . import netsyms from . import address_book from . import nav_tools +from .geocode import PHOTON_URL from .utils import setup_logging logger = setup_logging('recon.netsyms_api') @@ -124,3 +130,158 @@ def api_reverse(): results = _parse_photon_features(features, source='photon_reverse') return jsonify({'query': query_str, 'results': results, 'count': len(results)}) + + +# ───────────────────────────────────────────────────────────────────────── +# /api/reverse// — localhost-sourced enrichment bundle (Central) +# +# Sibling to the query-string /api/reverse above; that route is unchanged. +# Every component is sourced from localhost only (Photon, timezones.sqlite, +# in-process landclass/PostGIS, Valhalla). Each lookup is independent: a +# component failure logs a warning and yields null — never a 5xx. +# ───────────────────────────────────────────────────────────────────────── + +_TZ_DB_PATH = "/mnt/nav/sources/timezones.sqlite" +_VALHALLA_HEIGHT_URL = "http://localhost:8002/height" + +# Full bundle cache: key=(round(lat,4), round(lon,4)) -> dict. ~10k entries, 24h TTL. +_REVERSE_BUNDLE_CACHE = TTLCache(maxsize=10_000, ttl=86_400) +_REVERSE_BUNDLE_LOCK = threading.Lock() + +_BUNDLE_KEYS = ('name', 'city', 'county', 'state', 'country', + 'postal_code', 'timezone', 'landclass', 'elevation_m') + + +def _spatialite_blob_to_wkb(blob): + """Recover standard WKB from a SpatiaLite geometry BLOB. + + Layout: [00][endian][srid:4][mbr:32][7C][WKB body][FE]. The body omits the + leading byte-order marker, so we re-prepend it and drop the trailing 0xFE. + """ + return bytes([blob[1]]) + blob[39:-1] + + +def _reverse_photon(lat, lon): + """Nearest-feature admin fields from local Photon. Returns the six address + fields (any value may be None). Mirrors the existing /api/reverse call.""" + import requests as http_requests + resp = http_requests.get( + f"{PHOTON_URL}/reverse", + params={"lat": lat, "lon": lon, "limit": 1}, + timeout=10, + ) + resp.raise_for_status() + features = resp.json().get("features", []) + if not features: + return {} + props = features[0].get("properties", {}) + return { + "name": props.get("name"), + "city": props.get("city"), + "county": props.get("county"), + "state": props.get("state"), + "country": props.get("country"), + "postal_code": props.get("postcode"), + } + + +def _reverse_timezone(lat, lon): + """IANA tzid for the point from local timezones.sqlite (SpatiaLite tz_world). + + Uses the table's R-tree index for an MBR prefilter, then shapely + point-in-polygon on the few candidates. Returns None if unresolved. + """ + from shapely import wkb + from shapely.geometry import Point + con = sqlite3.connect(f"file:{_TZ_DB_PATH}?mode=ro", uri=True) + try: + cur = con.cursor() + cur.execute( + "SELECT pkid FROM idx_tz_world_geom " + "WHERE xmin<=? AND xmax>=? AND ymin<=? AND ymax>=?", + (lon, lon, lat, lat), + ) + candidates = [r[0] for r in cur.fetchall()] + if not candidates: + return None + pt = Point(lon, lat) + for pk in candidates: + row = cur.execute( + "SELECT tzid, geom FROM tz_world WHERE pk_uid=?", (pk,) + ).fetchone() + if row and wkb.loads(_spatialite_blob_to_wkb(row[1])).contains(pt): + return row[0] + return None + finally: + con.close() + + +def _reverse_landclass(lat, lon): + """Most-specific PAD-US land class for the point, looked up in-process. + Returns None when there is no coverage or landclass is unavailable.""" + from .landclass import lookup_landclass, format_summary + return format_summary(lookup_landclass(lat, lon)) + + +def _reverse_elevation(lat, lon): + """Elevation in metres from local Valhalla /height. None on failure.""" + import requests as http_requests + resp = http_requests.post( + _VALHALLA_HEIGHT_URL, + json={"shape": [{"lat": lat, "lon": lon}]}, + timeout=10, + ) + resp.raise_for_status() + heights = resp.json().get("height", []) + return heights[0] if heights else None + + +@geocode_bp.route('/api/reverse//') +def api_reverse_bundle(lat, lon): + """Localhost-sourced reverse-geocode enrichment bundle for Central. + + GET /api/reverse// + + Always returns 200 with EXACTLY these keys (any may be null): + name, city, county, state, country, postal_code, timezone, landclass, elevation_m + + lat/lon are parsed manually (not via Flask's converter, which + rejects negative and integer coordinates) so out-of-range or unparseable + input yields 400 per contract; 503 is reserved for catastrophic failure. + """ + try: + lat = float(lat) + lon = float(lon) + except (ValueError, TypeError): + return jsonify({'error': 'lat and lon must be numbers'}), 400 + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return jsonify({'error': 'lat must be -90..90, lon must be -180..180'}), 400 + + key = (round(lat, 4), round(lon, 4)) + with _REVERSE_BUNDLE_LOCK: + cached = _REVERSE_BUNDLE_CACHE.get(key) + if cached is not None: + return jsonify(cached) + + bundle = {k: None for k in _BUNDLE_KEYS} + + try: + bundle.update(_reverse_photon(lat, lon)) + except Exception: + logger.warning("reverse-bundle: Photon lookup failed for %s,%s", lat, lon) + try: + bundle['timezone'] = _reverse_timezone(lat, lon) + except Exception: + logger.warning("reverse-bundle: timezone lookup failed for %s,%s", lat, lon) + try: + bundle['landclass'] = _reverse_landclass(lat, lon) + except Exception: + logger.warning("reverse-bundle: landclass lookup failed for %s,%s", lat, lon) + try: + bundle['elevation_m'] = _reverse_elevation(lat, lon) + except Exception: + logger.warning("reverse-bundle: elevation lookup failed for %s,%s", lat, lon) + + with _REVERSE_BUNDLE_LOCK: + _REVERSE_BUNDLE_CACHE[key] = bundle + return jsonify(bundle) diff --git a/lib/reverse_bundle_test.py b/lib/reverse_bundle_test.py new file mode 100644 index 0000000..d825b71 --- /dev/null +++ b/lib/reverse_bundle_test.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Tests for the /api/reverse// enrichment bundle (lib.netsyms_api). + +Photon/Valhalla/landclass are mocked so the suite runs without live services; +one timezone test exercises the real SpatiaLite DB when it is present. Plain +asserts + a __main__ runner, matching the rest of lib/*_test.py. +""" + +import os +import sys +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from flask import Flask +from lib import netsyms_api + +EXPECTED_KEYS = {'name', 'city', 'county', 'state', 'country', + 'postal_code', 'timezone', 'landclass', 'elevation_m'} + + +def _client(): + app = Flask(__name__) + app.register_blueprint(netsyms_api.geocode_bp) + return app.test_client() + + +def _clear_cache(): + netsyms_api._REVERSE_BUNDLE_CACHE.clear() + + +def test_happy_path(): + _clear_cache() + with mock.patch.object(netsyms_api, '_reverse_photon', return_value={ + 'name': 'Where you are', 'city': 'Boise', 'county': 'Ada', + 'state': 'Idaho', 'country': 'United States', 'postal_code': '83701'}), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value='America/Boise'), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value='Boise National Forest'), \ + mock.patch.object(netsyms_api, '_reverse_elevation', return_value=824): + resp = _client().get('/api/reverse/43.6150/-116.2023') + assert resp.status_code == 200, resp.status_code + data = resp.get_json() + assert set(data.keys()) == EXPECTED_KEYS, data.keys() + assert data['city'] == 'Boise' and data['timezone'] == 'America/Boise' + assert data['landclass'] == 'Boise National Forest' and data['elevation_m'] == 824 + print(" PASS: happy path — all 9 fields populated, exact key set") + + +def test_negative_and_integer_coords_parse(): + # Regression: Flask's converter would 404 these; manual parse must not. + _clear_cache() + with mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_elevation', return_value=None): + for path in ('/api/reverse/43.6/-116.2', '/api/reverse/43/-116'): + resp = _client().get(path) + assert resp.status_code == 200, f"{path} -> {resp.status_code}" + assert set(resp.get_json().keys()) == EXPECTED_KEYS + print(" PASS: negative and integer coordinates parse (200, not 404)") + + +def test_partial_failure_returns_200_with_nulls(): + _clear_cache() + with mock.patch.object(netsyms_api, '_reverse_photon', + side_effect=RuntimeError('photon down')), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value='America/Boise'), \ + mock.patch.object(netsyms_api, '_reverse_landclass', + side_effect=RuntimeError('postgis down')), \ + mock.patch.object(netsyms_api, '_reverse_elevation', return_value=824): + resp = _client().get('/api/reverse/43.6150/-116.2023') + assert resp.status_code == 200, resp.status_code + data = resp.get_json() + assert set(data.keys()) == EXPECTED_KEYS + assert data['name'] is None and data['city'] is None # photon failed -> nulls + assert data['landclass'] is None # landclass failed -> null + assert data['timezone'] == 'America/Boise' and data['elevation_m'] == 824 + print(" PASS: per-component failure -> 200 with nulls, no 5xx") + + +def test_ocean_point_mostly_null(): + _clear_cache() + with mock.patch.object(netsyms_api, '_reverse_photon', return_value={}), \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value='Etc/GMT+2'), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_elevation', return_value=0): + resp = _client().get('/api/reverse/0.0/-30.0') + assert resp.status_code == 200, resp.status_code + data = resp.get_json() + assert set(data.keys()) == EXPECTED_KEYS + assert data['city'] is None and data['country'] is None and data['landclass'] is None + print(" PASS: ocean point -> 200, mostly null") + + +def test_invalid_input_400(): + _clear_cache() + client = _client() + for path in ('/api/reverse/9999/0', '/api/reverse/0/9999', '/api/reverse/abc/0'): + resp = client.get(path) + assert resp.status_code == 400, f"{path} -> {resp.status_code}" + print(" PASS: out-of-range / unparseable input -> 400") + + +def test_cache_hit_serves_without_recompute(): + _clear_cache() + with mock.patch.object(netsyms_api, '_reverse_photon', + return_value={'name': 'X'}) as m_photon, \ + mock.patch.object(netsyms_api, '_reverse_timezone', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_landclass', return_value=None), \ + mock.patch.object(netsyms_api, '_reverse_elevation', return_value=None): + client = _client() + client.get('/api/reverse/12.3456/-65.4321') + client.get('/api/reverse/12.3456/-65.4321') # same key (rounded) -> cached + assert m_photon.call_count == 1, f"expected 1 compute, got {m_photon.call_count}" + print(" PASS: second identical request served from cache (no recompute)") + + +def test_real_timezone_db(): + if not os.path.exists(netsyms_api._TZ_DB_PATH): + print(" SKIP: real timezone test (timezones.sqlite not present)") + return + assert netsyms_api._reverse_timezone(43.6150, -116.2023) == 'America/Boise' + assert netsyms_api._reverse_timezone(40.7128, -74.0060) == 'America/New_York' + print(" PASS: real timezones.sqlite point-in-polygon") + + +if __name__ == '__main__': + print("Running reverse-bundle tests...") + test_happy_path() + test_negative_and_integer_coords_parse() + test_partial_failure_returns_200_with_nulls() + test_ocean_point_mostly_null() + test_invalid_input_400() + test_cache_hit_serves_without_recompute() + test_real_timezone_db() + print("All tests passed.") diff --git a/requirements.txt b/requirements.txt index f643cd8..1da21bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ anyio==4.12.1 babel==2.18.0 beautifulsoup4==4.14.3 blinker==1.9.0 +cachetools==7.1.3 certifi==2026.1.4 cffi==2.0.0 charset-normalizer==3.4.4