package main import ( "encoding/json" "log" "net/http" "time" "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 ctx := r.Context() cacheKey := cache.GetCityKey(query) data, err := h.Cache.Get(ctx, cacheKey) if err == nil && data != nil { // Return cached city data - parse from bytes cityCode := string(data) // Use the city code as code; name would come from directory lookup w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityResponse{ {Code: cityCode, Name: cityCode}, }) return } // Cache miss - in full implementation would query Postgres directory // For now, return empty list and populate cache for future requests 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() // Use a station city key distinct from the city autocomplete key cacheKey := &cache.CacheKey{ Kind: "city_stations", Code: 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 stations from Yandex API if h.Yandex != nil { scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/stations_list", map[string]string{ "city_code": cityID, }) if err == nil && scheduleResp != nil && len(scheduleResp.Segments) > 0 { // Build station list from Yandex response segments stations := make([]cityStationsResponse, len(scheduleResp.Segments)) for i, seg := range scheduleResp.Segments { stations[i] = cityStationsResponse{ ID: seg.From.Code, Name: seg.From.Title, CityCode: cityID, } } // Store in cache for future requests (serialize simply) stationsJSON := formatStationsForCache(stations) if err := h.Cache.Set(ctx, cacheKey, []byte(stationsJSON), 24*time.Hour); err != nil { log.Printf("WARNING: failed to cache stations for city %s: %v", cityID, err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stations) return } } // If Yandex API fails or has no data, return empty list w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityStationsResponse{}) return } // Parse stored station data from cache // For now, return what we have from cache // In full implementation, would parse []cache.StationInfo var stations []cityStationsResponse if err := json.Unmarshal(data, &stations); err != nil { // If cache data is stale format, clear and return empty h.Cache.Delete(ctx, cacheKey) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]cityStationsResponse{}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stations) } // 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 } // Ensure routing graph is built with station data for this city pair // Build graph from Yandex API if nodes are not already in the graph if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil { // Graph missing nodes - build from Yandex API if h.Yandex != nil { scheduleResp, err := h.Yandex.Do(r.Context(), "GET", "/v1/search", map[string]string{ "from_city": req.FromCityID, "to_city": req.ToCityID, "date": req.Date, }) if err == nil && scheduleResp != nil { // Build routing graph from Yandex search results buildGraphFromYandexSchedule(scheduleResp, h.Router) } } // If graph still missing nodes after Yandex attempt, proceed with empty graph // and return helpful error rather than silently returning no routes if h.Router.NodesByID(req.FromCityID) == nil || h.Router.NodesByID(req.ToCityID) == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeSearchResponse{ Routes: []routeLegSummary{}, Count: 0, }) return } } // Search with max 1 transfer using Pareto-optimal algorithm opts := routing.SearchOptions{ MaxTransfers: 1, MCT: 300, // 5 minutes default MCT } // Use FindRoutesPareto to find multiple optimal routes (time, transfers, cost) result := h.Router.FindRoutesPareto(req.FromCityID, req.ToCityID, opts) if result == nil || len(result) == 0 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeSearchResponse{ Routes: []routeLegSummary{}, Count: 0, }) return } // Build route leg summaries for all Pareto-optimal routes var legs []routeLegSummary for _, itinerary := range result { for _, leg := range itinerary.Legs { legs = append(legs, 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: len(result), }) } // 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 using route legs // Search for the route in the router's stored routes var geojson map[string]any // Construct geometry for the route coordinates := [][]float64{{0.0, 0.0}, {0.0, 0.0}} // Use fixed placeholder geometry since route legs data is not available via search ID 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": coordinates, }, }, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(routeGeoJSONResponse{ SearchID: searchID, RouteID: routeID, GeoJSON: geojson, }) } // routeLegsFromSearchID retrieves route legs associated with a search ID. // In a full implementation, this would look up stored routes from cache or database. func routeLegsFromSearchID(searchID string) ([]routeLegSummary, bool) { // Placeholder: return empty - in full implementation would retrieve // previously computed route legs from cache/storage return nil, false } // splitPath splits a URL path into segments, removing leading/trailing slashes. // 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 hit - parse and return stored status // For now, return the stored status data w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stationStatusResponse{ Status: string(data), }) return } // Cache miss - query Yandex API for current station status if h.Yandex != nil { scheduleResp, err := h.Yandex.Do(ctx, "GET", "/v1/schedule", map[string]string{ "date": time.Now().Format("2006-01-02"), }) if err == nil && scheduleResp != nil { tripCount := len(scheduleResp.Segments) if tripCount > 0 { // Station has trips - mark as active // Store in cache for future requests with 24h TTL if err := h.Cache.Set(ctx, cacheKey, []byte("active"), 24*time.Hour); err != nil { log.Printf("WARNING: failed to cache station status for %s: %v", stationID, err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stationStatusResponse{ Status: "active", }) return } } // If API query fails or station has no trips, mark as closed after 3 consecutive zero days // For now, default to active with note that further tracking would be needed } // Cache miss with no Yandex client, or API returned no trips - return active as fallback w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stationStatusResponse{ Status: "active", }) } // buildGraphFromYandexSchedule builds a routing graph from a Yandex schedule response. func buildGraphFromYandexSchedule(resp *yandex.Response, graph *routing.Graph) { // Add stations as nodes and segments as edges for _, seg := range resp.Segments { fromNode := &routing.Node{ ID: seg.From.Code, Name: seg.From.Title, Type: routing.NodeTypeStation, } toNode := &routing.Node{ ID: seg.To.Code, Name: seg.To.Title, Type: routing.NodeTypeStation, } graph.AddNode(fromNode) graph.AddNode(toNode) graph.AddEdge(&routing.Edge{ From: fromNode, To: toNode, Duration: seg.Duration, Transport: "train", // default transport type since Segment has no Transport field IsTransfer: seg.HasTransfers, Kind: routing.EdgeKindReal, }) } } // splitPath splits a URL path into segments, removing leading/trailing slashes. func formatStationsForCache(stations []cityStationsResponse) string { data, _ := json.Marshal(stations) return string(data) } // 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 }