From d85b242c8914d5697c87f86e5aad32bde90f2a81 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:15:22 +0000 Subject: [PATCH 1/2] Strip incidents logic from the API --- .../Controllers/CollectorsController.cs | 660 ++++++++---------- .../Controllers/HealthIncidentsController.cs | 48 -- BinDays.Api/Incidents/IIncidentStore.cs | 22 - .../Incidents/InMemoryIncidentStore.cs | 35 - BinDays.Api/Incidents/IncidentCategory.cs | 26 - BinDays.Api/Incidents/IncidentOperation.cs | 26 - BinDays.Api/Incidents/IncidentRecord.cs | 48 -- BinDays.Api/Incidents/RedisIncidentStore.cs | 80 --- BinDays.Api/Program.cs | 135 ++-- 9 files changed, 360 insertions(+), 720 deletions(-) delete mode 100644 BinDays.Api/Controllers/HealthIncidentsController.cs delete mode 100644 BinDays.Api/Incidents/IIncidentStore.cs delete mode 100644 BinDays.Api/Incidents/InMemoryIncidentStore.cs delete mode 100644 BinDays.Api/Incidents/IncidentCategory.cs delete mode 100644 BinDays.Api/Incidents/IncidentOperation.cs delete mode 100644 BinDays.Api/Incidents/IncidentRecord.cs delete mode 100644 BinDays.Api/Incidents/RedisIncidentStore.cs diff --git a/BinDays.Api/Controllers/CollectorsController.cs b/BinDays.Api/Controllers/CollectorsController.cs index 76531bc6..a0fc8271 100644 --- a/BinDays.Api/Controllers/CollectorsController.cs +++ b/BinDays.Api/Controllers/CollectorsController.cs @@ -1,366 +1,294 @@ -namespace BinDays.Api.Controllers -{ - using BinDays.Api.Collectors.Exceptions; - using BinDays.Api.Collectors.Models; - using BinDays.Api.Collectors.Services; - using BinDays.Api.Collectors.Utilities; - using BinDays.Api.Incidents; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Caching.Distributed; - using Microsoft.Extensions.Logging; - using Newtonsoft.Json; - using System; - using System.Linq; - using System.Net.Http; - using System.Security.Cryptography; - using System.Text; - - /// - /// API controller for managing collectors. - /// - [ApiController] - public class CollectorsController : ControllerBase - { - /// - /// Service for returning specific or all collectors. - /// - private readonly CollectorService _collectorService; - - /// - /// Logger for the controller. - /// - private readonly ILogger _logger; - - /// - /// Store for recording failed incidents. - /// - private readonly IIncidentStore _incidentStore; - - /// - /// Distributed cache for storing responses. - /// - private readonly IDistributedCache _cache; - - private readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - TypeNameHandling = TypeNameHandling.Auto, - }; - - /// - /// Initializes a new instance of the class. - /// - /// Service for retrieving collector information. - /// Logger for the controller. - /// Distributed cache for storing responses. - /// Store for recording failed incidents. - public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache, IIncidentStore incidentStore) - { - _collectorService = collectorService; - _logger = logger; - _cache = cache; - _incidentStore = incidentStore; - } - - /// - /// Formats a postcode string for use in a cache key by converting it to uppercase and removing spaces. - /// - /// The postcode string to format. - /// The formatted postcode string for cache key usage. - private static string FormatPostcodeForCacheKey(string postcode) - { - return postcode.ToUpperInvariant().Replace(" ", string.Empty); - } - - /// - /// Attempts to retrieve and deserialize an object from the cache. - /// Handles deserialization errors by evicting the bad cache entry. - /// - /// The type to deserialize into. - /// The cache key. - /// The deserialized object or null if not found or invalid. - private T? TryGetFromCache(string cacheKey) where T : class - { - if (_cache.GetString(cacheKey) is string cachedResult) - { - try - { - return JsonConvert.DeserializeObject(cachedResult, _jsonSerializerSettings); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to deserialize cached data for key '{CacheKey}'. Evicting invalid cache entry.", cacheKey); - _cache.Remove(cacheKey); - } - } - - return null; - } - - /// - /// Gets all the collectors. - /// - /// An enumerable collection of collectors or an error response. - [HttpGet] - [Route("/collectors")] - public IActionResult GetCollectors() - { - try - { - var result = _collectorService.GetCollectors(); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving all collectors."); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching collectors. Please try again later."); - } - } - - /// - /// Gets the collector for a given postcode, potentially requiring multiple steps via client-side responses. - /// - /// The postcode to search for. - /// The response from a previous client-side request, if applicable. - /// The response containing either the next client-side request to make or the collector, or an error response. - [HttpPost] - [Route("/collector")] - public IActionResult GetCollector(string postcode, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"collector-{FormatPostcodeForCacheKey(postcode)}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning cached collector {CollectorName} for postcode: {Postcode}.", cachedResponse.Collector!.Name, postcode); - return Ok(cachedResponse); - } - - try - { - var result = _collectorService.GetCollector(postcode, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved collector {CollectorName} for postcode: {Postcode}.", result.Collector!.Name, postcode); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(90) }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (InvalidPostcodeException ex) - { - _logger.LogWarning(ex, "Invalid postcode provided: {Postcode}.", postcode); - return BadRequest("The supplied postcode is invalid."); - } - catch (UnsupportedCollectorException ex) - { - _logger.LogWarning(ex, "Unsupported collector {CollectorName} for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.CollectorName, ex.GovUkId, postcode); - return NotFound($"{ex.CollectorName} is not currently supported."); - } - catch (GovUkIdNotFoundException ex) - { - _logger.LogWarning(ex, "No gov.uk ID found for postcode: {Postcode}.", postcode); - return NotFound("No collector found for the specified postcode."); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.GovUkId, postcode); - return NotFound("No supported collector found for the specified postcode."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving collector for postcode: {Postcode}.", postcode); - RecordIncident(null, IncidentOperation.GetCollector, ex); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching the collector for the specified postcode. Please try again later."); - } - } - - /// - /// Gets addresses for a given gov.uk ID and postcode. - /// - /// The gov.uk identifier for the collector. - /// The postcode to search addresses for. - /// The response from a previous client-side request, if applicable. - /// A response containing addresses, or an error response. - [HttpPost] - [Route("/{govUkId}/addresses")] - public IActionResult GetAddresses(string govUkId, string postcode, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"addresses-{govUkId}-{FormatPostcodeForCacheKey(postcode)}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning {AddressCount} cached addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", cachedResponse.Addresses!.Count, govUkId, postcode); - return Ok(cachedResponse); - } - - try - { - var result = _collectorService.GetAddresses(govUkId, postcode, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved {AddressCount} addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", result.Addresses!.Count, govUkId, postcode); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(30) }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); - return NotFound("No supported collector found for the specified gov.uk ID."); - } - catch (AddressesNotFoundException ex) - { - _logger.LogWarning(ex, "No addresses found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); - return NotFound("No addresses found for the specified postcode."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); - RecordIncident(govUkId, IncidentOperation.GetAddresses, ex); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching addresses. Please try again later."); - } - } - - /// - /// Gets bin days for a given gov.uk ID, postcode, and unique address identifier. - /// - /// The gov.uk identifier for the collector. - /// The postcode of the address. - /// The unique identifier of the address. - /// The response from a previous client-side request, if applicable. - /// A response containing bin days, or an error response. - [HttpPost] - [Route("/{govUkId}/bin-days")] - public IActionResult GetBinDays(string govUkId, string postcode, string uid, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"bin-days-{govUkId}-{FormatPostcodeForCacheKey(postcode)}-{uid}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning {BinDayCount} cached bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", cachedResponse.BinDays!.Count, govUkId, postcode, uid); - return Ok(cachedResponse); - } - - try - { - var address = new Address - { - Postcode = postcode, - Uid = uid - }; - - var result = _collectorService.GetBinDays(govUkId, address, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved {BinDayCount} bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", result.BinDays!.Count, govUkId, postcode, uid); - - // Cache until the day after the earliest bin day, or for 1 day if no bin days are returned. - var earliestBinDayDate = result.BinDays?.OrderBy(binDay => binDay.Date).FirstOrDefault()?.Date.ToDateTime(TimeOnly.MinValue); - var cacheExpiration = (earliestBinDayDate ?? DateTimeOffset.UtcNow.Date).AddDays(1); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = cacheExpiration }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); - return NotFound("No supported collector found for the specified gov.uk ID."); - } - catch (BinDaysNotFoundException ex) - { - _logger.LogWarning(ex, "No bin days found for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); - return NotFound("No bin days found for the specified address."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); - RecordIncident(govUkId, IncidentOperation.GetBinDays, ex); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching bin days. Please try again later."); - } - } - - /// - /// Records an unexpected incident for diagnostics. - /// - /// The collector identifier, if known. - /// The operation that was being performed. - /// The triggering exception. - private void RecordIncident(string? govUkId, IncidentOperation operation, Exception exception) - { - var normalisedGovUkId = string.IsNullOrWhiteSpace(govUkId) ? string.Empty : govUkId.Trim().ToLowerInvariant(); - - var incident = new IncidentRecord - { - IncidentId = Guid.NewGuid(), - GovUkId = normalisedGovUkId, - OccurredUtc = DateTime.UtcNow, - Category = Classify(exception), - Operation = operation, - MessageHash = ComputeHash(exception), - ExceptionType = exception.GetType().FullName ?? exception.GetType().Name, - }; - - try - { - _incidentStore.RecordIncident(incident); - } - catch (Exception storeException) - { - _logger.LogError(storeException, "Failed to record incident for collector {GovUkId}.", normalisedGovUkId); - } - } - - /// - /// Classifies an exception into an incident category. - /// - /// The exception to classify. - /// The incident category. - private static IncidentCategory Classify(Exception exception) - { - return exception switch - { - HttpRequestException or TimeoutException or TaskCanceledException => IncidentCategory.CollectorFailure, - System.Text.Json.JsonException or FormatException or InvalidOperationException => IncidentCategory.IntegrationChanged, - _ => IncidentCategory.SystemFailure, - }; - } - - /// - /// Computes a deterministic hash for an exception. - /// - /// The exception to hash. - /// A hexadecimal hash string. - private static string ComputeHash(Exception exception) - { - var payload = Encoding.UTF8.GetBytes( - $"{exception.GetType().FullName}|{exception.TargetSite?.ToString() ?? "UnknownTarget"}|{exception.Message}" - ); - var hash = SHA256.HashData(payload); - - return Convert.ToHexString(hash); - } - } -} +namespace BinDays.Api.Controllers +{ + using BinDays.Api.Collectors.Exceptions; + using BinDays.Api.Collectors.Models; + using BinDays.Api.Collectors.Services; + using BinDays.Api.Collectors.Utilities; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Caching.Distributed; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using System; + using System.Linq; + using System.Net.Http; + using System.Security.Cryptography; + using System.Text; + + /// + /// API controller for managing collectors. + /// + [ApiController] + public class CollectorsController : ControllerBase + { + /// + /// Service for returning specific or all collectors. + /// + private readonly CollectorService _collectorService; + + /// + /// Logger for the controller. + /// + private readonly ILogger _logger; + + /// + /// Distributed cache for storing responses. + /// + private readonly IDistributedCache _cache; + + private readonly JsonSerializerSettings _jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + }; + + /// + /// Initializes a new instance of the class. + /// + /// Service for retrieving collector information. + /// Logger for the controller. + /// Distributed cache for storing responses. + public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache) + { + _collectorService = collectorService; + _logger = logger; + _cache = cache; + } + + /// + /// Formats a postcode string for use in a cache key by converting it to uppercase and removing spaces. + /// + /// The postcode string to format. + /// The formatted postcode string for cache key usage. + private static string FormatPostcodeForCacheKey(string postcode) + { + return postcode.ToUpperInvariant().Replace(" ", string.Empty); + } + + /// + /// Attempts to retrieve and deserialize an object from the cache. + /// Handles deserialization errors by evicting the bad cache entry. + /// + /// The type to deserialize into. + /// The cache key. + /// The deserialized object or null if not found or invalid. + private T? TryGetFromCache(string cacheKey) where T : class + { + if (_cache.GetString(cacheKey) is string cachedResult) + { + try + { + return JsonConvert.DeserializeObject(cachedResult, _jsonSerializerSettings); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached data for key '{CacheKey}'. Evicting invalid cache entry.", cacheKey); + _cache.Remove(cacheKey); + } + } + + return null; + } + + /// + /// Gets all the collectors. + /// + /// An enumerable collection of collectors or an error response. + [HttpGet] + [Route("/collectors")] + public IActionResult GetCollectors() + { + try + { + var result = _collectorService.GetCollectors(); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving all collectors."); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching collectors. Please try again later."); + } + } + + /// + /// Gets the collector for a given postcode, potentially requiring multiple steps via client-side responses. + /// + /// The postcode to search for. + /// The response from a previous client-side request, if applicable. + /// The response containing either the next client-side request to make or the collector, or an error response. + [HttpPost] + [Route("/collector")] + public IActionResult GetCollector(string postcode, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"collector-{FormatPostcodeForCacheKey(postcode)}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning cached collector {CollectorName} for postcode: {Postcode}.", cachedResponse.Collector!.Name, postcode); + return Ok(cachedResponse); + } + + try + { + var result = _collectorService.GetCollector(postcode, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved collector {CollectorName} for postcode: {Postcode}.", result.Collector!.Name, postcode); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(90) }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (InvalidPostcodeException ex) + { + _logger.LogWarning(ex, "Invalid postcode provided: {Postcode}.", postcode); + return BadRequest("The supplied postcode is invalid."); + } + catch (UnsupportedCollectorException ex) + { + _logger.LogWarning(ex, "Unsupported collector {CollectorName} for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.CollectorName, ex.GovUkId, postcode); + return NotFound($"{ex.CollectorName} is not currently supported."); + } + catch (GovUkIdNotFoundException ex) + { + _logger.LogWarning(ex, "No gov.uk ID found for postcode: {Postcode}.", postcode); + return NotFound("No collector found for the specified postcode."); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.GovUkId, postcode); + return NotFound("No supported collector found for the specified postcode."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving collector for postcode: {Postcode}.", postcode); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching the collector for the specified postcode. Please try again later."); + } + } + + /// + /// Gets addresses for a given gov.uk ID and postcode. + /// + /// The gov.uk identifier for the collector. + /// The postcode to search addresses for. + /// The response from a previous client-side request, if applicable. + /// A response containing addresses, or an error response. + [HttpPost] + [Route("/{govUkId}/addresses")] + public IActionResult GetAddresses(string govUkId, string postcode, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"addresses-{govUkId}-{FormatPostcodeForCacheKey(postcode)}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning {AddressCount} cached addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", cachedResponse.Addresses!.Count, govUkId, postcode); + return Ok(cachedResponse); + } + + try + { + var result = _collectorService.GetAddresses(govUkId, postcode, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved {AddressCount} addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", result.Addresses!.Count, govUkId, postcode); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(30) }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); + return NotFound("No supported collector found for the specified gov.uk ID."); + } + catch (AddressesNotFoundException ex) + { + _logger.LogWarning(ex, "No addresses found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); + return NotFound("No addresses found for the specified postcode."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching addresses. Please try again later."); + } + } + + /// + /// Gets bin days for a given gov.uk ID, postcode, and unique address identifier. + /// + /// The gov.uk identifier for the collector. + /// The postcode of the address. + /// The unique identifier of the address. + /// The response from a previous client-side request, if applicable. + /// A response containing bin days, or an error response. + [HttpPost] + [Route("/{govUkId}/bin-days")] + public IActionResult GetBinDays(string govUkId, string postcode, string uid, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"bin-days-{govUkId}-{FormatPostcodeForCacheKey(postcode)}-{uid}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning {BinDayCount} cached bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", cachedResponse.BinDays!.Count, govUkId, postcode, uid); + return Ok(cachedResponse); + } + + try + { + var address = new Address + { + Postcode = postcode, + Uid = uid + }; + + var result = _collectorService.GetBinDays(govUkId, address, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved {BinDayCount} bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", result.BinDays!.Count, govUkId, postcode, uid); + + // Cache until the day after the earliest bin day, or for 1 day if no bin days are returned. + var earliestBinDayDate = result.BinDays?.OrderBy(binDay => binDay.Date).FirstOrDefault()?.Date.ToDateTime(TimeOnly.MinValue); + var cacheExpiration = (earliestBinDayDate ?? DateTimeOffset.UtcNow.Date).AddDays(1); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = cacheExpiration }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); + return NotFound("No supported collector found for the specified gov.uk ID."); + } + catch (BinDaysNotFoundException ex) + { + _logger.LogWarning(ex, "No bin days found for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); + return NotFound("No bin days found for the specified address."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching bin days. Please try again later."); + } + } + } +} diff --git a/BinDays.Api/Controllers/HealthIncidentsController.cs b/BinDays.Api/Controllers/HealthIncidentsController.cs deleted file mode 100644 index 1cfe5c44..00000000 --- a/BinDays.Api/Controllers/HealthIncidentsController.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace BinDays.Api.Controllers -{ - using BinDays.Api.Incidents; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Logging; - using System; - using System.Collections.Generic; - - /// - /// Provides health-focused incident feeds. - /// - [ApiController] - [Route("health")] - public sealed class HealthIncidentsController : ControllerBase - { - private readonly IIncidentStore _incidentStore; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The incident store. - /// Logger instance. - public HealthIncidentsController(IIncidentStore incidentStore, ILogger logger) - { - _incidentStore = incidentStore; - _logger = logger; - } - - /// - /// Gets all recorded incidents ordered from newest to oldest. - /// - /// The incidents. - [HttpGet("incidents")] - public ActionResult> GetIncidents() - { - try - { - return Ok(_incidentStore.GetIncidents()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve incidents."); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching incidents. Please try again later."); - } - } - } -} diff --git a/BinDays.Api/Incidents/IIncidentStore.cs b/BinDays.Api/Incidents/IIncidentStore.cs deleted file mode 100644 index 55355236..00000000 --- a/BinDays.Api/Incidents/IIncidentStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Collections.Generic; - - /// - /// Persists incident records for health monitoring. - /// - public interface IIncidentStore - { - /// - /// Records a new incident. - /// - /// The incident to store. - void RecordIncident(IncidentRecord incident); - - /// - /// Retrieves all recorded incidents ordered newest-first. - /// - /// The incidents ordered from newest to oldest. - IReadOnlyList GetIncidents(); - } -} diff --git a/BinDays.Api/Incidents/InMemoryIncidentStore.cs b/BinDays.Api/Incidents/InMemoryIncidentStore.cs deleted file mode 100644 index fd4fe0da..00000000 --- a/BinDays.Api/Incidents/InMemoryIncidentStore.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System; - using System.Collections.Generic; - using System.Linq; - - /// - /// In-memory fallback for development environments where Redis is not configured. - /// - internal sealed class InMemoryIncidentStore : IIncidentStore - { - private readonly object _lock = new(); - private readonly List _records = []; - - /// - public void RecordIncident(IncidentRecord incident) - { - ArgumentNullException.ThrowIfNull(incident); - - lock (_lock) - { - _records.Add(incident); - } - } - - /// - public IReadOnlyList GetIncidents() - { - lock (_lock) - { - return [.. _records.OrderByDescending(record => record.OccurredUtc)]; - } - } - } -} diff --git a/BinDays.Api/Incidents/IncidentCategory.cs b/BinDays.Api/Incidents/IncidentCategory.cs deleted file mode 100644 index b52c8435..00000000 --- a/BinDays.Api/Incidents/IncidentCategory.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Text.Json.Serialization; - - /// - /// High-level category assigned to incidents recorded for collectors. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum IncidentCategory - { - /// - /// An unexpected failure occurred while calling a council/collector upstream. - /// - CollectorFailure, - - /// - /// The platform encountered an infrastructure or configuration problem. - /// - SystemFailure, - - /// - /// The collector API contract appears to have changed unexpectedly. - /// - IntegrationChanged, - } -} diff --git a/BinDays.Api/Incidents/IncidentOperation.cs b/BinDays.Api/Incidents/IncidentOperation.cs deleted file mode 100644 index 4d55aba8..00000000 --- a/BinDays.Api/Incidents/IncidentOperation.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Text.Json.Serialization; - - /// - /// Indicates which collector operation was being performed when an incident occurred. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum IncidentOperation - { - /// - /// Incident occurred during the collector lookup flow. - /// - GetCollector, - - /// - /// Incident occurred while retrieving addresses. - /// - GetAddresses, - - /// - /// Incident occurred while retrieving bin days. - /// - GetBinDays, - } -} diff --git a/BinDays.Api/Incidents/IncidentRecord.cs b/BinDays.Api/Incidents/IncidentRecord.cs deleted file mode 100644 index 171e9526..00000000 --- a/BinDays.Api/Incidents/IncidentRecord.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System; - using System.Text.Json.Serialization; - - /// - /// Value object persisted for each incident. - /// - public sealed class IncidentRecord - { - /// - /// Gets or sets the incident identifier. - /// - public Guid IncidentId { get; set; } - - /// - /// Gets or sets the collector gov.uk identifier associated with this incident. - /// - public string GovUkId { get; set; } = string.Empty; - - /// - /// Gets or sets the timestamp (UTC) when the incident occurred. - /// - public DateTime OccurredUtc { get; set; } - - /// - /// Gets or sets the incident category. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public IncidentCategory Category { get; set; } - - /// - /// Gets or sets the operation that was being executed. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public IncidentOperation Operation { get; set; } - - /// - /// Gets or sets the hashed signature of the error message/stack trace. - /// - public string MessageHash { get; set; } = string.Empty; - - /// - /// Gets or sets the name of the exception type that triggered the incident. - /// - public string ExceptionType { get; set; } = string.Empty; - } -} diff --git a/BinDays.Api/Incidents/RedisIncidentStore.cs b/BinDays.Api/Incidents/RedisIncidentStore.cs deleted file mode 100644 index 78a4969d..00000000 --- a/BinDays.Api/Incidents/RedisIncidentStore.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using StackExchange.Redis; - using System; - using System.Collections.Generic; - using System.Text.Json; - using System.Text.Json.Serialization; - - /// - /// Redis-backed implementation of . - /// - internal sealed class RedisIncidentStore : IIncidentStore - { - private const string IndexKey = "health:incidents"; - private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(90); - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() }, - }; - - private readonly IConnectionMultiplexer _connectionMultiplexer; - - /// - /// Initializes a new instance of the class. - /// - /// The Redis connection. - public RedisIncidentStore(IConnectionMultiplexer multiplexer) - { - _connectionMultiplexer = multiplexer; - } - - /// - public void RecordIncident(IncidentRecord incident) - { - ArgumentNullException.ThrowIfNull(incident); - - var db = _connectionMultiplexer.GetDatabase(); - var payload = JsonSerializer.Serialize(incident, SerializerOptions); - - var cutoffScore = ToScore(DateTime.UtcNow - RetentionWindow); - - var transaction = db.CreateTransaction(); - _ = transaction.SortedSetRemoveRangeByScoreAsync(IndexKey, double.NegativeInfinity, cutoffScore); - _ = transaction.SortedSetAddAsync(IndexKey, payload, ToScore(incident.OccurredUtc)); - transaction.Execute(); - } - - /// - public IReadOnlyList GetIncidents() - { - var db = _connectionMultiplexer.GetDatabase(); - var entries = db.SortedSetRangeByScore(IndexKey, order: Order.Descending); - - if (entries.Length == 0) - { - return []; - } - - var incidents = entries - .Where(e => e.HasValue) - .Select(e => JsonSerializer.Deserialize(e!, SerializerOptions)) - .OfType() - .ToList(); - - return incidents; - } - - /// - /// Converts a UTC timestamp to a sorted-set score. - /// - /// The UTC timestamp. - /// The numeric score. - private static double ToScore(DateTime utcDateTime) - { - var utc = DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc); - return new DateTimeOffset(utc).ToUnixTimeMilliseconds(); - } - } -} diff --git a/BinDays.Api/Program.cs b/BinDays.Api/Program.cs index 77089eb0..ab7f4dc7 100644 --- a/BinDays.Api/Program.cs +++ b/BinDays.Api/Program.cs @@ -1,69 +1,66 @@ -using BinDays.Api.Incidents; -using StackExchange.Redis; - -var builder = WebApplication.CreateBuilder(args); - -// Use Autofac as the service provider factory -builder.Host.UseServiceProviderFactory(new Autofac.Extensions.DependencyInjection.AutofacServiceProviderFactory()); - -// Register services directly with Autofac using the ConfigureContainer method -builder.Host.ConfigureContainer(BinDays.Api.Initialisation.DependencyInjection.ConfigureContainer); - -builder.Services.AddControllers(); - -// Add caching for responses and incidents, either in-memory or Redis -var redis = builder.Configuration.GetValue("Redis"); -if (!string.IsNullOrEmpty(redis)) -{ - var multiplexer = ConnectionMultiplexer.Connect(redis); - builder.Services.AddSingleton(multiplexer); - builder.Services.AddSingleton(); - - builder.Services.AddStackExchangeRedisCache(options => - { - options.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); - }); -} -else -{ - builder.Services.AddDistributedMemoryCache(); - builder.Services.AddSingleton(); -} - -// Health check for monitoring -builder.Services.AddHealthChecks(); - -// Configure Seq logging (optional) -builder.Services.AddLogging(loggingBuilder => -{ - loggingBuilder.AddSeq(builder.Configuration.GetSection("Seq")); -}); - -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseCors(x => x - .AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader() -); - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.MapHealthChecks("/status"); - -app.Run(); +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +// Use Autofac as the service provider factory +builder.Host.UseServiceProviderFactory(new Autofac.Extensions.DependencyInjection.AutofacServiceProviderFactory()); + +// Register services directly with Autofac using the ConfigureContainer method +builder.Host.ConfigureContainer(BinDays.Api.Initialisation.DependencyInjection.ConfigureContainer); + +builder.Services.AddControllers(); + +// Add caching for responses, either in-memory or Redis +var redis = builder.Configuration.GetValue("Redis"); +if (!string.IsNullOrEmpty(redis)) +{ + var multiplexer = ConnectionMultiplexer.Connect(redis); + builder.Services.AddSingleton(multiplexer); + + builder.Services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); + }); +} +else +{ + builder.Services.AddDistributedMemoryCache(); +} + +// Health check for monitoring +builder.Services.AddHealthChecks(); + +// Configure Seq logging (optional) +builder.Services.AddLogging(loggingBuilder => +{ + loggingBuilder.AddSeq(builder.Configuration.GetSection("Seq")); +}); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors(x => x + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() +); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHealthChecks("/status"); + +app.Run(); From bbbe116dbef3eebe1f325896b637221f879c3d92 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 21 Dec 2025 00:16:03 +0000 Subject: [PATCH 2/2] Auto-format code with dotnet format --- .../Controllers/CollectorsController.cs | 588 +++++++++--------- BinDays.Api/Program.cs | 132 ++-- 2 files changed, 360 insertions(+), 360 deletions(-) diff --git a/BinDays.Api/Controllers/CollectorsController.cs b/BinDays.Api/Controllers/CollectorsController.cs index a0fc8271..14e1f78e 100644 --- a/BinDays.Api/Controllers/CollectorsController.cs +++ b/BinDays.Api/Controllers/CollectorsController.cs @@ -1,294 +1,294 @@ -namespace BinDays.Api.Controllers -{ - using BinDays.Api.Collectors.Exceptions; - using BinDays.Api.Collectors.Models; - using BinDays.Api.Collectors.Services; - using BinDays.Api.Collectors.Utilities; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Caching.Distributed; - using Microsoft.Extensions.Logging; - using Newtonsoft.Json; - using System; - using System.Linq; - using System.Net.Http; - using System.Security.Cryptography; - using System.Text; - - /// - /// API controller for managing collectors. - /// - [ApiController] - public class CollectorsController : ControllerBase - { - /// - /// Service for returning specific or all collectors. - /// - private readonly CollectorService _collectorService; - - /// - /// Logger for the controller. - /// - private readonly ILogger _logger; - - /// - /// Distributed cache for storing responses. - /// - private readonly IDistributedCache _cache; - - private readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - TypeNameHandling = TypeNameHandling.Auto, - }; - - /// - /// Initializes a new instance of the class. - /// - /// Service for retrieving collector information. - /// Logger for the controller. - /// Distributed cache for storing responses. - public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache) - { - _collectorService = collectorService; - _logger = logger; - _cache = cache; - } - - /// - /// Formats a postcode string for use in a cache key by converting it to uppercase and removing spaces. - /// - /// The postcode string to format. - /// The formatted postcode string for cache key usage. - private static string FormatPostcodeForCacheKey(string postcode) - { - return postcode.ToUpperInvariant().Replace(" ", string.Empty); - } - - /// - /// Attempts to retrieve and deserialize an object from the cache. - /// Handles deserialization errors by evicting the bad cache entry. - /// - /// The type to deserialize into. - /// The cache key. - /// The deserialized object or null if not found or invalid. - private T? TryGetFromCache(string cacheKey) where T : class - { - if (_cache.GetString(cacheKey) is string cachedResult) - { - try - { - return JsonConvert.DeserializeObject(cachedResult, _jsonSerializerSettings); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to deserialize cached data for key '{CacheKey}'. Evicting invalid cache entry.", cacheKey); - _cache.Remove(cacheKey); - } - } - - return null; - } - - /// - /// Gets all the collectors. - /// - /// An enumerable collection of collectors or an error response. - [HttpGet] - [Route("/collectors")] - public IActionResult GetCollectors() - { - try - { - var result = _collectorService.GetCollectors(); - return Ok(result); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving all collectors."); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching collectors. Please try again later."); - } - } - - /// - /// Gets the collector for a given postcode, potentially requiring multiple steps via client-side responses. - /// - /// The postcode to search for. - /// The response from a previous client-side request, if applicable. - /// The response containing either the next client-side request to make or the collector, or an error response. - [HttpPost] - [Route("/collector")] - public IActionResult GetCollector(string postcode, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"collector-{FormatPostcodeForCacheKey(postcode)}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning cached collector {CollectorName} for postcode: {Postcode}.", cachedResponse.Collector!.Name, postcode); - return Ok(cachedResponse); - } - - try - { - var result = _collectorService.GetCollector(postcode, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved collector {CollectorName} for postcode: {Postcode}.", result.Collector!.Name, postcode); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(90) }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (InvalidPostcodeException ex) - { - _logger.LogWarning(ex, "Invalid postcode provided: {Postcode}.", postcode); - return BadRequest("The supplied postcode is invalid."); - } - catch (UnsupportedCollectorException ex) - { - _logger.LogWarning(ex, "Unsupported collector {CollectorName} for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.CollectorName, ex.GovUkId, postcode); - return NotFound($"{ex.CollectorName} is not currently supported."); - } - catch (GovUkIdNotFoundException ex) - { - _logger.LogWarning(ex, "No gov.uk ID found for postcode: {Postcode}.", postcode); - return NotFound("No collector found for the specified postcode."); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.GovUkId, postcode); - return NotFound("No supported collector found for the specified postcode."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving collector for postcode: {Postcode}.", postcode); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching the collector for the specified postcode. Please try again later."); - } - } - - /// - /// Gets addresses for a given gov.uk ID and postcode. - /// - /// The gov.uk identifier for the collector. - /// The postcode to search addresses for. - /// The response from a previous client-side request, if applicable. - /// A response containing addresses, or an error response. - [HttpPost] - [Route("/{govUkId}/addresses")] - public IActionResult GetAddresses(string govUkId, string postcode, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"addresses-{govUkId}-{FormatPostcodeForCacheKey(postcode)}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning {AddressCount} cached addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", cachedResponse.Addresses!.Count, govUkId, postcode); - return Ok(cachedResponse); - } - - try - { - var result = _collectorService.GetAddresses(govUkId, postcode, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved {AddressCount} addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", result.Addresses!.Count, govUkId, postcode); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(30) }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); - return NotFound("No supported collector found for the specified gov.uk ID."); - } - catch (AddressesNotFoundException ex) - { - _logger.LogWarning(ex, "No addresses found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); - return NotFound("No addresses found for the specified postcode."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching addresses. Please try again later."); - } - } - - /// - /// Gets bin days for a given gov.uk ID, postcode, and unique address identifier. - /// - /// The gov.uk identifier for the collector. - /// The postcode of the address. - /// The unique identifier of the address. - /// The response from a previous client-side request, if applicable. - /// A response containing bin days, or an error response. - [HttpPost] - [Route("/{govUkId}/bin-days")] - public IActionResult GetBinDays(string govUkId, string postcode, string uid, [FromBody] ClientSideResponse? clientSideResponse) - { - postcode = ProcessingUtilities.FormatPostcode(postcode); - - var cacheKey = $"bin-days-{govUkId}-{FormatPostcodeForCacheKey(postcode)}-{uid}"; - var cachedResponse = TryGetFromCache(cacheKey); - - if (cachedResponse != null) - { - _logger.LogInformation("Returning {BinDayCount} cached bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", cachedResponse.BinDays!.Count, govUkId, postcode, uid); - return Ok(cachedResponse); - } - - try - { - var address = new Address - { - Postcode = postcode, - Uid = uid - }; - - var result = _collectorService.GetBinDays(govUkId, address, clientSideResponse); - - // Cache result if successful and no next client-side request - if (result.NextClientSideRequest == null) - { - _logger.LogInformation("Successfully retrieved {BinDayCount} bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", result.BinDays!.Count, govUkId, postcode, uid); - - // Cache until the day after the earliest bin day, or for 1 day if no bin days are returned. - var earliestBinDayDate = result.BinDays?.OrderBy(binDay => binDay.Date).FirstOrDefault()?.Date.ToDateTime(TimeOnly.MinValue); - var cacheExpiration = (earliestBinDayDate ?? DateTimeOffset.UtcNow.Date).AddDays(1); - - var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = cacheExpiration }; - _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); - } - - return Ok(result); - } - catch (SupportedCollectorNotFoundException ex) - { - _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); - return NotFound("No supported collector found for the specified gov.uk ID."); - } - catch (BinDaysNotFoundException ex) - { - _logger.LogWarning(ex, "No bin days found for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); - return NotFound("No bin days found for the specified address."); - } - catch (Exception ex) - { - _logger.LogError(ex, "An unexpected error occurred while retrieving bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching bin days. Please try again later."); - } - } - } -} +namespace BinDays.Api.Controllers +{ + using BinDays.Api.Collectors.Exceptions; + using BinDays.Api.Collectors.Models; + using BinDays.Api.Collectors.Services; + using BinDays.Api.Collectors.Utilities; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Caching.Distributed; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using System; + using System.Linq; + using System.Net.Http; + using System.Security.Cryptography; + using System.Text; + + /// + /// API controller for managing collectors. + /// + [ApiController] + public class CollectorsController : ControllerBase + { + /// + /// Service for returning specific or all collectors. + /// + private readonly CollectorService _collectorService; + + /// + /// Logger for the controller. + /// + private readonly ILogger _logger; + + /// + /// Distributed cache for storing responses. + /// + private readonly IDistributedCache _cache; + + private readonly JsonSerializerSettings _jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + }; + + /// + /// Initializes a new instance of the class. + /// + /// Service for retrieving collector information. + /// Logger for the controller. + /// Distributed cache for storing responses. + public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache) + { + _collectorService = collectorService; + _logger = logger; + _cache = cache; + } + + /// + /// Formats a postcode string for use in a cache key by converting it to uppercase and removing spaces. + /// + /// The postcode string to format. + /// The formatted postcode string for cache key usage. + private static string FormatPostcodeForCacheKey(string postcode) + { + return postcode.ToUpperInvariant().Replace(" ", string.Empty); + } + + /// + /// Attempts to retrieve and deserialize an object from the cache. + /// Handles deserialization errors by evicting the bad cache entry. + /// + /// The type to deserialize into. + /// The cache key. + /// The deserialized object or null if not found or invalid. + private T? TryGetFromCache(string cacheKey) where T : class + { + if (_cache.GetString(cacheKey) is string cachedResult) + { + try + { + return JsonConvert.DeserializeObject(cachedResult, _jsonSerializerSettings); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached data for key '{CacheKey}'. Evicting invalid cache entry.", cacheKey); + _cache.Remove(cacheKey); + } + } + + return null; + } + + /// + /// Gets all the collectors. + /// + /// An enumerable collection of collectors or an error response. + [HttpGet] + [Route("/collectors")] + public IActionResult GetCollectors() + { + try + { + var result = _collectorService.GetCollectors(); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving all collectors."); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching collectors. Please try again later."); + } + } + + /// + /// Gets the collector for a given postcode, potentially requiring multiple steps via client-side responses. + /// + /// The postcode to search for. + /// The response from a previous client-side request, if applicable. + /// The response containing either the next client-side request to make or the collector, or an error response. + [HttpPost] + [Route("/collector")] + public IActionResult GetCollector(string postcode, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"collector-{FormatPostcodeForCacheKey(postcode)}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning cached collector {CollectorName} for postcode: {Postcode}.", cachedResponse.Collector!.Name, postcode); + return Ok(cachedResponse); + } + + try + { + var result = _collectorService.GetCollector(postcode, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved collector {CollectorName} for postcode: {Postcode}.", result.Collector!.Name, postcode); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(90) }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (InvalidPostcodeException ex) + { + _logger.LogWarning(ex, "Invalid postcode provided: {Postcode}.", postcode); + return BadRequest("The supplied postcode is invalid."); + } + catch (UnsupportedCollectorException ex) + { + _logger.LogWarning(ex, "Unsupported collector {CollectorName} for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.CollectorName, ex.GovUkId, postcode); + return NotFound($"{ex.CollectorName} is not currently supported."); + } + catch (GovUkIdNotFoundException ex) + { + _logger.LogWarning(ex, "No gov.uk ID found for postcode: {Postcode}.", postcode); + return NotFound("No collector found for the specified postcode."); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", ex.GovUkId, postcode); + return NotFound("No supported collector found for the specified postcode."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving collector for postcode: {Postcode}.", postcode); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching the collector for the specified postcode. Please try again later."); + } + } + + /// + /// Gets addresses for a given gov.uk ID and postcode. + /// + /// The gov.uk identifier for the collector. + /// The postcode to search addresses for. + /// The response from a previous client-side request, if applicable. + /// A response containing addresses, or an error response. + [HttpPost] + [Route("/{govUkId}/addresses")] + public IActionResult GetAddresses(string govUkId, string postcode, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"addresses-{govUkId}-{FormatPostcodeForCacheKey(postcode)}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning {AddressCount} cached addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", cachedResponse.Addresses!.Count, govUkId, postcode); + return Ok(cachedResponse); + } + + try + { + var result = _collectorService.GetAddresses(govUkId, postcode, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved {AddressCount} addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", result.Addresses!.Count, govUkId, postcode); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(30) }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); + return NotFound("No supported collector found for the specified gov.uk ID."); + } + catch (AddressesNotFoundException ex) + { + _logger.LogWarning(ex, "No addresses found for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); + return NotFound("No addresses found for the specified postcode."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching addresses. Please try again later."); + } + } + + /// + /// Gets bin days for a given gov.uk ID, postcode, and unique address identifier. + /// + /// The gov.uk identifier for the collector. + /// The postcode of the address. + /// The unique identifier of the address. + /// The response from a previous client-side request, if applicable. + /// A response containing bin days, or an error response. + [HttpPost] + [Route("/{govUkId}/bin-days")] + public IActionResult GetBinDays(string govUkId, string postcode, string uid, [FromBody] ClientSideResponse? clientSideResponse) + { + postcode = ProcessingUtilities.FormatPostcode(postcode); + + var cacheKey = $"bin-days-{govUkId}-{FormatPostcodeForCacheKey(postcode)}-{uid}"; + var cachedResponse = TryGetFromCache(cacheKey); + + if (cachedResponse != null) + { + _logger.LogInformation("Returning {BinDayCount} cached bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", cachedResponse.BinDays!.Count, govUkId, postcode, uid); + return Ok(cachedResponse); + } + + try + { + var address = new Address + { + Postcode = postcode, + Uid = uid + }; + + var result = _collectorService.GetBinDays(govUkId, address, clientSideResponse); + + // Cache result if successful and no next client-side request + if (result.NextClientSideRequest == null) + { + _logger.LogInformation("Successfully retrieved {BinDayCount} bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", result.BinDays!.Count, govUkId, postcode, uid); + + // Cache until the day after the earliest bin day, or for 1 day if no bin days are returned. + var earliestBinDayDate = result.BinDays?.OrderBy(binDay => binDay.Date).FirstOrDefault()?.Date.ToDateTime(TimeOnly.MinValue); + var cacheExpiration = (earliestBinDayDate ?? DateTimeOffset.UtcNow.Date).AddDays(1); + + var cacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = cacheExpiration }; + _cache.SetString(cacheKey, JsonConvert.SerializeObject(result, _jsonSerializerSettings), cacheEntryOptions); + } + + return Ok(result); + } + catch (SupportedCollectorNotFoundException ex) + { + _logger.LogWarning(ex, "No supported collector found for gov.uk ID: {GovUkId}.", govUkId); + return NotFound("No supported collector found for the specified gov.uk ID."); + } + catch (BinDaysNotFoundException ex) + { + _logger.LogWarning(ex, "No bin days found for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); + return NotFound("No bin days found for the specified address."); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while retrieving bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); + return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching bin days. Please try again later."); + } + } + } +} diff --git a/BinDays.Api/Program.cs b/BinDays.Api/Program.cs index ab7f4dc7..ab4c0d66 100644 --- a/BinDays.Api/Program.cs +++ b/BinDays.Api/Program.cs @@ -1,66 +1,66 @@ -using StackExchange.Redis; - -var builder = WebApplication.CreateBuilder(args); - -// Use Autofac as the service provider factory -builder.Host.UseServiceProviderFactory(new Autofac.Extensions.DependencyInjection.AutofacServiceProviderFactory()); - -// Register services directly with Autofac using the ConfigureContainer method -builder.Host.ConfigureContainer(BinDays.Api.Initialisation.DependencyInjection.ConfigureContainer); - -builder.Services.AddControllers(); - -// Add caching for responses, either in-memory or Redis -var redis = builder.Configuration.GetValue("Redis"); -if (!string.IsNullOrEmpty(redis)) -{ - var multiplexer = ConnectionMultiplexer.Connect(redis); - builder.Services.AddSingleton(multiplexer); - - builder.Services.AddStackExchangeRedisCache(options => - { - options.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); - }); -} -else -{ - builder.Services.AddDistributedMemoryCache(); -} - -// Health check for monitoring -builder.Services.AddHealthChecks(); - -// Configure Seq logging (optional) -builder.Services.AddLogging(loggingBuilder => -{ - loggingBuilder.AddSeq(builder.Configuration.GetSection("Seq")); -}); - -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseCors(x => x - .AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader() -); - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.MapControllers(); - -app.MapHealthChecks("/status"); - -app.Run(); +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +// Use Autofac as the service provider factory +builder.Host.UseServiceProviderFactory(new Autofac.Extensions.DependencyInjection.AutofacServiceProviderFactory()); + +// Register services directly with Autofac using the ConfigureContainer method +builder.Host.ConfigureContainer(BinDays.Api.Initialisation.DependencyInjection.ConfigureContainer); + +builder.Services.AddControllers(); + +// Add caching for responses, either in-memory or Redis +var redis = builder.Configuration.GetValue("Redis"); +if (!string.IsNullOrEmpty(redis)) +{ + var multiplexer = ConnectionMultiplexer.Connect(redis); + builder.Services.AddSingleton(multiplexer); + + builder.Services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); + }); +} +else +{ + builder.Services.AddDistributedMemoryCache(); +} + +// Health check for monitoring +builder.Services.AddHealthChecks(); + +// Configure Seq logging (optional) +builder.Services.AddLogging(loggingBuilder => +{ + loggingBuilder.AddSeq(builder.Configuration.GetSection("Seq")); +}); + +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors(x => x + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() +); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHealthChecks("/status"); + +app.Run();