303 lines
8.3 KiB
Python
303 lines
8.3 KiB
Python
"""
|
|
Geocoding API Endpoints
|
|
|
|
Provides address search, autocomplete, and geocoding via Nominatim.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Query, HTTPException
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel, Field
|
|
import structlog
|
|
|
|
from app.services.nominatim_service import NominatimService
|
|
|
|
logger = structlog.get_logger()
|
|
router = APIRouter(prefix="/api/v1/geocoding", tags=["Geocoding"])
|
|
|
|
# Initialize Nominatim service
|
|
# In production, override with environment variable for self-hosted instance
|
|
nominatim_service = NominatimService()
|
|
|
|
|
|
# Response Models
|
|
class AddressResult(BaseModel):
|
|
"""Address search result"""
|
|
display_name: str = Field(..., description="Full formatted address")
|
|
lat: float = Field(..., description="Latitude")
|
|
lon: float = Field(..., description="Longitude")
|
|
osm_type: str = Field(..., description="OSM object type")
|
|
osm_id: int = Field(..., description="OSM object ID")
|
|
place_id: int = Field(..., description="Nominatim place ID")
|
|
type: str = Field(..., description="Place type")
|
|
class_: str = Field(..., alias="class", description="OSM class")
|
|
address: dict = Field(..., description="Parsed address components")
|
|
boundingbox: List[str] = Field(..., description="Bounding box coordinates")
|
|
|
|
|
|
class GeocodeResult(BaseModel):
|
|
"""Geocoding result"""
|
|
display_name: str = Field(..., description="Full formatted address")
|
|
lat: float = Field(..., description="Latitude")
|
|
lon: float = Field(..., description="Longitude")
|
|
address: dict = Field(..., description="Parsed address components")
|
|
|
|
|
|
class CoordinateValidation(BaseModel):
|
|
"""Coordinate validation result"""
|
|
valid: bool = Field(..., description="Whether coordinates are valid")
|
|
address: Optional[str] = Field(None, description="Address at coordinates if valid")
|
|
|
|
|
|
# Endpoints
|
|
@router.get(
|
|
"/search",
|
|
response_model=List[AddressResult],
|
|
summary="Search for addresses",
|
|
description="Search for addresses matching query (autocomplete). Minimum 3 characters required."
|
|
)
|
|
async def search_addresses(
|
|
q: str = Query(..., min_length=3, description="Search query (minimum 3 characters)"),
|
|
country_code: str = Query("es", description="ISO country code to restrict search"),
|
|
limit: int = Query(10, ge=1, le=50, description="Maximum number of results")
|
|
):
|
|
"""
|
|
Search for addresses matching the query.
|
|
|
|
This endpoint provides autocomplete functionality for address input.
|
|
Results are restricted to the specified country and sorted by relevance.
|
|
|
|
Example:
|
|
GET /api/v1/geocoding/search?q=Gran%20Via%20Madrid&limit=5
|
|
"""
|
|
try:
|
|
results = await nominatim_service.search_address(
|
|
query=q,
|
|
country_code=country_code,
|
|
limit=limit
|
|
)
|
|
|
|
logger.info(
|
|
"Address search request",
|
|
query=q,
|
|
country=country_code,
|
|
result_count=len(results)
|
|
)
|
|
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Address search failed",
|
|
query=q,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Address search failed: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/geocode",
|
|
response_model=GeocodeResult,
|
|
summary="Geocode an address",
|
|
description="Convert an address string to coordinates (lat/lon)"
|
|
)
|
|
async def geocode_address(
|
|
address: str = Query(..., min_length=5, description="Full address to geocode"),
|
|
country_code: str = Query("es", description="ISO country code")
|
|
):
|
|
"""
|
|
Geocode an address to get coordinates.
|
|
|
|
Returns the best matching location for the given address.
|
|
|
|
Example:
|
|
GET /api/v1/geocoding/geocode?address=Gran%20Via%2028,%20Madrid
|
|
"""
|
|
try:
|
|
result = await nominatim_service.geocode_address(
|
|
address=address,
|
|
country_code=country_code
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Address not found: {address}"
|
|
)
|
|
|
|
logger.info(
|
|
"Geocoding request",
|
|
address=address,
|
|
lat=result["lat"],
|
|
lon=result["lon"]
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
"Geocoding failed",
|
|
address=address,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Geocoding failed: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/reverse",
|
|
response_model=GeocodeResult,
|
|
summary="Reverse geocode coordinates",
|
|
description="Convert coordinates (lat/lon) to an address"
|
|
)
|
|
async def reverse_geocode(
|
|
lat: float = Query(..., ge=-90, le=90, description="Latitude"),
|
|
lon: float = Query(..., ge=-180, le=180, description="Longitude")
|
|
):
|
|
"""
|
|
Reverse geocode coordinates to get address.
|
|
|
|
Returns the address at the specified coordinates.
|
|
|
|
Example:
|
|
GET /api/v1/geocoding/reverse?lat=40.4168&lon=-3.7038
|
|
"""
|
|
try:
|
|
result = await nominatim_service.reverse_geocode(
|
|
latitude=lat,
|
|
longitude=lon
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No address found at coordinates: {lat}, {lon}"
|
|
)
|
|
|
|
logger.info(
|
|
"Reverse geocoding request",
|
|
lat=lat,
|
|
lon=lon,
|
|
address=result["display_name"]
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
"Reverse geocoding failed",
|
|
lat=lat,
|
|
lon=lon,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Reverse geocoding failed: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/validate",
|
|
response_model=CoordinateValidation,
|
|
summary="Validate coordinates",
|
|
description="Check if coordinates point to a valid location"
|
|
)
|
|
async def validate_coordinates(
|
|
lat: float = Query(..., ge=-90, le=90, description="Latitude"),
|
|
lon: float = Query(..., ge=-180, le=180, description="Longitude")
|
|
):
|
|
"""
|
|
Validate that coordinates point to a real location.
|
|
|
|
Returns validation result with address if valid.
|
|
|
|
Example:
|
|
GET /api/v1/geocoding/validate?lat=40.4168&lon=-3.7038
|
|
"""
|
|
try:
|
|
is_valid = await nominatim_service.validate_coordinates(
|
|
latitude=lat,
|
|
longitude=lon
|
|
)
|
|
|
|
result = {"valid": is_valid, "address": None}
|
|
|
|
if is_valid:
|
|
geocode_result = await nominatim_service.reverse_geocode(lat, lon)
|
|
if geocode_result:
|
|
result["address"] = geocode_result["display_name"]
|
|
|
|
logger.info(
|
|
"Coordinate validation request",
|
|
lat=lat,
|
|
lon=lon,
|
|
valid=is_valid
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Coordinate validation failed",
|
|
lat=lat,
|
|
lon=lon,
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Coordinate validation failed: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/health",
|
|
summary="Check geocoding service health",
|
|
description="Check if Nominatim service is accessible"
|
|
)
|
|
async def health_check():
|
|
"""
|
|
Check if Nominatim service is accessible.
|
|
|
|
Returns service health status.
|
|
"""
|
|
try:
|
|
is_healthy = await nominatim_service.health_check()
|
|
|
|
if not is_healthy:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Nominatim service is unavailable"
|
|
)
|
|
|
|
return {
|
|
"status": "healthy",
|
|
"service": "nominatim",
|
|
"base_url": nominatim_service.base_url,
|
|
"is_public_api": nominatim_service.is_public_api
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
"Health check failed",
|
|
error=str(e),
|
|
exc_info=True
|
|
)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Health check failed: {str(e)}"
|
|
)
|