Files
bakery-ia/services/external/app/services/nominatim_service.py

283 lines
8.8 KiB
Python

"""
Nominatim Geocoding Service
Provides address search and geocoding using OpenStreetMap Nominatim API.
For development: uses public API (rate-limited)
For production: should point to self-hosted Nominatim instance
"""
import httpx
from typing import List, Dict, Any, Optional
import structlog
from asyncio import sleep
logger = structlog.get_logger()
class NominatimService:
"""
Nominatim geocoding and address search service.
Uses OpenStreetMap Nominatim API for address autocomplete and geocoding.
Respects rate limits and usage policy.
"""
# For development: public API (rate-limited to 1 req/sec)
# For production: should be overridden with self-hosted instance
DEFAULT_BASE_URL = "https://nominatim.openstreetmap.org"
def __init__(self, base_url: Optional[str] = None, user_agent: str = "BakeryIA-Forecasting/1.0"):
"""
Initialize Nominatim service.
Args:
base_url: Nominatim server URL (defaults to public API)
user_agent: User agent for API requests (required by Nominatim policy)
"""
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
self.user_agent = user_agent
self.headers = {
"User-Agent": self.user_agent
}
# Rate limiting for public API (1 request per second)
self.is_public_api = self.base_url == self.DEFAULT_BASE_URL
self.min_request_interval = 1.0 if self.is_public_api else 0.0
logger.info(
"Nominatim service initialized",
base_url=self.base_url,
is_public_api=self.is_public_api,
rate_limit=f"{self.min_request_interval}s" if self.is_public_api else "none"
)
async def search_address(
self,
query: str,
country_code: str = "es",
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Search for addresses matching query (autocomplete).
Args:
query: Address search query
country_code: ISO country code to restrict search (default: Spain)
limit: Maximum number of results
Returns:
List of address suggestions with display_name, lat, lon, osm_id, etc.
"""
if not query or len(query.strip()) < 3:
logger.warning("Search query too short", query=query)
return []
try:
# Rate limiting for public API
if self.is_public_api:
await sleep(self.min_request_interval)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{self.base_url}/search",
params={
"q": query,
"format": "json",
"addressdetails": 1,
"countrycodes": country_code,
"limit": limit,
"accept-language": "es"
},
headers=self.headers
)
response.raise_for_status()
results = response.json()
# Parse and enrich results
addresses = []
for result in results:
addresses.append({
"display_name": result.get("display_name"),
"lat": float(result.get("lat")),
"lon": float(result.get("lon")),
"osm_type": result.get("osm_type"),
"osm_id": result.get("osm_id"),
"place_id": result.get("place_id"),
"type": result.get("type"),
"class": result.get("class"),
"address": result.get("address", {}),
"boundingbox": result.get("boundingbox", [])
})
logger.info(
"Address search completed",
query=query,
result_count=len(addresses)
)
return addresses
except httpx.HTTPError as e:
logger.error(
"Nominatim API request failed",
query=query,
error=str(e)
)
return []
except Exception as e:
logger.error(
"Unexpected error in address search",
query=query,
error=str(e),
exc_info=True
)
return []
async def geocode_address(
self,
address: str,
country_code: str = "es"
) -> Optional[Dict[str, Any]]:
"""
Geocode an address to get coordinates.
Args:
address: Full address string
country_code: ISO country code
Returns:
Dictionary with lat, lon, display_name, address components or None
"""
results = await self.search_address(address, country_code, limit=1)
if not results:
logger.warning("No geocoding results found", address=address)
return None
result = results[0]
logger.info(
"Address geocoded successfully",
address=address,
lat=result["lat"],
lon=result["lon"]
)
return result
async def reverse_geocode(
self,
latitude: float,
longitude: float
) -> Optional[Dict[str, Any]]:
"""
Reverse geocode coordinates to get address.
Args:
latitude: Latitude coordinate
longitude: Longitude coordinate
Returns:
Dictionary with address information or None
"""
try:
# Rate limiting for public API
if self.is_public_api:
await sleep(self.min_request_interval)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{self.base_url}/reverse",
params={
"lat": latitude,
"lon": longitude,
"format": "json",
"addressdetails": 1,
"accept-language": "es"
},
headers=self.headers
)
response.raise_for_status()
result = response.json()
address_info = {
"display_name": result.get("display_name"),
"lat": float(result.get("lat")),
"lon": float(result.get("lon")),
"osm_type": result.get("osm_type"),
"osm_id": result.get("osm_id"),
"place_id": result.get("place_id"),
"address": result.get("address", {}),
"boundingbox": result.get("boundingbox", [])
}
logger.info(
"Reverse geocoding completed",
lat=latitude,
lon=longitude,
address=address_info["display_name"]
)
return address_info
except httpx.HTTPError as e:
logger.error(
"Nominatim reverse geocoding failed",
lat=latitude,
lon=longitude,
error=str(e)
)
return None
except Exception as e:
logger.error(
"Unexpected error in reverse geocoding",
lat=latitude,
lon=longitude,
error=str(e),
exc_info=True
)
return None
async def validate_coordinates(
self,
latitude: float,
longitude: float
) -> bool:
"""
Validate that coordinates point to a real location.
Args:
latitude: Latitude to validate
longitude: Longitude to validate
Returns:
True if coordinates are valid, False otherwise
"""
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
return False
result = await self.reverse_geocode(latitude, longitude)
return result is not None
async def health_check(self) -> bool:
"""
Check if Nominatim service is accessible.
Returns:
True if service is healthy, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{self.base_url}/status",
params={"format": "json"},
headers=self.headers
)
return response.status_code == 200
except Exception as e:
logger.error(
"Nominatim health check failed",
error=str(e)
)
return False