diff --git a/src/Handyman.AspNetCore/docs/changelog.md b/src/Handyman.AspNetCore/docs/changelog.md index 3083739a..213a3b7a 100644 --- a/src/Handyman.AspNetCore/docs/changelog.md +++ b/src/Handyman.AspNetCore/docs/changelog.md @@ -1,5 +1,9 @@ # Handyman.AspNetCore changelog +## 3.8.0 - 2024-09-02 + +* Add static `ETagUtility` class. + ## 3.7.0 - 2023-11-24 * Add `net8.0` TFM. diff --git a/src/Handyman.AspNetCore/docs/index.md b/src/Handyman.AspNetCore/docs/index.md index 90d21604..50a07c07 100644 --- a/src/Handyman.AspNetCore/docs/index.md +++ b/src/Handyman.AspNetCore/docs/index.md @@ -159,32 +159,18 @@ public IEnumerable GetValues(string apiVersion) ## ETags -This feature simplifies accessing the `e-tag` from `If-Match` and `If-None-Match` headers. -It will also make sure that the e-tag conforms to the e-tag format. +This feature simplifies working with `e-tags` by +* Reading the `e-tag` from `If-Match` and `If-None-Match` headers. +* Simplify converting between `e-tag` and SQL Server row version and comparing the two. +* Add `e-tag` header to outgoing http response. ### Setup -Add required services and middleware. - -``` csharp -public void ConfigureServices(IServiceCollection services) -{ - services.AddETags(); -} - -public void Configure(IApplicationBuilder app) -{ - app.UseRouting(); - app.UseETags(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); -} -``` - -:information_source: The e-tags middleware must be added after any exception handling middleware (like [Hellang.Middleware.ProblemDetails](https://www.nuget.org/packages/Hellang.Middleware.ProblemDetails/)) to work. +Add required services and middleware by calling `IServiceCollection.AddETags()` & `IApplicationBuilder.UseETags()` extension methods. ### Validate http request e-tag header format -Http request headers `If-Match` or `If-None-Match` will automatically be inspected and if it isn't a valid e-tag (wildcards are supported), it will respond with `400 Bad request`. +Http request headers `If-Match` or `If-None-Match` will automatically be inspected and if it isn't a valid e-tag (wildcards are supported), it will respond with a `400 Bad request`. ### Access request e-tag @@ -207,42 +193,38 @@ public void Store(Payload payload, [FromIfMatchHeader] string eTag) ### Compare e-tags -Use `IETagUtilities.Comparer` to see if the incoming e-tag is up to date. +Use `ETagUtility.EnsureEquals(...)` to see if the incoming e-tag is up to date. The `EnsureEquals` methods will throw a `PreconditionFailedException` if the values don't match. The exception will be caught by the middleware and converted to a `412 Precondition failed` response. ``` csharp -public class Repository +public async Task UpdateItem(Item item, string eTag, CancellationToken cancellationToken) { - private readonly DbContext _dbContext; - private readonly IETagUtilities _eTagUtilities; + var dbItem = await _dbContext.Items.SingleAsync(x => x.Id == item.Id, cancellationToken); - public Repository(DbContext dbContext, IETagUtilities eTagUtilities) - { - _dbContext = dbContext; - _eTagUtilities = eTagUtilities; - } - - public async Task UpdateItem(Item item, string eTag, CancellationToken cancellationToken) - { - var dbItem = await _dbContext.Items.SingleAsync(x => x.Id == item.Id, cancellationToken); + ETagUtility.EnsureEquals(eTag, item.RowVersion); - _eTagUtilities.Comparer.EnsureEquals(eTag, item.RowVersion); + dbItem.Name = item.Name; - // update dbItem from item - - await _dbContext.SaveChangesAsync(cancellationToken); - } + await _dbContext.SaveChangesAsync(cancellationToken); } ``` ### Generate e-tags -Use `IETagUtilities.Converter` to convert byte arrays (sql server _row versions_) to string. +Use `ETagUtility.ToETagValue(...)` to convert byte arrays (sql server _row versions_) to string. ``` csharp -var eTagUtilities = serviceProvider.GetRequiredService(); -byte[] bytes = ...; -string eTag = eTagUtilities.Converter.FromByteArray(bytes); +public async Task GetItem(int itemId, CancellationToken cancellationToken) +{ + var dbItem = await _dbContext.Items.SingleAsync(x => x.Id == itemId, cancellationToken); + + return new Item + { + Id = dbItem.Id, + Name = dbItem.Name, + ETag = ETagUtility.ToETagValue(dbItem.RowVersion) + }; +} ``` ### Write response e-tag header @@ -250,10 +232,16 @@ string eTag = eTagUtilities.Converter.FromByteArray(bytes); Use the `SetETagHeader` extension method on `HttpResponse` to set the e-tag header. ``` csharp -[HttpGet] -public void Get() +[HttpGet("{id:int}")] +public async Task GetItem(int id) { - var eTag = GetETag(); - Response.SetETagHeader(eTag); + var item = await LoadItem(id); + Response.SetETagHeader(item.ETag); + return item; } ``` + +### Backwards compatibility + +The static `ETagUtility` class was introduced in version 3.8.0, before that there `IETagUtilities` had to be used. +`IETagUtilities` is still available for backwards compatibility. \ No newline at end of file diff --git a/src/Handyman.AspNetCore/src/ETags/ETagUtility.cs b/src/Handyman.AspNetCore/src/ETags/ETagUtility.cs new file mode 100644 index 00000000..c304ed2a --- /dev/null +++ b/src/Handyman.AspNetCore/src/ETags/ETagUtility.cs @@ -0,0 +1,168 @@ +#nullable enable + +using Handyman.AspNetCore.ETags.Internals; +using System; +using System.Linq; + +namespace Handyman.AspNetCore.ETags; + +public static class ETagUtility +{ + public static string ToETag(byte[] bytes) + { + return $"W/\"{ToETagValue(bytes)}\""; + } + + public static string ToETag(string s) + { + if (s == "*") + { + return s; + } + + var value = GetETagValueOrThrow(s); + + if (value.Length != s.Length) + { + return s; + } + + return $"W/\"{value}\""; + } + + public static string ToETagValue(byte[] bytes) + { + var strings = bytes + .SkipWhile(x => x == 0) + .Select(x => x.ToString("x2")); + + var eTagValue = string.Join("", strings); + + return eTagValue.Length != 0 + ? eTagValue + : "0"; + } + + public static string ToETagValue(string s) + { + var value = GetETagValueOrThrow(s); + + if (value.Length == s.Length) + { + return s; + } + + return value.ToString(); + } + + public static bool Equals(string eTag1, byte[] eTag2) + { + if (eTag1 == "*") + { + return true; + } + + return Equals(eTag1, ToETagValue(eTag2)); + } + + public static bool Equals(string eTag1, string eTag2) + { + if (eTag1 == "*" || eTag2 == "*") + { + return true; + } + + var value1 = GetETagValueOrThrow(eTag1); + var value2 = GetETagValueOrThrow(eTag2); + + return value1.SequenceEqual(value2); + } + + public static void EnsureEquals(string eTag1, byte[] eTag2) + { + if (Equals(eTag1, eTag2)) + { + return; + } + + throw new PreconditionFailedException(); + } + + public static void EnsureEquals(string eTag1, string eTag2) + { + if (Equals(eTag1, eTag2)) + { + return; + } + + throw new PreconditionFailedException(); + } + + internal static ReadOnlySpan GetETagValueOrThrow(string eTag) + { + var startsWith = eTag.StartsWith("W/\""); + var endsWith = eTag.EndsWith("\""); + + var span = eTag.AsSpan(); + + if (startsWith && endsWith) + { + span = span.Slice(3, span.Length - 4); + } + else if (!startsWith && !endsWith) + { + // nothing to do here + } + else + { + ThrowInvalidETagException(); + } + + if (span.Length == 0) + { + ThrowInvalidETagException(); + } + + return span; + } + + internal static bool IsValidETag(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + if (candidate == "*") + { + return true; + } + + if (candidate.Length <= 4) + { + return false; + } + + if (!candidate.StartsWith("W/\"")) + { + return false; + } + + if (!candidate.EndsWith("\"")) + { + return false; + } + + if (candidate.AsSpan(3, candidate.Length - 4).Contains('"')) + { + return false; + } + + return true; + } + + public static void ThrowInvalidETagException() + { + throw new ArgumentException("Invalid eTag format/value."); + } +} \ No newline at end of file diff --git a/src/Handyman.AspNetCore/src/ETags/HttpResponseExtensions.cs b/src/Handyman.AspNetCore/src/ETags/HttpResponseExtensions.cs index ecc6a86b..984a3498 100644 --- a/src/Handyman.AspNetCore/src/ETags/HttpResponseExtensions.cs +++ b/src/Handyman.AspNetCore/src/ETags/HttpResponseExtensions.cs @@ -7,6 +7,13 @@ public static class HttpResponseExtensions { public static void SetETagHeader(this HttpResponse response, string eTag) { + eTag = ETagUtility.ToETag(eTag); + + if (!ETagUtility.IsValidETag(eTag)) + { + ETagUtility.ThrowInvalidETagException(); + } + response.Headers[HeaderNames.ETag] = eTag; } } diff --git a/src/Handyman.AspNetCore/src/ETags/Internals/ETagComparer.cs b/src/Handyman.AspNetCore/src/ETags/Internals/ETagComparer.cs index 64c59451..95fca224 100644 --- a/src/Handyman.AspNetCore/src/ETags/Internals/ETagComparer.cs +++ b/src/Handyman.AspNetCore/src/ETags/Internals/ETagComparer.cs @@ -1,50 +1,39 @@ -namespace Handyman.AspNetCore.ETags.Internals +using System; + +namespace Handyman.AspNetCore.ETags.Internals { internal class ETagComparer : IETagComparer { - private readonly IETagConverter _converter; - - public ETagComparer(IETagConverter converter) - { - _converter = converter; - } - public void EnsureEquals(string eTag, byte[] bytes) { - if (Equals(eTag, bytes)) - return; + ArgumentNullException.ThrowIfNull(eTag); + ArgumentNullException.ThrowIfNull(bytes); - throw new PreconditionFailedException(); + ETagUtility.EnsureEquals(eTag, bytes); } public void EnsureEquals(string eTag1, string eTag2) { - if (Equals(eTag1, eTag2)) - return; + ArgumentNullException.ThrowIfNull(eTag1); + ArgumentNullException.ThrowIfNull(eTag2); - throw new PreconditionFailedException(); + ETagUtility.EnsureEquals(eTag1, eTag2); } public bool Equals(string eTag, byte[] bytes) { - if (eTag == "*") - return true; - - if (string.IsNullOrWhiteSpace(eTag)) - return false; + ArgumentNullException.ThrowIfNull(eTag); + ArgumentNullException.ThrowIfNull(bytes); - return eTag == _converter.FromByteArray(bytes); + return ETagUtility.Equals(eTag, bytes); } public bool Equals(string eTag1, string eTag2) { - if (eTag1 == "*" || eTag2 == "*") - return true; - - if (string.IsNullOrWhiteSpace(eTag1) || string.IsNullOrWhiteSpace(eTag2)) - return false; + ArgumentNullException.ThrowIfNull(eTag1); + ArgumentNullException.ThrowIfNull(eTag2); - return eTag1 == eTag2; + return ETagUtility.Equals(eTag1, eTag2); } } } \ No newline at end of file diff --git a/src/Handyman.AspNetCore/src/ETags/Internals/ETagConverter.cs b/src/Handyman.AspNetCore/src/ETags/Internals/ETagConverter.cs index 0749fc5c..851edaa6 100644 --- a/src/Handyman.AspNetCore/src/ETags/Internals/ETagConverter.cs +++ b/src/Handyman.AspNetCore/src/ETags/Internals/ETagConverter.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; namespace Handyman.AspNetCore.ETags.Internals { @@ -7,12 +6,9 @@ internal class ETagConverter : IETagConverter { public string FromByteArray(byte[] bytes) { - if (bytes == null) throw new ArgumentNullException(); - if (bytes.Length == 0) throw new ArgumentException(); + ArgumentNullException.ThrowIfNull(bytes); - var strings = bytes.SkipWhile(x => x == 0).Select(x => x.ToString("x2")); - - return $"W/\"{string.Join("", strings)}\""; + return ETagUtility.ToETag(bytes); } } } \ No newline at end of file diff --git a/src/Handyman.AspNetCore/src/ETags/Internals/ETagValidator.cs b/src/Handyman.AspNetCore/src/ETags/Internals/ETagValidator.cs index a9f58a8e..1c29b27e 100644 --- a/src/Handyman.AspNetCore/src/ETags/Internals/ETagValidator.cs +++ b/src/Handyman.AspNetCore/src/ETags/Internals/ETagValidator.cs @@ -4,31 +4,7 @@ internal class ETagValidator : IETagValidator { public bool IsValidETag(string candidate) { - if (string.IsNullOrEmpty(candidate)) - return false; - - if (candidate == "*") - return true; - - if (!candidate.EndsWith("\"")) - return false; - - int start; - - if (candidate.StartsWith("W/\"")) - { - start = 3; - } - else if (candidate.StartsWith("\"")) - { - start = 1; - } - else - { - return false; - } - - return candidate.Length > (start + 1); + return ETagUtility.IsValidETag(candidate); } } } \ No newline at end of file diff --git a/src/Handyman.AspNetCore/src/Handyman.AspNetCore.csproj b/src/Handyman.AspNetCore/src/Handyman.AspNetCore.csproj index d9695ac1..089d6ade 100644 --- a/src/Handyman.AspNetCore/src/Handyman.AspNetCore.csproj +++ b/src/Handyman.AspNetCore/src/Handyman.AspNetCore.csproj @@ -1,7 +1,7 @@  - 3.7.0 + 3.8.0 net6.0;net7.0;net8.0 diff --git a/src/Handyman.AspNetCore/tests/ETags/ETagComparerTests.cs b/src/Handyman.AspNetCore/tests/ETags/ETagComparerTests.cs index 3d4a86d8..fb9e72b2 100644 --- a/src/Handyman.AspNetCore/tests/ETags/ETagComparerTests.cs +++ b/src/Handyman.AspNetCore/tests/ETags/ETagComparerTests.cs @@ -1,5 +1,4 @@ -using Handyman.AspNetCore.ETags; -using Handyman.AspNetCore.ETags.Internals; +using Handyman.AspNetCore.ETags.Internals; using Shouldly; using Xunit; @@ -8,18 +7,13 @@ namespace Handyman.AspNetCore.Tests.ETags public class ETagComparerTests { [Theory] - [InlineData(null, null, false)] - [InlineData(null, "*", true)] - [InlineData(null, "123", false)] - [InlineData("*", null, true)] [InlineData("*", "123", true)] - [InlineData("123", null, false)] [InlineData("123", "*", true)] [InlineData("123", "123", true)] [InlineData("123", "321", false)] public void ShouldCompareETags(string eTag1, string eTag2, bool result) { - new ETagComparer(new ETagConverter()).Equals(eTag1, eTag2).ShouldBe(result); + new ETagComparer().Equals(eTag1, eTag2).ShouldBe(result); } } } \ No newline at end of file diff --git a/src/Handyman.AspNetCore/tests/ETags/ETagUtilityTests.cs b/src/Handyman.AspNetCore/tests/ETags/ETagUtilityTests.cs new file mode 100644 index 00000000..025931f9 --- /dev/null +++ b/src/Handyman.AspNetCore/tests/ETags/ETagUtilityTests.cs @@ -0,0 +1,48 @@ +using Handyman.AspNetCore.ETags; +using Shouldly; +using System; +using Xunit; + +namespace Handyman.AspNetCore.Tests.ETags; + +public class ETagUtilityTests +{ + [Fact] + public void BytesToETag() + { + ETagUtility.ToETag(new byte[] { 0, 1, 2, 3 }).ShouldBe("W/\"010203\""); + ETagUtility.ToETag(new byte[] { 1, 2, 3 }).ShouldBe("W/\"010203\""); + ETagUtility.ToETag(new byte[] { 0 }).ShouldBe("W/\"0\""); + ETagUtility.ToETag(Array.Empty()).ShouldBe("W/\"0\""); + } + + [Fact] + public void StringToETag() + { + ETagUtility.ToETag("W/\"010203\"").ShouldBe("W/\"010203\""); + ETagUtility.ToETag("010203").ShouldBe("W/\"010203\""); + ETagUtility.ToETag("0").ShouldBe("W/\"0\""); + ETagUtility.ToETag("*").ShouldBe("*"); + Should.Throw(() => ETagUtility.ToETag("")); + Should.Throw(() => ETagUtility.ToETag("W/\"\"")); + } + + [Fact] + public void BytesToETagValue() + { + ETagUtility.ToETagValue(new byte[] { 0, 1, 2, 3 }).ShouldBe("010203"); + ETagUtility.ToETagValue(new byte[] { 1, 2, 3 }).ShouldBe("010203"); + ETagUtility.ToETagValue(new byte[] { 0 }).ShouldBe("0"); + ETagUtility.ToETagValue(Array.Empty()).ShouldBe("0"); + } + + [Fact] + public void StringToETagValue() + { + ETagUtility.ToETagValue("W/\"010203\"").ShouldBe("010203"); + ETagUtility.ToETagValue("010203").ShouldBe("010203"); + ETagUtility.ToETagValue("0").ShouldBe("0"); + Should.Throw(() => ETagUtility.ToETagValue("")); + Should.Throw(() => ETagUtility.ToETagValue("W/\"\"")); + } +} \ No newline at end of file diff --git a/src/Handyman.AspNetCore/tests/ETags/ETagValidatorTests.cs b/src/Handyman.AspNetCore/tests/ETags/ETagValidatorTests.cs index fecfde05..f2c5a63b 100644 --- a/src/Handyman.AspNetCore/tests/ETags/ETagValidatorTests.cs +++ b/src/Handyman.AspNetCore/tests/ETags/ETagValidatorTests.cs @@ -1,5 +1,4 @@ -using Handyman.AspNetCore.ETags; -using Handyman.AspNetCore.ETags.Internals; +using Handyman.AspNetCore.ETags.Internals; using Shouldly; using Xunit; @@ -9,7 +8,6 @@ public class ETagValidatorTests { [Theory] [InlineData("*")] - [InlineData("\"x\"")] [InlineData("W/\"x\"")] [InlineData("W/\"iuwyehf72tw45ii7yhw734ydh287rygw87ryug8wd7yhr8w37yr8w37jry8\"")] public void ShouldAcceptValidETags(string eTag) @@ -20,7 +18,7 @@ public void ShouldAcceptValidETags(string eTag) [Theory] [InlineData("x")] [InlineData("x\"")] - [InlineData("\"x")] + [InlineData("\"x\"")] [InlineData("x\"x\"")] [InlineData("\"x\"x")] [InlineData("W/\"x")]