Skip to content

Commit

Permalink
Smarter pending cookies (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
YuriyDurov authored Oct 28, 2024
1 parent 1f467bf commit 5936647
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -1,65 +1,95 @@
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<string, Cookie> _cache;
private readonly Dictionary<string, Cookie> _requestCookies;

public HttpContextCookieService(IHttpContextAccessor httpContextAccessor)
private readonly ILogger _logger;

private IHeaderDictionary _responseHeaders { get; set; }

public HttpContextCookieService(IHttpContextAccessor httpContextAccessor, IFeatureCollection features, ILogger<ICookieService> 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<IHttpResponseFeature>().Headers;
}

public Task<IEnumerable<Cookie>> GetAllAsync()
{
return Task.FromResult(_cache.Select(x => x.Value).ToList().AsEnumerable());
return Task.FromResult(_requestCookies.Select(x => x.Value).ToList().AsEnumerable());
}

public Task<Cookie?> GetAsync(string key)
{
if (_cache.TryGetValue(key, out var cookie)) return Task.FromResult<Cookie?>(cookie);
if (_requestCookies.TryGetValue(key, out var cookie)) return Task.FromResult<Cookie?>(cookie);

return Task.FromResult<Cookie?>(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;
}

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;
}
}
1 change: 1 addition & 0 deletions src/BitzArt.Blazor.Cookies/BitzArt.Blazor.Cookies.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

<PackageId>BitzArt.Blazor.Cookies</PackageId>
<Authors>BitzArt</Authors>
Expand Down
40 changes: 39 additions & 1 deletion src/BitzArt.Blazor.Cookies/Interfaces/ICookieService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
namespace BitzArt.Blazor.Cookies;

/// <summary>
/// Allows interacting with browser cookies.
/// </summary>
public interface ICookieService
{
/// <summary>
/// Retrieves all cookies.
/// </summary>
public Task<IEnumerable<Cookie>> GetAllAsync();

/// <summary>
/// Retrieves a cookie by its key.
/// </summary>
/// <param name="key"> The key of the cookie to retrieve. </param>
/// <returns> The requested cookie, or null if it does not exist. </returns>
public Task<Cookie?> GetAsync(string key);

/// <summary>
/// Removes a cookie by marking it as expired.
/// </summary>
/// <param name="key"> The key of the cookie to remove. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task RemoveAsync(string key, CancellationToken cancellationToken = default);
public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default);

/// <inheritdoc cref="SetAsync(Cookie, CancellationToken)"/>
/// <param name="key"> The name of the cookie to set. </param>
/// <param name="value"> The value of the cookie to set. </param>
/// <param name="expiration"> The cookie's expiration date. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task SetAsync(string key, string value, DateTimeOffset? expiration, CancellationToken cancellationToken = default)
=> SetAsync(new Cookie(key, value, expiration), cancellationToken);

/// <summary>
/// Adds or updates a browser cookie. <br/> <br/>
/// When in <see href="https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes">Static SSR render mode</see>,
/// 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.
/// </summary>
/// <param name="cookie"> The cookie to set. </param>
/// <param name="cancellationToken"> Cancellation token. </param>
/// <returns> A task that represents the asynchronous operation. </returns>
public Task SetAsync(Cookie cookie, CancellationToken cancellationToken = default);
}
6 changes: 6 additions & 0 deletions src/BitzArt.Blazor.Cookies/Model/Cookie.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
namespace BitzArt.Blazor.Cookies;

/// <summary>
/// Browser cookie.
/// </summary>
/// <param name="Key"> The name of the cookie. </param>
/// <param name="Value"> The value of the cookie. </param>
/// <param name="Expiration"> The expiration date of the cookie. </param>
public record Cookie(string Key, string Value, DateTimeOffset? Expiration = null) { }
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Logging;

namespace BitzArt.Blazor.Cookies.Server.Tests;

Expand All @@ -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();
Expand All @@ -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]
Expand All @@ -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<IHttpResponseFeature>().Headers.SetCookie;
Assert.Single(values);
}

private static TestServices CreateTestServices()
{
var httpContext = new DefaultHttpContext();
var accessor = new TestHttpContextAccessor(httpContext);
var logger = new LoggerFactory().CreateLogger<ICookieService>();

var cookieService = new HttpContextCookieService(accessor);
var cookieService = new HttpContextCookieService(accessor, httpContext.Features, logger);

return new TestServices(httpContext, accessor, cookieService);
}
Expand Down

0 comments on commit 5936647

Please sign in to comment.