Skip to content

Commit 7008d2e

Browse files
author
Bart Koelman
authored
Adds support for ETag header in GET and HEAD requests (#998)
Adds support for HEAD requests and ETags to cache read-only requests
1 parent b387363 commit 7008d2e

File tree

14 files changed

+589
-69
lines changed

14 files changed

+589
-69
lines changed

docs/usage/caching.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Caching with ETags
2+
3+
_since v4.2_
4+
5+
GET requests return an [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) HTTP header, which can be used by the client in subsequent requests to save network bandwidth.
6+
7+
Be aware that the returned ETag represents the entire response body (a 'resource' in HTTP terminology) for a request URL that includes the query string.
8+
This is unrelated to JSON:API resources. Therefore, we do not use ETags for optimistic concurrency.
9+
10+
Getting a list of resources returns an ETag:
11+
12+
```http
13+
GET /articles?sort=-lastModifiedAt HTTP/1.1
14+
Host: localhost:5000
15+
```
16+
17+
```http
18+
HTTP/1.1 200 OK
19+
Content-Type: application/vnd.api+json
20+
Server: Kestrel
21+
Transfer-Encoding: chunked
22+
ETag: "7FFF010786E2CE8FC901896E83870E00"
23+
24+
{
25+
"data": [ ... ]
26+
}
27+
```
28+
29+
The request is later resent using the received ETag. The server data has not changed at this point.
30+
31+
```http
32+
GET /articles?sort=-lastModifiedAt HTTP/1.1
33+
Host: localhost:5000
34+
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
35+
```
36+
37+
```http
38+
HTTP/1.1 304 Not Modified
39+
Server: Kestrel
40+
ETag: "7FFF010786E2CE8FC901896E83870E00"
41+
```
42+
43+
After some time, the server data has changed.
44+
45+
```http
46+
GET /articles?sort=-lastModifiedAt HTTP/1.1
47+
Host: localhost:5000
48+
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
49+
```
50+
51+
```http
52+
HTTP/1.1 200 OK
53+
Content-Type: application/vnd.api+json
54+
Server: Kestrel
55+
Transfer-Encoding: chunked
56+
ETag: "356075D903B8FE8D9921201A7E7CD3F9"
57+
58+
{
59+
"data": [ ... ]
60+
}
61+
```
62+
63+
Note: To just poll for changes (without fetching them), send a HEAD request instead:
64+
65+
```http
66+
HEAD /articles?sort=-lastModifiedAt HTTP/1.1
67+
Host: localhost:5000
68+
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
69+
```
70+
71+
```http
72+
HTTP/1.1 200 OK
73+
Server: Kestrel
74+
ETag: "356075D903B8FE8D9921201A7E7CD3F9"
75+
```

docs/usage/toc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# [Routing](routing.md)
2222
# [Errors](errors.md)
2323
# [Metadata](meta.md)
24+
# [Caching](caching.md)
2425

2526
# Extensibility
2627
## [Layer Overview](extensibility/layer-overview.md)

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/Controllers/JsonApiController.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,27 +42,31 @@ protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactor
4242

4343
/// <inheritdoc />
4444
[HttpGet]
45+
[HttpHead]
4546
public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
4647
{
4748
return await base.GetAsync(cancellationToken);
4849
}
4950

5051
/// <inheritdoc />
5152
[HttpGet("{id}")]
53+
[HttpHead("{id}")]
5254
public override async Task<IActionResult> GetAsync(TId id, CancellationToken cancellationToken)
5355
{
5456
return await base.GetAsync(id, cancellationToken);
5557
}
5658

5759
/// <inheritdoc />
5860
[HttpGet("{id}/{relationshipName}")]
61+
[HttpHead("{id}/{relationshipName}")]
5962
public override async Task<IActionResult> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken)
6063
{
6164
return await base.GetSecondaryAsync(id, relationshipName, cancellationToken);
6265
}
6366

6467
/// <inheritdoc />
6568
[HttpGet("{id}/relationships/{relationshipName}")]
69+
[HttpHead("{id}/relationships/{relationshipName}")]
6670
public override async Task<IActionResult> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken)
6771
{
6872
return await base.GetRelationshipAsync(id, relationshipName, cancellationToken);

src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.Linq;
55
using System.Net;
66
using System.Net.Http;
7-
using System.Net.Http.Headers;
87
using System.Text;
98
using System.Threading.Tasks;
109
using JetBrains.Annotations;
@@ -16,6 +15,7 @@
1615
using Microsoft.AspNetCore.Mvc;
1716
using Microsoft.AspNetCore.Mvc.Controllers;
1817
using Microsoft.AspNetCore.Routing;
18+
using Microsoft.Net.Http.Headers;
1919
using Newtonsoft.Json;
2020

2121
namespace JsonApiDotNetCore.Middleware
@@ -45,8 +45,12 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
4545
ArgumentGuard.NotNull(request, nameof(request));
4646
ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider));
4747

