Add POI feature and imporve the overall backend implementation
This commit is contained in:
302
services/external/app/api/geocoding.py
vendored
Normal file
302
services/external/app/api/geocoding.py
vendored
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user