Skip to content

Commit 4df5ead

Browse files
author
Bart Koelman
committed
Adds ETag support for read-only requests
1 parent d3d2f00 commit 4df5ead

File tree

6 files changed

+392
-1
lines changed

6 files changed

+392
-1
lines changed

src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ private void AddSerializationLayer()
285285
_services.AddScoped(typeof(AtomicOperationsResponseSerializer));
286286
_services.AddScoped(sp => sp.GetRequiredService<IJsonApiSerializerFactory>().GetSerializer());
287287
_services.AddScoped<IResourceObjectBuilder, ResponseResourceObjectBuilder>();
288+
_services.AddSingleton<IETagGenerator, ETagGenerator>();
288289
}
289290

290291
private void AddOperationsLayer()

src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
4545
ArgumentGuard.NotNull(request, nameof(request));
4646
ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider));
4747

48+
if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerSettings))
49+
{
50+
return;
51+
}
52+
4853
RouteValueDictionary routeValues = httpContext.GetRouteData().Values;
4954
ResourceContext primaryResourceContext = CreatePrimaryResourceContext(httpContext, controllerResourceMapping, resourceContextProvider);
5055

@@ -76,6 +81,21 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
7681
await _next(httpContext);
7782
}
7883

84+
private async Task<bool> ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings)
85+
{
86+
if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch))
87+
{
88+
await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.PreconditionFailed)
89+
{
90+
Title = "Detection of mid-air edit collisions using ETags is not supported."
91+
});
92+
93+
return false;
94+
}
95+
96+
return true;
97+
}
98+
7999
private static ResourceContext CreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping,
80100
IResourceContextProvider resourceContextProvider)
81101
{
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.Linq;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using Microsoft.Net.Http.Headers;
5+
6+
namespace JsonApiDotNetCore.Serialization
7+
{
8+
/// <inheritdoc />
9+
internal sealed class ETagGenerator : IETagGenerator
10+
{
11+
private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray();
12+
13+
private static uint ToLookupEntry(int index)
14+
{
15+
string hex = index.ToString("X2");
16+
return hex[0] + ((uint)hex[1] << 16);
17+
}
18+
19+
/// <inheritdoc />
20+
public EntityTagHeaderValue Generate(string requestUrl, string responseBody)
21+
{
22+
byte[] buffer = Encoding.UTF8.GetBytes(requestUrl + "|" + responseBody);
23+
24+
using HashAlgorithm hashAlgorithm = MD5.Create();
25+
byte[] hash = hashAlgorithm.ComputeHash(buffer);
26+
27+
string eTagValue = "\"" + ByteArrayToHex(hash) + "\"";
28+
return EntityTagHeaderValue.Parse(eTagValue);
29+
}
30+
31+
// https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa
32+
private static string ByteArrayToHex(byte[] bytes)
33+
{
34+
char[] buffer = new char[bytes.Length * 2];
35+
36+
for (int index = 0; index < bytes.Length; index++)
37+
{
38+
uint value = LookupTable[bytes[index]];
39+
buffer[2 * index] = (char)value;
40+
buffer[2 * index + 1] = (char)(value >> 16);
41+
}
42+
43+
return new string(buffer);
44+
}
45+
}
46+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.Net.Http.Headers;
2+
3+
namespace JsonApiDotNetCore.Serialization
4+
{
5+
/// <summary>
6+
/// Provides generation of an ETag HTTP response header.
7+
/// </summary>
8+
public interface IETagGenerator
9+
{
10+
/// <summary>
11+
/// Generates an ETag HTTP response header value for the response to an incoming request.
12+
/// </summary>
13+
/// <param name="requestUrl">
14+
/// The incoming request URL, including query string.
15+
/// </param>
16+
/// <param name="responseBody">
17+
/// The produced response body.
18+
/// </param>
19+
/// <returns>
20+
/// The ETag, or <c>null</c> to disable saving bandwidth.
21+
/// </returns>
22+
public EntityTagHeaderValue Generate(string requestUrl, string responseBody);
23+
}
24+
}

src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.AspNetCore.Mvc;
1515
using Microsoft.AspNetCore.Mvc.Formatters;
1616
using Microsoft.Extensions.Logging;
17+
using Microsoft.Net.Http.Headers;
1718

1819
namespace JsonApiDotNetCore.Serialization
1920
{
@@ -26,16 +27,19 @@ public class JsonApiWriter : IJsonApiWriter
2627
{
2728
private readonly IJsonApiSerializer _serializer;
2829
private readonly IExceptionHandler _exceptionHandler;
30+
private readonly IETagGenerator _eTagGenerator;
2931
private readonly TraceLogWriter<JsonApiWriter> _traceWriter;
3032

31-
public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, ILoggerFactory loggerFactory)
33+
public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, IETagGenerator eTagGenerator, ILoggerFactory loggerFactory)
3234
{
3335
ArgumentGuard.NotNull(serializer, nameof(serializer));
3436
ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler));
37+
ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator));
3538
ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory));
3639

3740
_serializer = serializer;
3841
_exceptionHandler = exceptionHandler;
42+
_eTagGenerator = eTagGenerator;
3943
_traceWriter = new TraceLogWriter<JsonApiWriter>(loggerFactory);
4044
}
4145

@@ -63,6 +67,14 @@ public async Task WriteAsync(OutputFormatterWriteContext context)
6367
response.StatusCode = (int)errorDocument.GetErrorStatusCode();
6468
}
6569

70+
bool hasMatchingETag = SetETagResponseHeader(context.HttpContext.Request, response, responseContent);
71+
72+
if (hasMatchingETag)
73+
{
74+
response.StatusCode = (int)HttpStatusCode.NotModified;
75+
responseContent = string.Empty;
76+
}
77+
6678
if (context.HttpContext.Request.Method == HttpMethod.Head.Method)
6779
{
6880
responseContent = string.Empty;
@@ -122,5 +134,42 @@ private static object WrapErrors(object contextObject)
122134

123135
return contextObject;
124136
}
137+
138+
private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent)
139+
{
140+
bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method;
141+
142+
if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK)
143+
{
144+
string url = request.GetEncodedUrl();
145+
EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent);
146+
147+
if (responseETag != null)
148+
{
149+
response.Headers.Add(HeaderNames.ETag, responseETag.ToString());
150+
151+
return RequestContainsMatchingETag(request.Headers, responseETag);
152+
}
153+
}
154+
155+
return false;
156+
}
157+
158+
private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag)
159+
{
160+
if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) &&
161+
EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList<EntityTagHeaderValue> requestETags))
162+
{
163+
foreach (EntityTagHeaderValue requestETag in requestETags)
164+
{
165+
if (responseETag.Equals(requestETag))
166+
{
167+
return true;
168+
}
169+
}
170+
}
171+
172+
return false;
173+
}
125174
}
126175
}

0 commit comments

Comments
 (0)