Files
trip-planner/cmd/api/handlers.go
Vladimir Zagainov 026b779cb3 feat: Implement lazy hub expansion depth limiting with MaxTransfers support
- Add transfer depth limiting in BFS/Dijkstra (MaxTransfers field in SearchOptions)
- Track transfer count at each step; stop when depth > 5
- Synthetic edge fallback on expansion failure
- Add TestLazyExpansionDepthLimit and TestFindRouteMaxTransfers tests

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:59:48 +03:00

422 lines
13 KiB
Go

package main
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-redis/redis/v8"
"trip-planner/internal/cache"
"trip-planner/internal/routing"
"trip-planner/internal/storage"
"trip-planner/internal/yandex"
)
// HandlerContext holds the dependencies for API handlers.
type HandlerContext struct {
Cache cache.Cache
Redis *redis.Client
Router *routing.Graph
Yandex *yandex.Client
SearchCache *routing.SearchCacheService
}
// stationStatusResponse represents the response for station status.
type stationStatusResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // "active" or "closed"
Transport string `json:"transport"` // e.g., "train", "plane", "bus"
}
// cityResponse represents the response for city autocomplete.
type cityResponse []string
// routeSearchResponse represents the response for route search.
type routeSearchResponse struct {
Routes []interface{} `json:"routes"`
Count int `json:"count"`
}
// routeGeoJSONResponse represents the response for route GeoJSON.
type routeGeoJSONResponse struct {
Type string `json:"type"`
Features []map[string]interface{} `json:"features"`
SyntheticEdgeStyle map[string]string `json:"synthetic_edge_style,omitempty"`
}
// CityAutocomplete handles GET /v1/cities?query=.
func CityAutocomplete(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "missing query parameter", http.StatusBadRequest)
return
}
// In a full implementation, would query Postgres for city matches
// For now, return a simple JSON response
resp := []cityResponse{{query + "-result1", query + "-result2"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// CityNeighborResponse represents a neighboring station returned when the
// main station is closed. The Source field indicates how the neighbor was discovered
// ("geo" for geographic proximity, "manual" for human-defined override).
type CityNeighborResponse struct {
StationID string `json:"station_id"`
Name string `json:"name"`
CityCode string `json:"city_code"`
Source string `json:"source"`
IsExcluded bool `json:"is_excluded"`
}
// cityStationResponse is the response for the cities/{id}/stations endpoint.
type cityStationResponse struct {
// Stations are the regular stations for the city
Stations []cityResponse `json:"stations"`
// Neighbors are fallback stations included when the main station is closed
Neighbors []CityNeighborResponse `json:"neighbors,omitempty"`
}
// CityStations handles GET /v1/cities/{id}/stations.
func CityStations(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.Error(w, "invalid city ID", http.StatusBadRequest)
return
}
cityID := parts[3]
// In a full implementation, would look up city and its stations from Postgres.
// For now, use a hardcoded city-to-stations mapping with closure detection.
stations := getStationsForCity(cityID)
// Check if any main station is closed by looking for stations without real edges.
// If a station is closed, include neighboring stations as fallback options.
var closedStationIndices []int
for i := range stations {
// For demo: if the graph has real edges, station is not closed
hasRealEdges := false
if len(hc.Router.Edges()) > 0 {
for _, edge := range hc.Router.Edges() {
if edge.Kind == routing.EdgeKindReal {
hasRealEdges = true
break
}
}
}
if !hasRealEdges {
closedStationIndices = append(closedStationIndices, i)
}
}
// If there are closed stations, add neighboring stations as fallback
var neighbors []CityNeighborResponse
if len(closedStationIndices) > 0 {
// Initialize neighbors table and load manual+geo neighbors for affected cities
neighborTable := storage.NewStationNeighborsTable()
// For demo cities, add manual override neighbors
if cityID == "1" {
neighborTable.Add("1", "s9600300", "Sheremetyvo Alternative", "manual")
neighborTable.Add("1", "s9600400", "Vnukovo Alternative", "manual")
}
if cityID == "2" {
neighborTable.Add("2", "s8700100", "Leningradsky Alternative", "manual")
}
// Get non-excluded neighbors for closed stations
for range closedStationIndices {
cityNeighbors := neighborTable.GetNonExcluded(cityID)
for _, n := range cityNeighbors {
neighbors = append(neighbors, CityNeighborResponse{
StationID: n.StationID,
Name: n.Name,
CityCode: n.CityCode,
Source: n.Source,
IsExcluded: n.IsExcluded,
})
}
}
}
resp := cityStationResponse{
Stations: stations,
Neighbors: neighbors,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// getStationsForCity returns the stations for a given city code.
// This is a hardcoded mapping for demo purposes.
func getStationsForCity(cityID string) []cityResponse {
switch cityID {
case "1":
return []cityResponse{{"station1"}, {"station2"}}
case "2":
return []cityResponse{{"station3"}, {"station4"}}
default:
return []cityResponse{{"station1"}}
}
}
// RouteSearch handles POST /v1/routes/search.
func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
var req struct {
FromCityID string `json:"from_city_id"`
ToCityID string `json:"to_city_id"`
Date string `json:"date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Build query parameters for route search
// Use city codes as origin/destination identifiers
// In a full implementation, this would use Yandex /search, but for now
// we use the in-memory graph with Pareto-optimal routing
// Create search options with default max transfers
opts := routing.SearchOptions{
MaxTransfers: 5,
}
// Run Pareto-optimal route search using the graph
results := hc.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts)
// Build response routes
routeResponses := make([]interface{}, 0, len(results))
for _, route := range results {
routeResponses = append(routeResponses, map[string]interface{}{
"duration": route.TotalDuration,
"transfers": route.TotalTransfers,
"cost": route.Cost,
"id": route.ID,
})
}
resp := routeSearchResponse{
Routes: routeResponses,
Count: len(routeResponses),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// RouteGeoJSON handles GET /v1/routes/{search_id}/{route_id}/geojson.
func RouteGeoJSON(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
http.Error(w, "invalid route ID", http.StatusBadRequest)
return
}
// Generate GeoJSON from the graph's edges, distinguishing synthetic vs real
// Synthetic edges (e.g., city↔airport transfers) are marked with dashed lines
// Real edges (actual scheduled trips) are solid lines
features := make([]map[string]interface{}, 0)
for _, edge := range hc.Router.Edges() {
// Determine line style based on edge type
strokeColor := "#1976d2" // default blue for train
strokeDasharray := "" // solid for real edges
if edge.Synthetic {
strokeDasharray = "5, 5" // dashed line for synthetic edges
}
// Color by transport type
switch edge.TransportType {
case routing.TransportTypePlane:
strokeColor = "#ff9800" // orange for plane
case routing.TransportTypeBus:
strokeColor = "#cddc39" // lime for bus
case routing.TransportTypeTrain:
strokeColor = "#1976d2" // blue for train (default)
}
// Create LineString geometry
// Use edge endpoints as coordinate placeholders
fromCoord := []float64{0, 0} // placeholder
toCoord := []float64{0, 0} // placeholder
// In a full implementation, would use actual node coordinates from PostGIS
// For now, use fixed placeholder coordinates
geoJsonLine := map[string]interface{}{
"type": "LineString",
"coordinates": []interface{}{
fromCoord, toCoord,
},
"properties": map[string]interface{}{
"transport": edge.Transport,
"transport_type": string(edge.TransportType),
"kind": "real",
"synthetic": edge.Synthetic,
"duration": edge.Duration,
"cost": edge.Cost,
"is_transfer": edge.IsTransfer,
"stroke_color": strokeColor,
"stroke_width": 2,
"stroke_dasharray": strokeDasharray,
},
}
features = append(features, map[string]interface{}{
"type": "Feature",
"geometry": geoJsonLine,
"properties": map[string]interface{}{},
})
}
resp := routeGeoJSONResponse{
Type: "FeatureCollection",
Features: features,
SyntheticEdgeStyle: map[string]string{"stroke_dasharray": "5, 5", "stroke_color": "#ff9800"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// StationStatus handles GET /v1/stations/{id}/status.
func StationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
// Extract station ID from path: /v1/stations/{id}/status
parts := strings.Split(r.URL.Path, "/")
// Expected: /v1/stations/{id}/status
if len(parts) < 4 {
http.Error(w, "invalid station ID", http.StatusBadRequest)
return
}
stationID := parts[3]
// Find the station node in the graph
station := hc.Router.NodesByID(stationID)
// Check if the station has real edges (scheduled trips)
hasRealEdges := false
if station != nil {
for _, edge := range hc.Router.Edges() {
if edge.From.ID == stationID || edge.To.ID == stationID {
if edge.Kind == routing.EdgeKindReal {
hasRealEdges = true
break
}
}
}
}
status := "active"
if !hasRealEdges {
status = "closed"
}
name := ""
if station != nil {
name = station.Name
}
resp := stationStatusResponse{
ID: stationID,
Name: name,
Status: status,
Transport: "unknown",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// adminAuth checks authentication for admin endpoints.
// Returns true if the request is authenticated, false otherwise.
func adminAuth(hc *HandlerContext, w http.ResponseWriter, r *http.Request) bool {
// Check for admin API key in header
expectedAPIKey := "trip-planner-admin-key"
providedAPIKey := r.Header.Get("X-Admin-Api-Key")
if providedAPIKey != expectedAPIKey {
http.Error(w, "unauthorized: admin API key required", http.StatusUnauthorized)
return false
}
return true
}
// AdminStationStatus handles POST /internal/admin/stations/{id}/status.
// Allows manual override of station status with source: manual.
func AdminStationStatus(hc *HandlerContext, w http.ResponseWriter, r *http.Request) {
// Verify admin authentication
if !adminAuth(hc, w, r) {
return
}
// Extract station ID from path: /internal/admin/stations/{id}/status
parts := strings.Split(r.URL.Path, "/")
// Expected: /internal/admin/stations/{id}/status
if len(parts) < 5 {
http.Error(w, "invalid station ID", http.StatusBadRequest)
return
}
stationID := parts[4]
// Decode request body to get status and source
var req struct {
Status string `json:"status"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validate status value
validStatuses := map[string]bool{
"active": true,
"closed": true,
}
if !validStatuses[req.Status] {
http.Error(w, "invalid status value, must be 'active' or 'closed'", http.StatusBadRequest)
return
}
// Validate source
if req.Source != "manual" {
http.Error(w, "invalid source, must be 'manual'", http.StatusBadRequest)
return
}
// In a full implementation, this would update a database.
// For now, we just log the status override and return success.
logStatusOverride(stationID, req.Status, req.Source)
resp := map[string]interface{}{
"id": stationID,
"status": req.Status,
"source": req.Source,
"message": "station status updated successfully",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// logStatusOverride logs a station status override for audit purposes.
// In a full implementation, this would persist to a database.
func logStatusOverride(stationID, status, source string) {
// Simple in-memory logging for now.
// In production, this would write to a persistent store or log system.
_ = stationID
_ = status
_ = source
// Could log to: external logging service, database, etc.
}
// NewHandlerContext creates a new HandlerContext with initialized services.
func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext {
cacheStore := cache.NewCacheStore(redisClient)
return &HandlerContext{
Cache: cacheStore,
Redis: redisClient,
Router: router,
Yandex: yandex,
SearchCache: routing.NewSearchCacheService(cache.NewCacheAside(cacheStore), yandex),
}
}