Skip to content

Commit 6635901

Browse files
committed
Add endpoint & logic for getting multiple stops map
1 parent 9cc594c commit 6635901

File tree

5 files changed

+112
-18
lines changed

5 files changed

+112
-18
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ _La API puede devolver información de Paradas y listados en tiempo real de los
9595
- Add Near Stops endpoint
9696
- Add endpoint for static maps and StreetView acquisition
9797
- Add endpoints for static buses info
98+
- On multi-stop map generation, return markers showing stop IDs
9899
- Add integration tests
99100
- Add detailed install & configuration instructions
100101
- Improve endpoint and services/controllers in-code organization
@@ -106,6 +107,7 @@ _La API puede devolver información de Paradas y listados en tiempo real de los
106107
- _Añadir endpoint para consulta de paradas cercanas a ubicación_
107108
- _Añadir endpoint para obtención de mapas estáticos y StreetView_
108109
- _Añadir endpoints para consulta de información estática de buses_
110+
- _En generación de mapas de varias paradas, devolver marcadores mostrando IDs de parada_
109111
- _Añadir tests de integración_
110112
- _Añadir instrucciones detalladas de instalación y configuración_
111113
- _Mejorar organización en código de endpoints y servicios/controladores_

vigobusapi/app.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
# # Native # #
66
import io
7+
import json
78
from typing import Optional, Set
89

910
# # Installed # #
1011
import uvicorn
11-
from fastapi import FastAPI, Response, Query, HTTPException
12+
from fastapi import FastAPI, Response, Query, Depends, HTTPException
1213
from starlette.responses import StreamingResponse
1314

1415
# # Project # #
@@ -87,13 +88,24 @@ async def endpoint_get_buses(stop_id: int, get_all_buses: bool = False):
8788
return buses_result.dict()
8889

8990

