diff --git a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs index 4119967..832e1a7 100644 --- a/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs +++ b/src/BitzArt.Blazor.Cookies.Server/Services/HttpContextCookieService.cs @@ -1,38 +1,50 @@ -using Microsoft.AspNetCore.DataProtection.KeyManagement; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; namespace BitzArt.Blazor.Cookies; internal class HttpContextCookieService : ICookieService { private readonly HttpContext _httpContext; - private readonly Dictionary _cache; + private readonly Dictionary _requestCookies; - public HttpContextCookieService(IHttpContextAccessor httpContextAccessor) + private readonly ILogger _logger; + + private IHeaderDictionary _responseHeaders { get; set; } + + public HttpContextCookieService(IHttpContextAccessor httpContextAccessor, IFeatureCollection features, ILogger logger) { _httpContext = httpContextAccessor.HttpContext!; - _cache = _httpContext.Request.Cookies + _logger = logger; + + _requestCookies = _httpContext.Request.Cookies .Select(x => new Cookie(x.Key, x.Value)).ToDictionary(cookie => cookie.Key); + + _responseHeaders = features.GetRequiredFeature().Headers; } public Task> GetAllAsync() { - return Task.FromResult(_cache.Select(x => x.Value).ToList().AsEnumerable()); + return Task.FromResult(_requestCookies.Select(x => x.Value).ToList().AsEnumerable()); } public Task GetAsync(string key) { - if (_cache.TryGetValue(key, out var cookie)) return Task.FromResult(cookie); + if (_requestCookies.TryGetValue(key, out var cookie)) return Task.FromResult(cookie); return Task.FromResult(null); } public Task RemoveAsync(string key, CancellationToken cancellationToken = default) { - if (!_cache.TryGetValue(key, out _)) return Task.CompletedTask; + if (RemovePending(key)) _logger.LogDebug("Pending cookie [{key}] removed.", key); - _cache.Remove(key); - _httpContext.Response.Cookies.Delete(key); + if (_requestCookies.Remove(key)) + { + _logger.LogDebug("Removing client browser cookie [{key}] by marking it as expired.", key); + _httpContext.Response.Cookies.Delete(key); + } return Task.CompletedTask; } @@ -40,26 +52,44 @@ public Task RemoveAsync(string key, CancellationToken cancellationToken = defaul public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default) => SetAsync(new Cookie(key, value, expiration), cancellationToken); - public async Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) + public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default) { - var alreadyExists = _cache.TryGetValue(cookie.Key, out var existingCookie); + _logger.LogDebug("Setting cookie: '{key}'='{value}'", cookie.Key, cookie.Value); - if (alreadyExists) - { - // If the cookie already exists and the value has not changed, - // we don't need to update it. - if (existingCookie == cookie) return; - - // If the cookie already exists and the new value has changed, - // we remove the old one before adding the new one. - await RemoveAsync(cookie.Key, cancellationToken); - } + RemovePending(cookie.Key); - _cache.Add(cookie.Key, cookie); _httpContext.Response.Cookies.Append(cookie.Key, cookie.Value, new CookieOptions { Expires = cookie.Expiration, Path = "/", }); + + return Task.CompletedTask; + } + + private bool RemovePending(string key) + { + _logger.LogDebug("Checking for pending cookie: '{key}'", key); + + var cookieValues = _responseHeaders + .SetCookie + .ToList(); + + for (int i = 0; i < cookieValues.Count; i++) + { + var value = cookieValues[i]; + if (string.IsNullOrWhiteSpace(value)) continue; + if (!value.StartsWith($"{key}=")) continue; + + _logger.LogDebug("Pending cookie [{key}] found, removing...", key); + cookieValues.RemoveAt(i); + _responseHeaders.SetCookie = new([.. cookieValues]); + _logger.LogDebug("Pending cookie [{key}] removed.", key); + + return true; + } + + _logger.LogDebug("No pending cookie found."); + return false; } } diff --git a/src/BitzArt.Blazor.Cookies/BitzArt.Blazor.Cookies.csproj b/src/BitzArt.Blazor.Cookies/BitzArt.Blazor.Cookies.csproj index dd24ac7..1ac3b67 100644 --- a/src/BitzArt.Blazor.Cookies/BitzArt.Blazor.Cookies.csproj +++ b/src/BitzArt.Blazor.Cookies/BitzArt.Blazor.Cookies.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + true BitzArt.Blazor.Cookies BitzArt diff --git a/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs b/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs index 70ee513..f7a6929 100644 --- a/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs +++ b/src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs @@ -1,10 +1,48 @@ namespace BitzArt.Blazor.Cookies; +/// +/// Allows interacting with browser cookies. +/// public interface ICookieService { + /// + /// Retrieves all cookies. + /// public Task> GetAllAsync(); + + /// + /// Retrieves a cookie by its key. + /// + /// The key of the cookie to retrieve. + /// The requested cookie, or null if it does not exist. public Task GetAsync(string key); + + /// + /// Removes a cookie by marking it as expired. + /// + /// The key of the cookie to remove. + /// Cancellation token. + /// A task that represents the asynchronous operation. public Task RemoveAsync(string key, CancellationToken cancellationToken = default); - public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default); + + /// + /// The name of the cookie to set. + /// The value of the cookie to set. + /// The cookie's expiration date. + /// Cancellation token. + /// A task that represents the asynchronous operation. + public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default) + => SetAsync(new Cookie(key, value, expiration), cancellationToken); + + /// + /// Adds or updates a browser cookie.

+ /// When in Static SSR render mode, + /// the new value will be sent to the client machine + /// after the page has completed rendering, + /// and will not appear in the cookies collection until the next request. + ///
+ /// The cookie to set. + /// Cancellation token. + /// A task that represents the asynchronous operation. public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default); } diff --git a/src/BitzArt.Blazor.Cookies/Model/Cookie.cs b/src/BitzArt.Blazor.Cookies/Model/Cookie.cs index 8093bfc..459ea3a 100644 --- a/src/BitzArt.Blazor.Cookies/Model/Cookie.cs +++ b/src/BitzArt.Blazor.Cookies/Model/Cookie.cs @@ -1,3 +1,9 @@ namespace BitzArt.Blazor.Cookies; +/// +/// Browser cookie. +/// +/// The name of the cookie. +/// The value of the cookie. +/// The expiration date of the cookie. public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null) { } diff --git a/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs index 3432451..16fc6ef 100644 --- a/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs +++ b/tests/BitzArt.Blazor.Cookies.Server.Tests/HttpContextCookieServiceTests.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; namespace BitzArt.Blazor.Cookies.Server.Tests; @@ -15,12 +17,14 @@ public async Task SetCookie_WhenProperCookie_ShouldSetCookie() // Assert Assert.Single(httpContext.Response.Headers); - Assert.Single(httpContext.Response.Headers["Set-Cookie"]); - Assert.Contains("key=value", httpContext.Response.Headers["Set-Cookie"].First()); + var values = httpContext.Response.Headers.SetCookie; + Assert.Single(values); + var value = values.First(); + Assert.Equal("key=value; path=/", value); } [Fact] - public async Task RemoveCookie_AfterSetCookie_ShouldRemoveCookie() + public async Task RemoveCookie_AfterSetCookie_ShouldRemovePending() { // Arrange (var httpContext, _, var service) = CreateTestServices(); @@ -31,9 +35,8 @@ public async Task RemoveCookie_AfterSetCookie_ShouldRemoveCookie() await service.RemoveAsync("key"); // Assert - Assert.Single(httpContext.Response.Headers); - Assert.Single(httpContext.Response.Headers["Set-Cookie"]); - Assert.Contains("key=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=", httpContext.Response.Headers["Set-Cookie"].First()); + Assert.Empty(httpContext.Response.Headers); + Assert.True(httpContext.Response.Headers.SetCookie.Count == 0); } [Fact] @@ -47,15 +50,17 @@ public async Task SetCookie_WhenDuplicate_ShouldOnlySetCookieOnce() await service.SetAsync("key", "value2", null); // Assert - Assert.Single(httpContext.Response.Headers); + var values = httpContext.Features.GetRequiredFeature().Headers.SetCookie; + Assert.Single(values); } private static TestServices CreateTestServices() { var httpContext = new DefaultHttpContext(); var accessor = new TestHttpContextAccessor(httpContext); + var logger = new LoggerFactory().CreateLogger(); - var cookieService = new HttpContextCookieService(accessor); + var cookieService = new HttpContextCookieService(accessor, httpContext.Features, logger); return new TestServices(httpContext, accessor, cookieService); }