using System.Collections.Concurrent; using System.Text.Json; using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Services.Implementations; public class BlogService : IBlogService { private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5); private readonly TimeSpan _staleGrace = TimeSpan.FromHours(1); private readonly ConcurrentDictionary _cache = new(); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; public BlogService(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClient = httpClientFactory.CreateClient("BlogApi"); _logger = logger; } public async Task> GetAllPostsAsync() { return await GetCachedAsync("all_posts", () => FetchAsync>("api/blog/posts")) ?? []; } public async Task GetPostBySlugAsync(string slug) { return await GetCachedAsync($"post_{slug}", () => FetchAsync($"api/blog/posts/{Uri.EscapeDataString(slug)}")); } public async Task> GetPostsByTagAsync(string tag) { return await GetCachedAsync($"tag_{tag}", () => FetchAsync>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? []; } private async Task FetchAsync(string path) where T : class { try { var response = await _httpClient.GetAsync(path); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path); return null; } var json = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize(json, JsonOptions); } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch from blog API: {Path}", path); return null; } } private async Task GetCachedAsync(string key, Func> factory) where T : class { _cache.TryGetValue(key, out var entry); if (entry is not null && entry.ExpiresAt > DateTime.UtcNow) return (T?)entry.Value; var result = await factory(); if (result is not null) { _cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl)); return result; } return entry is null ? null : ServeStale(key, entry); } // Stale entries are dropped once the grace window closes so that a dead API fails the same // way for every key. Serving them indefinitely let a cached post list render next to 404s on // the posts themselves, which reads as a site bug rather than an outage. private T? ServeStale(string key, CacheEntry entry) where T : class { if (entry.ExpiresAt.Add(_staleGrace) > DateTime.UtcNow) { _logger.LogWarning("Blog API unreachable, serving stale cache for key {Key}", key); return (T?)entry.Value; } _cache.TryRemove(key, out _); _logger.LogError("Blog API unreachable for over {StaleGrace}, dropping stale cache for key {Key}", _staleGrace, key); return null; } public void ClearCache() { _cache.Clear(); _logger.LogInformation("Blog cache cleared"); } private record CacheEntry(object Value, DateTime ExpiresAt); }