package main import ( "encoding/json" "net/http" "github.com/go-redis/redis/v8" "trip-planner/internal/cache" "trip-planner/internal/routing" "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 } // NewHandlerContext creates a new HandlerContext with initialized services. func NewHandlerContext(redisClient *redis.Client, router *routing.Graph, yandex *yandex.Client) *HandlerContext { return &HandlerContext{ Cache: cache.NewCacheStore(redisClient), Redis: redisClient, Router: router, Yandex: yandex, } } // cityResponse represents a city in the autocomplete response. type cityResponse struct { Code string `json:"code"` Name string `json:"name"` } // CitiesQuery represents the query parameters for city autocomplete. type CitiesQuery struct { Query string `json:"query"` } // CityAutocomplete handles GET /v1/cities?query= // Returns matching cities from cache/directory. func CityAutocomplete(h *HandlerContext, w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("query") if query == "" { http.Error(w, "query parameter is required", http.StatusBadRequest) return } // Try to get cities from cache first // For now, we'll use a simple approach - check cache for city data // Since we don't have a direct "get all cities" cache method, // we'll return a basic response. In a full implementation, // this would query Postgres or use a cache-wide search. // For now, return empty list with 200 to avoid breaking the API. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityResponse{}) } // cityStationsResponse represents a station in the response. type cityStationsResponse struct { ID string `json:"id"` Name string `json:"name"` CityCode string `json:"city_code"` } // CityStations handles GET /v1/cities/{id}/stations // Returns list of stations for a city, including neighbors if main station is closed. func CityStations(h *HandlerContext, w http.ResponseWriter, r *http.Request) { // Parse city ID from path: /v1/cities/{id}/stations path := r.URL.Path // Expected format: /v1/cities/{id}/stations parts := splitPath(path) if len(parts) < 4 || parts[1] != "cities" { http.Error(w, "city ID is required", http.StatusBadRequest) return } cityID := parts[3] if cityID == "" { http.Error(w, "city ID is required", http.StatusBadRequest) return } // Check cache for stations in this city ctx := r.Context() cacheKey := cache.GetCityKey(cityID) data, err := h.Cache.Get(ctx, cacheKey) if err != nil { http.Error(w, "failed to query cache", http.StatusInternalServerError) return } if data == nil { // Cache miss - try to get from Yandex API or Postgres // For now, return empty list w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityStationsResponse{}) return } // Parse the stored data - could be []cache.StationInfo or similar // For now, return what we have w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityStationsResponse{}) } // routeSearchRequest represents the request body for route search. type routeSearchRequest struct { FromCityID string `json:"from_city_id"` ToCityID string `json:"to_city_id"` Date string `json:"date"` } // routeLegSummary represents a summarized route leg for the response. type routeLegSummary struct { From string `json:"from"` To string `json:"to"` Duration int `json:"duration"` Transport string `json:"transport"` IsTransfer bool `json:"is_transfer"` } // routeSearchResponse represents the response for route search. type routeSearchResponse struct { Routes []routeLegSummary `json:"routes"` Count int `json:"count"` } // RouteSearch handles POST /v1/routes/search // Searches for routes between cities with Pareto-optimal results (time, transfers). func RouteSearch(h *HandlerContext, w http.ResponseWriter, r *http.Request) { var req routeSearchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.FromCityID == "" || req.ToCityID == "" || req.Date == "" { http.Error(w, "from_city_id, to_city_id, and date are required", http.StatusBadRequest) return } // Build/search the routing graph for this city pair // Use the routing graph that's already built originNode := h.Router.NodesByID(req.FromCityID) destNode := h.Router.NodesByID(req.ToCityID) if originNode == nil || destNode == nil { http.Error(w, "origin or destination node not found in graph", http.StatusNotFound) return } // Search with max 1 transfer (Pareto-optimal) opts := routing.SearchOptions{ MaxTransfers: 1, MCT: 300, // 5 minutes default MCT } result := h.Router.FindRoute(req.FromCityID, req.ToCityID, opts) if result == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeSearchResponse{ Routes: []routeLegSummary{}, Count: 0, }) return } // Build route leg summary legs := make([]routeLegSummary, len(result.Legs)) for i, leg := range result.Legs { legs[i] = routeLegSummary{ From: leg.From.Name, To: leg.To.Name, Duration: leg.Duration, Transport: leg.Transport, IsTransfer: leg.IsTransfer, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeSearchResponse{ Routes: legs, Count: 1, }) } // routeGeoJSONResponse represents the GeoJSON geometry response. type routeGeoJSONResponse struct { SearchID string `json:"search_id"` RouteID string `json:"route_id"` GeoJSON any `json:"geojson"` } // RouteGeoJSON handles GET /v1/routes/{search_id}/{route_id}/geojson // Returns GeoJSON geometry for a specific route. func RouteGeoJSON(h *HandlerContext, w http.ResponseWriter, r *http.Request) { // Parse path: /v1/routes/{search_id}/{route_id}/geojson path := r.URL.Path parts := splitPath(path) if len(parts) < 5 || parts[1] != "routes" { http.Error(w, "search_id and route_id are required", http.StatusBadRequest) return } searchID := parts[2] routeID := parts[3] if searchID == "" || routeID == "" { http.Error(w, "search_id and route_id are required", http.StatusBadRequest) return } // Build GeoJSON for the route // This would use the route legs to construct a GeoJSON FeatureCollection // For now, return a basic geometry placeholder geojson := map[string]any{ "type": "FeatureCollection", "features": []map[string]any{ { "type": "Feature", "properties": map[string]any{ "search_id": searchID, "route_id": routeID, }, "geometry": map[string]any{ "type": "LineString", "coordinates": [][]float64{ {-44.7, 46.8}, {37.6, 55.8}, }, }, }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeGeoJSONResponse{ SearchID: searchID, RouteID: routeID, GeoJSON: geojson, }) } // stationStatusResponse represents station status. type stationStatusResponse struct { Status string `json:"status"` ZeroSince string `json:"zero_since,omitempty"` } // StationStatus handles GET /v1/stations/{id}/status // Returns the current status of a station. func StationStatus(h *HandlerContext, w http.ResponseWriter, r *http.Request) { // Parse station ID from path: /v1/stations/{id}/status path := r.URL.Path parts := splitPath(path) if len(parts) < 3 || parts[1] != "stations" { http.Error(w, "station ID is required", http.StatusBadRequest) return } stationID := parts[2] if stationID == "" { http.Error(w, "station ID is required", http.StatusBadRequest) return } ctx := r.Context() // Check cache for station status cacheKey := &cache.CacheKey{ Kind: "station", Code: stationID, } data, err := h.Cache.Get(ctx, cacheKey) if err != nil { http.Error(w, "failed to query cache", http.StatusInternalServerError) return } if data == nil { // Cache miss - return active status as default w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stationStatusResponse{ Status: "active", }) return } // Parse stored status data // For now, return default active status w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stationStatusResponse{ Status: "active", }) } // splitPath splits a URL path into segments, removing leading/trailing slashes. func splitPath(path string) []string { // Remove leading slash path = trimSlashLeft(path) // Remove trailing slash path = trimSlashRight(path) if path == "" { return nil } return splitBySlash(path) } // trimSlashLeft removes leading slashes from a string. func trimSlashLeft(s string) string { for len(s) > 0 && s[0] == '/' { s = s[1:] } return s } // trimSlashRight removes trailing slashes from a string. func trimSlashRight(s string) string { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] } return s } // splitBySlash splits a path string by slash separator. func splitBySlash(s string) []string { var parts []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '/' { if i > start { parts = append(parts, s[start:i]) } start = i + 1 } } if start < len(s) { parts = append(parts, s[start:]) } return parts }