91+
class MapQueryParams:
92+
def __init__(
93+
self,
94+
size_x: int = google_maps_settings.stop_map_default_size_x,
95+
size_y: int = google_maps_settings.stop_map_default_size_y,
96+
zoom: int = google_maps_settings.stop_map_default_zoom,
97+
map_type: GoogleMapRequest.MapTypes = google_maps_settings.stop_map_default_type
98+
):
99+
self.size_x = size_x
100+
self.size_y = size_y
101+
self.zoom = zoom
102+
self.map_type = map_type
103+
104+
90105
@app.get("/stop/{stop_id}/map")
91106
async def endpoint_get_stop_map(
92107
stop_id: int,
93-
size_x: int = google_maps_settings.stop_map_default_size_x,
94-
size_y: int = google_maps_settings.stop_map_default_size_y,
95-
zoom: int = google_maps_settings.stop_map_default_zoom,
96-
map_type: GoogleMapRequest.MapTypes = google_maps_settings.stop_map_default_type
108+
map_params: MapQueryParams = Depends()
97109
):
98110
"""Get a picture of a map with the stop location marked on it."""
99111
stop = await get_stop(stop_id)
@@ -103,16 +115,53 @@ async def endpoint_get_stop_map(
103115
map_request = GoogleMapRequest(
104116
location_x=stop.lat,
105117
location_y=stop.lon,
106-
size_x=size_x,
107-
size_y=size_y,
108-
zoom=zoom,
109-
map_type=map_type,
118+
size_x=map_params.size_x,
119+
size_y=map_params.size_y,
120+
zoom=map_params.zoom,
121+
map_type=map_params.map_type,
110122
tags=[GoogleMapRequest.Tag(location_x=stop.lat, location_y=stop.lon)]
111123
)
112124
map_data = await get_map(map_request)
113125
return StreamingResponse(io.BytesIO(map_data), media_type="image/png")
114126

115127

128+
@app.get("/stops/map")
129+
async def endpoint_get_stops_map(
130+
stops_ids: Set[int] = Query(None, alias="stop_id", min_items=1, max_items=35),
131+
map_params: MapQueryParams = Depends(),
132+
):
133+
"""Get a picture of a map with the locations of the given stops marked on it.
134+
The marks are labelled in the same order as the given stops ids.
135+
136+
A header "X-Stops-Tags" is returned, being a JSON associating the Stops IDs with the tag label on the map,
137+
with the format: {"<stop id>" : "<tag label>"}
138+
"""
139+
stops = await get_stops(stops_ids)
140+
141+
stops_tags = list()
142+
stops_tags_relation = dict()
143+
for i, stop in enumerate(stops):
144+
tag_label = GoogleMapRequest.Tag.get_allowed_labels()[i]
145+
tag = GoogleMapRequest.Tag(label=tag_label, location_x=stop.lat, location_y=stop.lon)
146+
stops_tags.append(tag)
147+
stops_tags_relation[stop.stop_id] = tag_label
148+
149+
map_request = GoogleMapRequest(
150+
size_x=map_params.size_x,
151+
size_y=map_params.size_y,
152+
zoom=map_params.zoom,
153+
map_type=map_params.map_type,
154+
tags=stops_tags
155+
)
156+
map_data = await get_map(map_request)
157+
158+
return StreamingResponse(
159+
content=io.BytesIO(map_data),
160+
media_type="image/png",
161+
headers={"X-Stops-Tags": json.dumps(stops_tags_relation)}
162+
)
163+
164+
116165
@app.get("/stop/{stop_id}/photo")
117166
async def endpoint_get_stop_photo(
118167
stop_id: int,
@@ -130,6 +179,8 @@ async def endpoint_get_stop_photo(
130179
size_y=size_y
131180
)
132181
photo_data = await get_photo(photo_request)
182+
if not photo_data:
183+
raise HTTPException(status_code=404, detail="No StreetView photo available for the stop location")
133184
return StreamingResponse(io.BytesIO(photo_data), media_type="image/png")
134185

135186

vigobusapi/services/google_maps.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
# # Native # #
6+
import string
67
from enum import Enum
78
from typing import *
89

@@ -30,7 +31,9 @@ class _GoogleMapsBaseRequest(BaseModel, ChecksumableClass):
3031
size_y: int
3132

3233
@property
33-
def location_str(self):
34+
def location_str(self) -> Optional[str]:
35+
if self.location_x is None or self.location_y is None:
36+
return None
3437
return f"{self.location_x},{self.location_y}"
3538

3639
@property
@@ -54,10 +57,16 @@ class GoogleMapRequest(_GoogleMapsBaseRequest):
5457
# Embed classes #
5558

5659
class Tag(BaseModel, ChecksumableClass):
57-
label: Optional[str] # single uppercase char (A~Z, 0~9)
60+
__ALLOWED_LABELS = [*[str(i) for i in range(1, 10)], *[c for c in string.ascii_uppercase]]
61+
62+
label: Optional[str] = None # TODO constrain values accepted (avoid enum?)
5863
location_x: float
5964
location_y: float
6065

66+
@classmethod
67+
def get_allowed_labels(cls):
68+
return cls.__ALLOWED_LABELS
69+
6170
@property
6271
def location_str(self):
6372
return f"{self.location_x},{self.location_y}"
@@ -107,8 +116,10 @@ def checksum_hash(self):
107116

108117
# Class Attributes #
109118

119+
location_x: Optional[float] = None
120+
location_y: Optional[float] = None
110121
tags: Optional[List[Tag]] = None
111-
zoom: int # TODO may be used by Streetview as well (refactor if so)
122+
zoom: int
112123
"""https://developers.google.com/maps/documentation/maps-static/start#Zoomlevels"""
113124
map_type: MapTypes
114125

@@ -153,14 +164,17 @@ async def get_map(request: GoogleMapRequest) -> bytes:
153164
logger.bind(map_request=request.dict()).debug("Requesting Google Static Map picture...")
154165
# TODO cache loaded pictures
155166
params = [
156-
("center", request.location_str),
157167
("size", request.size_str),
158-
("zoom", str(request.zoom)),
159168
("maptype", request.map_type.value),
160169
("language", settings.language),
161170
("format", "png8"),
162171
]
163172

173+
location_str = request.location_str
174+
if location_str:
175+
params.append(("center", location_str))
176+
params.append(("zoom", str(request.zoom)))
177+
164178
if request.tags:
165179
for tag in request.tags:
166180
tag_param_values = [tag.location_str] # Location always at the end

vigobusapi/services/http_requester.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ async def http_request(
7575
last_status_code = response.status_code
7676

7777
# Log response
78-
response_body = response.text
79-
response_body_size = len(response.content)
80-
if response_body_size > 500000 or "\\x00\\" in response_body: # 500kB
78+
response_body = response.content
79+
response_body_size = len(response_body)
80+
if response_body_size > 500000 or b"\x00" in response_body: # 500kB
8181
response_body = "binary or too large"
82+
else:
83+
response_body = response.text
8284
logger.bind(
8385
response_elapsed_time=response_time,
8486
response_status_code=last_status_code,

vigobusapi/utils.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
# # Native # #
66
import abc
77
import hashlib
8+
import json
9+
import base64 as _base64
10+
from typing import List, Dict, Union, Any
811

9-
__all__ = ("ChecksumableClass", "new_hash_values", "update_hash_values")
12+
# # Installed # #
13+
import pydantic
14+
from pydantic.json import pydantic_encoder
15+
16+
__all__ = ("ChecksumableClass", "new_hash_values", "update_hash_values", "base64_encode", "json_encode_object")
1017

1118

1219
class ChecksumableClass(abc.ABC):
@@ -42,3 +49,21 @@ def new_hash_values(*args, algorithm: str):
4249
Returns the hash object (the hash/checksum value can be acquired by using str(output))."""
4350
_hash = hashlib.new(algorithm)
4451
return update_hash_values(*args, _hash=_hash)
52+
53+
54+
def base64_encode(data: str) -> str:
55+
"""Encode the given string as base64"""
56+
return _base64.urlsafe_b64encode(data.encode()).decode()
57+
58+
59+
def json_encode_object(
60+
obj: Union[pydantic.BaseModel, List[pydantic.BaseModel], Dict[Any, pydantic.BaseModel]],
61+
base64: bool = False
62+
) -> str:
63+
"""Given a Pydantic object, a List of Pydantic objects, or a Dict with Pydantic objects, convert to JSON string.
64+
If base64=True, return the JSON result base64-encoded.
65+
"""
66+
encoded = json.dumps(obj, default=pydantic_encoder)
67+
if base64:
68+
return base64_encode(encoded)
69+
return encoded

0 commit comments

Comments
 (0)