Skip to content

Adds support for ETag header in GET and HEAD requests #998

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/usage/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Caching with ETags

_since v4.2_

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.

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.
This is unrelated to JSON:API resources. Therefore, we do not use ETags for optimistic concurrency.

Getting a list of resources returns an ETag:

```http
GET /articles?sort=-lastModifiedAt HTTP/1.1
Host: localhost:5000
```

```http
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
Server: Kestrel
Transfer-Encoding: chunked
ETag: "7FFF010786E2CE8FC901896E83870E00"

{
"data": [ ... ]
}
```

The request is later resent using the received ETag. The server data has not changed at this point.

```http
GET /articles?sort=-lastModifiedAt HTTP/1.1
Host: localhost:5000
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
```

```http
HTTP/1.1 304 Not Modified
Server: Kestrel
ETag: "7FFF010786E2CE8FC901896E83870E00"
```

After some time, the server data has changed.

```http
GET /articles?sort=-lastModifiedAt HTTP/1.1
Host: localhost:5000
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
```

```http
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
Server: Kestrel
Transfer-Encoding: chunked
ETag: "356075D903B8FE8D9921201A7E7CD3F9"

{
"data": [ ... ]
}
```

Note: To just poll for changes (without fetching them), send a HEAD request instead:

```http
HEAD /articles?sort=-lastModifiedAt HTTP/1.1
Host: localhost:5000
If-None-Match: "7FFF010786E2CE8FC901896E83870E00"
```

```http
HTTP/1.1 200 OK
Server: Kestrel
ETag: "356075D903B8FE8D9921201A7E7CD3F9"
```
1 change: 1 addition & 0 deletions docs/usage/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
# [Routing](routing.md)
# [Errors](errors.md)
# [Metadata](meta.md)
# [Caching](caching.md)

# Extensibility
## [Layer Overview](extensibility/layer-overview.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ private void AddSerializationLayer()
_services.AddScoped(typeof(AtomicOperationsResponseSerializer));
_services.AddScoped(sp => sp.GetRequiredService<IJsonApiSerializerFactory>().GetSerializer());
_services.AddScoped<IResourceObjectBuilder, ResponseResourceObjectBuilder>();
_services.AddSingleton<IETagGenerator, ETagGenerator>();
}

private void AddOperationsLayer()
Expand Down
4 changes: 4 additions & 0 deletions src/JsonApiDotNetCore/Controllers/JsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,27 +42,31 @@ protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactor

/// <inheritdoc />
[HttpGet]
[HttpHead]
public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
return await base.GetAsync(cancellationToken);
}

/// <inheritdoc />
[HttpGet("{id}")]
[HttpHead("{id}")]
public override async Task<IActionResult> GetAsync(TId id, CancellationToken cancellationToken)
{
return await base.GetAsync(id, cancellationToken);
}

/// <inheritdoc />
[HttpGet("{id}/{relationshipName}")]
[HttpHead("{id}/{relationshipName}")]
public override async Task<IActionResult> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken)
{
return await base.GetSecondaryAsync(id, relationshipName, cancellationToken);
}

/// <inheritdoc />
[HttpGet("{id}/relationships/{relationshipName}")]
[HttpHead("{id}/relationships/{relationshipName}")]
public override async Task<IActionResult> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken)
{
return await base.GetRelationshipAsync(id, relationshipName, cancellationToken);
Expand Down
27 changes: 23 additions & 4 deletions src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using JetBrains.Annotations;
Expand All @@ -16,6 +15,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.Net.Http.Headers;
using Newtonsoft.Json;

namespace JsonApiDotNetCore.Middleware
Expand Down Expand Up @@ -45,8 +45,12 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
ArgumentGuard.NotNull(request, nameof(request));
ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider));

RouteValueDictionary routeValues = httpContext.GetRouteData().Values;
if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerSettings))
{
return;
}

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

if (primaryResourceContext != null)
Expand Down Expand Up @@ -77,6 +81,21 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
await _next(httpContext);
}

private async Task<bool> ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings)
{
if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch))
{
await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.PreconditionFailed)
{
Title = "Detection of mid-air edit collisions using ETags is not supported."
});

return false;
}

return true;
}

