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();