diff options
Diffstat (limited to 'src/Enmarcha.Backend/Controllers')
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 249 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/RoutePlannerController.cs | 102 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TileController.cs | 217 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TrafficDataController.cs | 110 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TransitController.cs | 127 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/VigoController.cs | 69 |
6 files changed, 874 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs new file mode 100644 index 0000000..7260fb4 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -0,0 +1,249 @@ +using System.Net; +using Enmarcha.Sources.OpenTripPlannerGql; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Services; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/stops")] +public partial class ArrivalsController : ControllerBase +{ + private readonly ILogger<ArrivalsController> _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + private readonly ArrivalsPipeline _pipeline; + private readonly FeedService _feedService; + private readonly AppConfiguration _config; + + public ArrivalsController( + ILogger<ArrivalsController> logger, + IMemoryCache cache, + HttpClient httpClient, + ArrivalsPipeline pipeline, + FeedService feedService, + IOptions<AppConfiguration> configOptions + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + _pipeline = pipeline; + _feedService = feedService; + _config = configOptions.Value; + } + + [HttpGet("arrivals")] + public async Task<IActionResult> GetArrivals( + [FromQuery] string id, + [FromQuery] bool reduced + ) + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + var todayLocal = nowLocal.Date; + + var requestContent = ArrivalsAtStopContent.Query( + new ArrivalsAtStopContent.Args(id, reduced) + ); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<ArrivalsAtStopResponse>>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null) + { + LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync()); + return StatusCode(500, "Error fetching stop data"); + } + + var stop = responseBody.Data.Stop; + _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id); + + List<Arrival> arrivals = []; + foreach (var item in stop.Arrivals) + { + // Discard trip without pickup at stop + if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) + { + continue; + } + + // Discard on last stop + if (item.Trip.ArrivalStoptime.Stop.GtfsId == id) + { + continue; + } + + if (item.Trip.Geometry?.Points != null) + { + _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId); + } + + // Calculate departure time using the service day in the feed's timezone (Europe/Madrid) + // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day + var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz); + var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds); + + var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes; + //var isRunning = departureTime < nowLocal; + + Arrival arrival = new() + { + TripId = item.Trip.GtfsId, + Route = new RouteInfo + { + GtfsId = item.Trip.Route.GtfsId, + ShortName = item.Trip.RouteShortName, + Colour = item.Trip.Route.Color ?? "FFFFFF", + TextColour = item.Trip.Route.TextColor ?? "000000" + }, + Headsign = new HeadsignInfo + { + Destination = item.Headsign + }, + Estimate = new ArrivalDetails + { + Minutes = minutesToArrive, + Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled + }, + RawOtpTrip = item + }; + + arrivals.Add(arrival); + } + + await _pipeline.ExecuteAsync(new ArrivalsContext + { + StopId = id, + StopCode = stop.Code, + IsReduced = reduced, + Arrivals = arrivals, + NowLocal = nowLocal, + StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon } + }); + + var feedId = id.Split(':')[0]; + + // Time after an arrival's time to still include it in the response. This is useful without real-time data, for delayed buses. + var timeThreshold = GetThresholdForFeed(id); + + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + + return Ok(new StopArrivalsResponse + { + StopCode = _feedService.NormalizeStopCode(feedId, stop.Code), + StopName = _feedService.NormalizeStopName(feedId, stop.Name), + StopLocation = new Position + { + Latitude = stop.Lat, + Longitude = stop.Lon + }, + Routes = [.. stop.Routes + .OrderBy( + r => r.ShortName, + Comparer<string?>.Create(SortingHelper.SortRouteShortNames) + ) + .Select(r => new RouteInfo + { + GtfsId = r.GtfsId, + ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + Colour = r.Color ?? fallbackColor, + TextColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor + })], + Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)] + }); + + } + + private static int GetThresholdForFeed(string id) + { + string feedId = id.Split(':', 2)[0]; + + if (feedId is "vitrasa" or "tranvias" or "tussa") + { + return 0; + } + + return -30; + } + + [HttpGet] + public async Task<IActionResult> GetStops([FromQuery] string ids) + { + if (string.IsNullOrWhiteSpace(ids)) + { + return BadRequest("Ids parameter is required"); + } + + var stopIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var requestContent = StopsInfoContent.Query(new StopsInfoContent.Args(stopIds)); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<StopsInfoResponse>>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stops == null) + { + return StatusCode(500, "Error fetching stops data"); + } + + var result = responseBody.Data.Stops.ToDictionary( + s => s.GtfsId, + s => + { + var feedId = s.GtfsId.Split(':', 2)[0]; + var (fallbackColor, _) = _feedService.GetFallbackColourForFeed(feedId); + + return new + { + id = s.GtfsId, + code = _feedService.NormalizeStopCode(feedId, s.Code ?? ""), + name = s.Name, + routes = s.Routes + .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames)) + .Select(r => new + { + shortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + colour = r.Color ?? fallbackColor, + textColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor + }) + .ToList() + }; + } + ); + + return Ok(result); + } + + [HttpGet("search")] + public IActionResult SearchStops([FromQuery] string q) + { + // Placeholder for future implementation with Postgres and fuzzy searching + return Ok(new List<object>()); + } + + [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] + partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody); +} diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs new file mode 100644 index 0000000..c7201b0 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs @@ -0,0 +1,102 @@ +using System.Net; +using Enmarcha.Sources.OpenTripPlannerGql; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Services; +using Enmarcha.Backend.Types.Planner; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/planner")] +public partial class RoutePlannerController : ControllerBase +{ + private readonly ILogger<RoutePlannerController> _logger; + private readonly OtpService _otpService; + private readonly IGeocodingService _geocodingService; + private readonly AppConfiguration _config; + private readonly HttpClient _httpClient; + + public RoutePlannerController( + ILogger<RoutePlannerController> logger, + OtpService otpService, + IGeocodingService geocodingService, + IOptions<AppConfiguration> config, + HttpClient httpClient + ) + { + _logger = logger; + _otpService = otpService; + _geocodingService = geocodingService; + _config = config.Value; + _httpClient = httpClient; + } + + [HttpGet("autocomplete")] + public async Task<ActionResult<List<PlannerSearchResult>>> Autocomplete([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest("Query cannot be empty"); + } + + var results = await _geocodingService.GetAutocompleteAsync(query); + return Ok(results); + } + + [HttpGet("reverse")] + public async Task<ActionResult<PlannerSearchResult>> Reverse([FromQuery] double lat, [FromQuery] double lon) + { + var result = await _geocodingService.GetReverseGeocodeAsync(lat, lon); + if (result == null) + { + return NotFound(); + } + return Ok(result); + } + + [HttpGet("plan")] + public async Task<ActionResult<RoutePlan>> Plan( + [FromQuery] double fromLat, + [FromQuery] double fromLon, + [FromQuery] double toLat, + [FromQuery] double toLon, + [FromQuery] DateTimeOffset? time, + [FromQuery] bool arriveBy = false) + { + try + { + var requestContent = PlanConnectionContent.Query( + new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time ?? DateTimeOffset.Now, arriveBy) + ); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<PlanConnectionResponse>>(); + + if (responseBody is not { IsSuccess: true }) + { + LogErrorFetchingRoutes(response.StatusCode, await response.Content.ReadAsStringAsync()); + return StatusCode(500, "An error occurred while planning the route."); + } + + var plan = _otpService.MapPlanResponse(responseBody.Data!); + return Ok(plan); + } + catch (Exception e) + { + _logger.LogError("Exception planning route: {e}", e); + return StatusCode(500, "An error occurred while planning the route."); + } + } + + [LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")] + partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody); +} diff --git a/src/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs new file mode 100644 index 0000000..3459997 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/TileController.cs @@ -0,0 +1,217 @@ +using NetTopologySuite.Features; +using NetTopologySuite.IO.VectorTiles; +using NetTopologySuite.IO.VectorTiles.Mapbox; + +using Microsoft.AspNetCore.Mvc; + +using Microsoft.Extensions.Caching.Memory; +using System.Text.Json; +using Enmarcha.Sources.OpenTripPlannerGql; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Services; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/tiles")] +public class TileController : ControllerBase +{ + private readonly ILogger<TileController> _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + private readonly FeedService _feedService; + private readonly AppConfiguration _config; + + public TileController( + ILogger<TileController> logger, + IMemoryCache cache, + HttpClient httpClient, + FeedService feedService, + IOptions<AppConfiguration> configOptions + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + _feedService = feedService; + _config = configOptions.Value; + } + + [HttpGet("stops/{z:int}/{x:int}/{y:int}")] + public async Task<IActionResult> Stops(int z, int x, int y) + { + if (z is < 9 or > 20) + { + return BadRequest("Zoom level out of range (9-20)"); + } + + var cacheHit = _cache.TryGetValue($"stops-tile-{z}-{x}-{y}", out byte[]? cachedTile); + if (cacheHit && cachedTile != null) + { + Response.Headers.Append("X-Cache-Hit", "true"); + return File(cachedTile, "application/x-protobuf"); + } + + // Calculate bounding box in EPSG:4326 + var n = Math.Pow(2, z); + var lonMin = x / n * 360.0 - 180.0; + var lonMax = (x + 1) / n * 360.0 - 180.0; + + var latMaxRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * y / n))); + var latMax = latMaxRad * 180.0 / Math.PI; + + var latMinRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * (y + 1) / n))); + var latMin = latMinRad * 180.0 / Math.PI; + + var requestContent = StopTileRequestContent.Query(new StopTileRequestContent.Bbox(lonMin, latMin, lonMax, latMax)); + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<StopTileResponse>>(); + + if (responseBody is not { IsSuccess: true }) + { + _logger.LogError( + "Error fetching stop data, received {StatusCode} {ResponseBody}", + response.StatusCode, + await response.Content.ReadAsStringAsync() + ); + return StatusCode(500, "Error fetching stop data"); + } + + var tileDef = new NetTopologySuite.IO.VectorTiles.Tiles.Tile(x, y, z); + VectorTile vt = new() { TileId = tileDef.Id }; + var stopsLayer = new Layer { Name = "stops" }; + + responseBody.Data?.StopsByBbox?.ForEach(stop => + { + var idParts = stop.GtfsId.Split(':', 2); + string feedId = idParts[0]; + string codeWithinFeed = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty); + + if (_feedService.IsStopHidden($"{feedId}:{codeWithinFeed}")) + { + return; + } + + // TODO: Duplicate from ArrivalsController + var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]); + var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []); + + Feature feature = new() + { + Geometry = new NetTopologySuite.Geometries.Point(stop.Lon, stop.Lat), + Attributes = new AttributesTable + { + // The ID will be used to request the arrivals + { "id", stop.GtfsId }, + // The feed is the first part of the GTFS ID + { "feed", idParts[0] }, + // The public identifier, usually feed:code or feed:id, recognisable by users and in other systems + { "code", $"{idParts[0]}:{codeWithinFeed}" }, + { "name", _feedService.NormalizeStopName(feedId, stop.Name) }, + { "icon", GetIconNameForFeed(feedId) }, + { "transitKind", GetTransitKind(feedId) }, + // Routes + { "routes", JsonSerializer + .Serialize( + distinctRoutes.Select(r => { + var colour = r.Color ?? Color; + string textColour; + + if (r.Color is null) // None is present, use fallback + { + textColour = TextColor; + } + else if (r.TextColor is null || r.TextColor.EndsWith("000000")) + { + // Text colour not provided, or default-black; check the better contrasting + textColour = ContrastHelper.GetBestTextColour(colour); + } + else + { + // Use provided text colour + textColour = r.TextColor; + } + + return new { + shortName = r.ShortName, + colour, + textColour + }; + })) + } + } + }; + + stopsLayer.Features.Add(feature); + }); + + vt.Layers.Add(stopsLayer); + + using var ms = new MemoryStream(); + vt.Write(ms, minLinealExtent: 1, minPolygonalExtent: 2); + + _cache.Set($"stops-tile-{z}-{x}-{y}", ms.ToArray(), TimeSpan.FromMinutes(15)); + Response.Headers.Append("X-Cache-Hit", "false"); + + return File(ms.ToArray(), "application/x-protobuf"); + } + + private string GetIconNameForFeed(string feedId) + { + return feedId switch + { + "vitrasa" => "stop-vitrasa", + "tussa" => "stop-tussa", + "tranvias" => "stop-tranvias", + "xunta" => "stop-xunta", + "renfe" => "stop-renfe", + "feve" => "stop-feve", + _ => "stop-generic", + }; + } + + private string GetTransitKind(string feedId) + { + return feedId switch + { + "vitrasa" or "tussa" or "tranvias" => "bus", + "xunta" => "coach", + "renfe" or "feve" => "train", + _ => "unknown", + }; + } + + private List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes) + { + List<StopTileResponse.Route> distinctRoutes = []; + HashSet<string> seen = new(); + + foreach (var route in routes) + { + var seenId = _feedService.GetUniqueRouteShortName(feedId, route.ShortName ?? string.Empty); + route.ShortName = seenId; + + if (seen.Contains(seenId)) + { + continue; + } + + seen.Add(seenId); + distinctRoutes.Add(route); + } + + return [.. distinctRoutes.OrderBy( + r => r.ShortName, + Comparer<string?>.Create(SortingHelper.SortRouteShortNames) + )]; + } +} diff --git a/src/Enmarcha.Backend/Controllers/TrafficDataController.cs b/src/Enmarcha.Backend/Controllers/TrafficDataController.cs new file mode 100644 index 0000000..6a95d49 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/TrafficDataController.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using NetTopologySuite.Features; +using NetTopologySuite.IO; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api")] +public class TrafficDataController : ControllerBase +{ + private readonly ILogger<TrafficDataController> _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public TrafficDataController( + ILogger<TrafficDataController> logger, + IMemoryCache cache, + HttpClient httpClient + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + } + + [HttpGet("traffic")] + public async Task<IActionResult> Get() + { + var trafficData = _cache.GetOrCreate("vigo-traffic-geojson", entry => + { + var data = GetTrafficData(); + + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + + return data.Result; + }); + + if (string.IsNullOrEmpty(trafficData)) + { + return StatusCode(404); + } + + return Content(trafficData, "application/json", Encoding.UTF8); + } + + private async Task<string> GetTrafficData() + { + var resp = await _httpClient.GetAsync("https://datos.vigo.org/data/trafico/treal.geojson"); + var body = resp.Content.ReadAsStringAsync().Result; + + var reader = new GeoJsonReader(); + var featureCollection = reader.Read<FeatureCollection>(body); + + var filteredFeatures = new FeatureCollection(); + foreach (var kvp in featureCollection) + { + var newAttributes = new AttributesTable(); + + if ( + !kvp.Attributes.Exists("actualizacion") || + !kvp.Attributes.Exists("style") + ) + { + continue; + } + + var updateParsed = DateTime.TryParseExact( + kvp.Attributes["actualizacion"].ToString(), + "dd/MM/yyyy H:mm:ss", + null, + DateTimeStyles.None, + out var updatedAt + ); + + if (!updateParsed || updatedAt < DateTime.Today) + { + continue; + } + + var style = kvp.Attributes["style"].ToString(); + + if (style == "#SINDATOS") + { + continue; + } + + var vehiculosAttribute = (kvp.Attributes["vehiculos"] ?? "0").ToString(); + + var vehiclesParsed = int.TryParse(vehiculosAttribute, out var vehicles); + if (!vehiclesParsed || vehicles <= 0) + { + continue; + } + + newAttributes.Add("updatedAt", updatedAt.ToString("O")); + newAttributes.Add("style", style); + newAttributes.Add("vehicles", vehicles); + + kvp.Attributes = newAttributes; + filteredFeatures.Add(kvp); + } + + var writer = new GeoJsonWriter(); + + return writer.Write(filteredFeatures); + } +} diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs new file mode 100644 index 0000000..62b3725 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/TransitController.cs @@ -0,0 +1,127 @@ +using Enmarcha.Sources.OpenTripPlannerGql; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Services; +using Enmarcha.Backend.Types.Transit; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/transit")] +public class TransitController : ControllerBase +{ + private readonly ILogger<TransitController> _logger; + private readonly OtpService _otpService; + private readonly AppConfiguration _config; + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + + public TransitController( + ILogger<TransitController> logger, + OtpService otpService, + IOptions<AppConfiguration> config, + HttpClient httpClient, + IMemoryCache cache + ) + { + _logger = logger; + _otpService = otpService; + _config = config.Value; + _httpClient = httpClient; + _cache = cache; + } + + [HttpGet("routes")] + public async Task<ActionResult<List<RouteDto>>> GetRoutes([FromQuery] string[] feeds) + { + if (feeds.Length == 0) + { + feeds = ["tussa", "vitrasa", "tranvias", "feve"]; + } + + var serviceDate = DateTime.Now.ToString("yyyy-MM-dd"); + var cacheKey = $"routes_{string.Join("_", feeds)}_{serviceDate}"; + if (_cache.TryGetValue(cacheKey, out List<RouteDto>? cachedRoutes)) + { + return Ok(cachedRoutes); + } + + try + { + var query = RoutesListContent.Query(new RoutesListContent.Args(feeds, serviceDate)); + var response = await SendOtpQueryAsync<RoutesListResponse>(query); + + if (response?.Data == null) + { + return StatusCode(500, "Failed to fetch routes from OTP."); + } + + var routes = response.Data.Routes + .Select(_otpService.MapRoute) + .Where(r => r.TripCount > 0) + .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames)) + .ToList(); + + _cache.Set(cacheKey, routes, TimeSpan.FromHours(1)); + + return Ok(routes); + } + catch (Exception e) + { + _logger.LogError(e, "Error fetching routes"); + return StatusCode(500, "An error occurred while fetching routes."); + } + } + + [HttpGet("routes/{id}")] + public async Task<ActionResult<RouteDetailsDto>> GetRouteDetails(string id) + { + var serviceDate = DateTime.Now.ToString("yyyy-MM-dd"); + var cacheKey = $"route_details_{id}_{serviceDate}"; + + if (_cache.TryGetValue(cacheKey, out RouteDetailsDto? cachedDetails)) + { + return Ok(cachedDetails); + } + + try + { + var query = RouteDetailsContent.Query(new RouteDetailsContent.Args(id, serviceDate)); + var response = await SendOtpQueryAsync<RouteDetailsResponse>(query); + + if (response?.Data?.Route == null) + { + return NotFound(); + } + + var details = _otpService.MapRouteDetails(response.Data.Route); + _cache.Set(cacheKey, details, TimeSpan.FromHours(1)); + + return Ok(details); + } + catch (Exception e) + { + _logger.LogError(e, "Error fetching route details for {Id}", id); + return StatusCode(500, "An error occurred while fetching route details."); + } + } + + private async Task<GraphClientResponse<T>?> SendOtpQueryAsync<T>(string query) where T : AbstractGraphResponse + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest { Query = query }); + + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError("OTP query failed with status {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync<GraphClientResponse<T>>(); + } +} diff --git a/src/Enmarcha.Backend/Controllers/VigoController.cs b/src/Enmarcha.Backend/Controllers/VigoController.cs new file mode 100644 index 0000000..4199251 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/VigoController.cs @@ -0,0 +1,69 @@ +using Costasdev.VigoTransitApi; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Services; +using Enmarcha.Backend.Services.Providers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/vigo")] +public partial class VigoController : ControllerBase +{ + private readonly ILogger<VigoController> _logger; + private readonly VigoTransitApiClient _api; + private readonly AppConfiguration _configuration; + private readonly ShapeTraversalService _shapeService; + private readonly VitrasaTransitProvider _vitrasaProvider; + private readonly RenfeTransitProvider _renfeProvider; + + public VigoController( + HttpClient http, + IOptions<AppConfiguration> options, + ILogger<VigoController> logger, + ShapeTraversalService shapeService, + VitrasaTransitProvider vitrasaProvider, + RenfeTransitProvider renfeProvider) + { + _logger = logger; + _api = new VigoTransitApiClient(http); + _configuration = options.Value; + _shapeService = shapeService; + _vitrasaProvider = vitrasaProvider; + _renfeProvider = renfeProvider; + } + + [HttpGet("GetConsolidatedCirculations")] + public async Task<IActionResult> GetConsolidatedCirculations( + [FromQuery] string stopId + ) + { + // Use Europe/Madrid timezone consistently to avoid UTC/local skew + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + + ITransitProvider provider; + string effectiveStopId; + + if (stopId.StartsWith("renfe:")) + { + provider = _renfeProvider; + effectiveStopId = stopId.Substring("renfe:".Length); + } + else if (stopId.StartsWith("vitrasa:")) + { + provider = _vitrasaProvider; + effectiveStopId = stopId.Substring("vitrasa:".Length); + } + else + { + // Legacy/Default + provider = _vitrasaProvider; + effectiveStopId = stopId; + } + + var result = await provider.GetCirculationsAsync(effectiveStopId, nowLocal); + return Ok(result); + } +} |