private static ResourceContext CreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping,
IResourceContextProvider resourceContextProvider)
{
Expand Down Expand Up @@ -130,7 +149,7 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a

foreach (string acceptHeader in acceptHeaders)
{
if (MediaTypeWithQualityHeaderValue.TryParse(acceptHeader, out MediaTypeWithQualityHeaderValue headerValue))
if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue headerValue))
{
headerValue.Quality = null;

Expand Down Expand Up @@ -189,7 +208,7 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri
private static void SetupResourceRequest(JsonApiRequest request, ResourceContext primaryResourceContext, RouteValueDictionary routeValues,
IJsonApiOptions options, IResourceContextProvider resourceContextProvider, HttpRequest httpRequest)
{
request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method;
request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method;
request.Kind = EndpointKind.Primary;
request.PrimaryResource = primaryResourceContext;
request.PrimaryId = GetPrimaryRequestId(routeValues);
Expand Down
46 changes: 46 additions & 0 deletions src/JsonApiDotNetCore/Serialization/ETagGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Serialization
{
/// <inheritdoc />
internal sealed class ETagGenerator : IETagGenerator
{
private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray();

private static uint ToLookupEntry(int index)
{
string hex = index.ToString("X2");
return hex[0] + ((uint)hex[1] << 16);
}

/// <inheritdoc />
public EntityTagHeaderValue Generate(string requestUrl, string responseBody)
{
byte[] buffer = Encoding.UTF8.GetBytes(requestUrl + "|" + responseBody);

using HashAlgorithm hashAlgorithm = MD5.Create();
byte[] hash = hashAlgorithm.ComputeHash(buffer);

string eTagValue = "\"" + ByteArrayToHex(hash) + "\"";
return EntityTagHeaderValue.Parse(eTagValue);
}

// https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa
private static string ByteArrayToHex(byte[] bytes)
{
char[] buffer = new char[bytes.Length * 2];

for (int index = 0; index < bytes.Length; index++)
{
uint value = LookupTable[bytes[index]];
buffer[2 * index] = (char)value;
buffer[2 * index + 1] = (char)(value >> 16);
}

return new string(buffer);
}
}
}
24 changes: 24 additions & 0 deletions src/JsonApiDotNetCore/Serialization/IETagGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Serialization
{
/// <summary>
/// Provides generation of an ETag HTTP response header.
/// </summary>
public interface IETagGenerator
{
/// <summary>
/// Generates an ETag HTTP response header value for the response to an incoming request.
/// </summary>
/// <param name="requestUrl">
/// The incoming request URL, including query string.
/// </param>
/// <param name="responseBody">
/// The produced response body.
/// </param>
/// <returns>
/// The ETag, or <c>null</c> to disable saving bandwidth.
/// </returns>
public EntityTagHeaderValue Generate(string requestUrl, string responseBody);
}
}
72 changes: 66 additions & 6 deletions src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Serialization
{
Expand All @@ -26,25 +27,28 @@ public class JsonApiWriter : IJsonApiWriter
{
private readonly IJsonApiSerializer _serializer;
private readonly IExceptionHandler _exceptionHandler;
private readonly IETagGenerator _eTagGenerator;
private readonly TraceLogWriter<JsonApiWriter> _traceWriter;

public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, ILoggerFactory loggerFactory)
public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, IETagGenerator eTagGenerator, ILoggerFactory loggerFactory)
{
ArgumentGuard.NotNull(serializer, nameof(serializer));
ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler));
ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator));
ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory));

_serializer = serializer;
_exceptionHandler = exceptionHandler;
_eTagGenerator = eTagGenerator;
_traceWriter = new TraceLogWriter<JsonApiWriter>(loggerFactory);
}

public async Task WriteAsync(OutputFormatterWriteContext context)
{
ArgumentGuard.NotNull(context, nameof(context));

HttpRequest request = context.HttpContext.Request;
HttpResponse response = context.HttpContext.Response;
response.ContentType = _serializer.ContentType;

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

string url = context.HttpContext.Request.GetEncodedUrl();
_traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for request at '{url}' with body: <<{responseContent}>>");
bool hasMatchingETag = SetETagResponseHeader(request, response, responseContent);

if (hasMatchingETag)
{
response.StatusCode = (int)HttpStatusCode.NotModified;
responseContent = string.Empty;
}

if (request.Method == HttpMethod.Head.Method)
{
responseContent = string.Empty;
}

string url = request.GetEncodedUrl();

if (!string.IsNullOrEmpty(responseContent))
{
response.ContentType = _serializer.ContentType;
}

_traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for {request.Method} request at '{url}' with body: <<{responseContent}>>");

await writer.WriteAsync(responseContent);
await writer.FlushAsync();
Expand Down Expand Up @@ -96,6 +119,11 @@ private string SerializeResponse(object contextObject, HttpStatusCode statusCode
return _serializer.Serialize(contextObjectWrapped);
}

private bool IsSuccessStatusCode(HttpStatusCode statusCode)
{
return new HttpResponseMessage(statusCode).IsSuccessStatusCode;
}

private static object WrapErrors(object contextObject)
{
if (contextObject is IEnumerable<Error> errors)
Expand All @@ -111,9 +139,41 @@ private static object WrapErrors(object contextObject)
return contextObject;
}

private bool IsSuccessStatusCode(HttpStatusCode statusCode)
private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent)
{
return new HttpResponseMessage(statusCode).IsSuccessStatusCode;
bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method;

if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK)
{
string url = request.GetEncodedUrl();
EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent);

if (responseETag != null)
{
response.Headers.Add(HeaderNames.ETag, responseETag.ToString());

return RequestContainsMatchingETag(request.Headers, responseETag);
}
}

return false;
}

private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag)
{
if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) &&
EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList<EntityTagHeaderValue> requestETags))
{
foreach (EntityTagHeaderValue requestETag in requestETags)
{
if (responseETag.Equals(requestETag))
{
return true;
}
}
}

return false;
}
}
}
Loading