Skip to content
This repository was archived by the owner on Jun 3, 2024. It is now read-only.

Commit a36b26e

Browse files
authored
Add public transports ft. Hove's API (#324)
Add public transport support through Hove's API. This is built by building a translation layer that will convert Hove's API result into a format mostly compatible with Mapbox (a few structures are extended with new fields), which can be interpreted by Erdapfel seamlessly and in the same fashion as with former Combigo API. I also took my change to move every directions-related stuff in `idunn.datasources.directions`, which means a bit of refactoring in the mapbox client.
1 parent 52a2712 commit a36b26e

19 files changed

+9959
-4274
lines changed

idunn/api/directions.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from idunn import settings
55
from idunn.places import Latlon
66
from idunn.places.exceptions import IdunnPlaceError
7-
from ..directions.client import directions_client
7+
from ..datasources.directions import directions_client
88
from ..utils.place import place_from_id
99

1010

@@ -17,7 +17,7 @@ def directions_request(request: Request, response: Response):
1717
return request
1818

1919

20-
def get_directions_with_coordinates(
20+
async def get_directions_with_coordinates(
2121
# URL values
2222
f_lon: confloat(ge=-180, le=180) = Path(..., title="Origin point longitude"),
2323
f_lat: confloat(ge=-90, le=90) = Path(..., title="Origin point latitude"),
@@ -34,12 +34,12 @@ def get_directions_with_coordinates(
3434
to_place = Latlon(t_lat, t_lon)
3535
if not type:
3636
raise HTTPException(status_code=400, detail='"type" query param is required')
37-
return directions_client.get_directions(
38-
from_place, to_place, type, language, params=request.query_params
37+
return await directions_client.get_directions(
38+
from_place, to_place, type, language, extra=request.query_params
3939
)
4040

4141

42-
def get_directions(
42+
async def get_directions(
4343
# Query parameters
4444
origin: str = Query(..., description="Origin place id."),
4545
destination: str = Query(..., description="Destination place id."),
@@ -55,6 +55,6 @@ def get_directions(
5555
except IdunnPlaceError as exc:
5656
raise HTTPException(status_code=404, detail=exc.message) from exc
5757

58-
return directions_client.get_directions(
59-
from_place, to_place, type, language, params=request.query_params
58+
return await directions_client.get_directions(
59+
from_place, to_place, type, language, extra=request.query_params
6060
)

idunn/api/geocoder.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ..geocoder.bragi_client import bragi_client
88
from ..geocoder.models.geocodejson import Intention
99
from ..geocoder.nlu_client import nlu_client, NluClientException
10+
1011
from ..geocoder.models import QueryParams, ExtraParams, IdunnAutocomplete
1112

1213
from idunn import settings

idunn/api/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .places_list import get_places_bbox, PlacesBboxResponse
77
from .categories import AllCategoriesResponse, get_all_categories
88
from .closest import closest_address
9-
from ..directions.models import DirectionsResponse
9+
from ..datasources.directions.mapbox.models import DirectionsResponse
1010
from .geocoder import get_autocomplete_response
1111
from ..geocoder.models import IdunnAutocomplete
1212
from .directions import get_directions_with_coordinates, get_directions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
from abc import ABC, abstractmethod, abstractproperty
3+
from fastapi import HTTPException
4+
from pydantic import BaseModel
5+
from typing import Callable, Optional
6+
7+
8+
from idunn import settings
9+
from idunn.datasources.directions.abs_client import AbsDirectionsClient
10+
from idunn.geocoder.models import QueryParams
11+
from idunn.places.base import BasePlace
12+
from .hove.client import HoveClient
13+
from .mapbox.client import MapboxClient
14+
from .mapbox.models import DirectionsResponse, IdunnTransportMode
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class MapboxAPIExtraParams(BaseModel):
20+
steps: str = "true"
21+
alternatives: str = "true"
22+
overview: str = "full"
23+
geometries: str = "geojson"
24+
exclude: Optional[str]
25+
26+
27+
class DirectionsClient(AbsDirectionsClient):
28+
def __init__(self):
29+
self.mapbox = MapboxClient()
30+
self.hove = HoveClient()
31+
32+
@staticmethod
33+
def client_name() -> str:
34+
return "generic"
35+
36+
def get_method_for_mode(self, mode: IdunnTransportMode) -> AbsDirectionsClient:
37+
methods = {"mapbox": self.mapbox, "hove": self.hove}
38+
39+
match mode:
40+
case IdunnTransportMode.CAR:
41+
return methods[settings["DIRECTIONS_PROVIDER_DRIVE"]]
42+
case IdunnTransportMode.BIKE:
43+
return methods[settings["DIRECTIONS_PROVIDER_CYCLE"]]
44+
case IdunnTransportMode.WALKING:
45+
return methods[settings["DIRECTIONS_PROVIDER_WALK"]]
46+
case IdunnTransportMode.PUBLICTRANSPORT:
47+
return methods[settings["DIRECTIONS_PROVIDER_PUBLICTRANSPORT"]]
48+
49+
@staticmethod
50+
def place_to_url_coords(place):
51+
coord = place.get_coord()
52+
lat, lon = coord["lat"], coord["lon"]
53+
return (f"{lon:.5f}", f"{lat:.5f}")
54+
55+
async def get_directions(
56+
self,
57+
from_place: BasePlace,
58+
to_place: BasePlace,
59+
mode: IdunnTransportMode,
60+
lang: str,
61+
extra: Optional[QueryParams] = None,
62+
) -> DirectionsResponse:
63+
idunn_mode = IdunnTransportMode.parse(mode)
64+
65+
if idunn_mode is None:
66+
raise HTTPException(status_code=400, detail=f"unknown mode {mode}")
67+
68+
method = self.get_method_for_mode(idunn_mode)
69+
70+
logger.info(
71+
"Calling directions API '%s'",
72+
method.client_name(),
73+
extra={
74+
"method": method.client_name(),
75+
"mode": idunn_mode,
76+
"lang": lang,
77+
"from_place": from_place.get_id(),
78+
"to_place": to_place.get_id(),
79+
},
80+
)
81+
82+
# pylint: disable = not-callable
83+
return await method.get_directions(from_place, to_place, idunn_mode, lang, extra)
84+
85+
86+
directions_client = DirectionsClient()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Optional
3+
4+
from idunn.geocoder.models.params import QueryParams
5+
from idunn.places.base import BasePlace
6+
from .mapbox.models import DirectionsResponse, IdunnTransportMode
7+
8+
9+
class AbsDirectionsClient(ABC):
10+
@staticmethod
11+
@abstractmethod
12+
def client_name() -> str:
13+
...
14+
15+
@abstractmethod
16+
async def get_directions(
17+
self,
18+
from_place: BasePlace,
19+
to_place: BasePlace,
20+
mode: IdunnTransportMode,
21+
lang: str,
22+
extra: Optional[QueryParams] = None,
23+
) -> DirectionsResponse:
24+
...
File renamed without changes.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import httpx
2+
from fastapi import HTTPException
3+
from typing import Optional
4+
5+
from idunn import settings
6+
from idunn.geocoder.models.params import QueryParams
7+
from idunn.places.base import BasePlace
8+
from .models import HoveResponse
9+
from ..abs_client import AbsDirectionsClient
10+
from ..mapbox.models import IdunnTransportMode
11+
12+
13+
DIRECT_PATH_MAX_DURATION = 86400 # 24h
14+
MIN_NB_JOURNEYS = 2
15+
MAX_NB_JOURNEYS = 5
16+
FREE_RADIUS = 50 # meters
17+
18+
19+
class HoveClient(AbsDirectionsClient):
20+
def __init__(self):
21+
self.api_url = settings["HOVE_API_BASE_URL"]
22+
self.session = httpx.AsyncClient(verify=settings["VERIFY_HTTPS"])
23+
self.session.headers["User-Agent"] = settings["USER_AGENT"]
24+
25+
@staticmethod
26+
def client_name() -> str:
27+
return "hove"
28+
29+
@property
30+
def API_ENABLED(self): # pylint: disable = invalid-name
31+
return bool(settings["HOVE_API_TOKEN"])
32+
33+
async def get_directions(
34+
self,
35+
from_place: BasePlace,
36+
to_place: BasePlace,
37+
mode: IdunnTransportMode,
38+
_lang: str,
39+
_extra: Optional[QueryParams] = None,
40+
) -> HoveResponse:
41+
if not self.API_ENABLED:
42+
raise HTTPException(
43+
status_code=501,
44+
detail=f"Directions API is currently unavailable for mode {mode}",
45+
)
46+
47+
from_place = from_place.get_coord()
48+
to_place = to_place.get_coord()
49+
50+
params = {
51+
"from": f"{from_place['lon']};{from_place['lat']}",
52+
"to": f"{to_place['lon']};{to_place['lat']}",
53+
"free_radius_from": FREE_RADIUS,
54+
"free_radius_to": FREE_RADIUS,
55+
"max_walking_direct_path_duration": DIRECT_PATH_MAX_DURATION,
56+
"max_bike_direct_path_duration": DIRECT_PATH_MAX_DURATION,
57+
"max_car_no_park_direct_path_duration": DIRECT_PATH_MAX_DURATION,
58+
"min_nb_journeys": MIN_NB_JOURNEYS,
59+
"max_nb_journeys": MAX_NB_JOURNEYS,
60+
**(
61+
{"direct_path": "none"}
62+
if mode == IdunnTransportMode.PUBLICTRANSPORT
63+
else {
64+
"direct_path_mode[]": mode.to_hove(),
65+
"direct_path": "only",
66+
}
67+
),
68+
}
69+
70+
response = await self.session.get(
71+
self.api_url,
72+
params=params,
73+
headers={"Authorization": settings["HOVE_API_TOKEN"]},
74+
)
75+
76+
response.raise_for_status()
77+
return HoveResponse(**response.json()).as_api_response()

0 commit comments

Comments
 (0)