Skip to content

Commit

Permalink
added ETagUtility
Browse files Browse the repository at this point in the history
  • Loading branch information
JonasSamuelsson committed Sep 2, 2024
1 parent 4a26d89 commit d4ecc7c
Show file tree
Hide file tree
Showing 11 changed files with 284 additions and 116 deletions.
4 changes: 4 additions & 0 deletions src/Handyman.AspNetCore/docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
80 changes: 34 additions & 46 deletions src/Handyman.AspNetCore/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,32 +159,18 @@ public IEnumerable<string> 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

Expand All @@ -207,53 +193,55 @@ 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<IETagUtilities>();
byte[] bytes = ...;
string eTag = eTagUtilities.Converter.FromByteArray(bytes);
public async Task<Item> 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

Use the `SetETagHeader` extension method on `HttpResponse` to set the e-tag header.

``` csharp
[HttpGet]
public void Get()
[HttpGet("{id:int}")]
public async Task<Item> 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.
168 changes: 168 additions & 0 deletions src/Handyman.AspNetCore/src/ETags/ETagUtility.cs
Original file line number Diff line number Diff line change
@@ -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<char> 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.");
}
}
7 changes: 7 additions & 0 deletions src/Handyman.AspNetCore/src/ETags/HttpResponseExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
41 changes: 15 additions & 26 deletions src/Handyman.AspNetCore/src/ETags/Internals/ETagComparer.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading

0 comments on commit d4ecc7c

Please sign in to comment.