48-
RouteValueDictionary routeValues = httpContext.GetRouteData().Values;
48+
if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerSettings))
49+
{
50+
return;
51+
}
4952

53+
RouteValueDictionary routeValues = httpContext.GetRouteData().Values;
5054
ResourceContext primaryResourceContext = CreatePrimaryResourceContext(httpContext, controllerResourceMapping, resourceContextProvider);
5155

5256
if (primaryResourceContext != null)
@@ -77,6 +81,21 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
7781
await _next(httpContext);
7882
}
7983

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+
8099
private static ResourceContext CreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping,
81100
IResourceContextProvider resourceContextProvider)
82101
{
@@ -130,7 +149,7 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
130149

131150
foreach (string acceptHeader in acceptHeaders)
132151
{
133-
if (MediaTypeWithQualityHeaderValue.TryParse(acceptHeader, out MediaTypeWithQualityHeaderValue headerValue))
152+
if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue headerValue))
134153
{
135154
headerValue.Quality = null;
136155

@@ -189,7 +208,7 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri
189208
private static void SetupResourceRequest(JsonApiRequest request, ResourceContext primaryResourceContext, RouteValueDictionary routeValues,
190209
IJsonApiOptions options, IResourceContextProvider resourceContextProvider, HttpRequest httpRequest)
191210
{
192-
request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method;
211+
request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method;
193212
request.Kind = EndpointKind.Primary;
194213
request.PrimaryResource = primaryResourceContext;
195214
request.PrimaryId = GetPrimaryRequestId(routeValues);
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: 66 additions & 6 deletions
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,25 +27,28 @@ 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

4246
public async Task WriteAsync(OutputFormatterWriteContext context)
4347
{
4448
ArgumentGuard.NotNull(context, nameof(context));
4549

50+
HttpRequest request = context.HttpContext.Request;
4651
HttpResponse response = context.HttpContext.Response;
47-
response.ContentType = _serializer.ContentType;
4852

4953
await using TextWriter writer = context.WriterFactory(response.Body, Encoding.UTF8);
5054
string responseContent;
@@ -63,8 +67,27 @@ public async Task WriteAsync(OutputFormatterWriteContext context)
6367
response.StatusCode = (int)errorDocument.GetErrorStatusCode();
6468
}
6569

66-
string url = context.HttpContext.Request.GetEncodedUrl();
67-
_traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for request at '{url}' with body: <<{responseContent}>>");
70+
bool hasMatchingETag = SetETagResponseHeader(request, response, responseContent);
71+
72+
if (hasMatchingETag)
73+
{
74+
response.StatusCode = (int)HttpStatusCode.NotModified;
75+
responseContent = string.Empty;
76+
}
77+
78+
if (request.Method == HttpMethod.Head.Method)
79+
{
80+
responseContent = string.Empty;
81+
}
82+
83+
string url = request.GetEncodedUrl();
84+
85+
if (!string.IsNullOrEmpty(responseContent))
86+
{
87+
response.ContentType = _serializer.ContentType;
88+
}
89+
90+
_traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for {request.Method} request at '{url}' with body: <<{responseContent}>>");
6891

6992
await writer.WriteAsync(responseContent);
7093
await writer.FlushAsync();
@@ -96,6 +119,11 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode
96119
return _serializer.Serialize(contextObjectWrapped);
97120
}
98121

122+
private bool IsSuccessStatusCode(HttpStatusCode statusCode)
123+
{
124+
return new HttpResponseMessage(statusCode).IsSuccessStatusCode;
125+
}
126+
99127
private static object WrapErrors(object contextObject)
100128
{
101129
if (contextObject is IEnumerable<Error> errors)
@@ -111,9 +139,41 @@ private static object WrapErrors(object contextObject)
111139
return contextObject;
112140
}
113141

114-
private bool IsSuccessStatusCode(HttpStatusCode statusCode)
142+
private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent)
115143
{
116-
return new HttpResponseMessage(statusCode).IsSuccessStatusCode;
144+
bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method;
145+
146+
if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK)
147+
{
148+
string url = request.GetEncodedUrl();
149+
EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent);
150+
151+
if (responseETag != null)
152+
{
153+
response.Headers.Add(HeaderNames.ETag, responseETag.ToString());
154+
155+
return RequestContainsMatchingETag(request.Headers, responseETag);
156+
}
157+
}
158+
159+
return false;
160+
}
161+
162+
private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag)
163+
{
164+
if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) &&
165+
EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList<EntityTagHeaderValue> requestETags))
166+
{
167+
foreach (EntityTagHeaderValue requestETag in requestETags)
168+
{
169+
if (responseETag.Equals(requestETag))
170+
{
171+
return true;
172+
}
173+
}
174+
}
175+
176+
return false;
117177
}
118178
}
119179
}

0 commit comments

Comments
 (0)