diff --git a/docs/usage/caching.md b/docs/usage/caching.md new file mode 100644 index 0000000000..f1b04e64bd --- /dev/null +++ b/docs/usage/caching.md @@ -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" +``` diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 82ba96a42f..4b531a1468 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -21,6 +21,7 @@ # [Routing](routing.md) # [Errors](errors.md) # [Metadata](meta.md) +# [Caching](caching.md) # Extensibility ## [Layer Overview](extensibility/layer-overview.md) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 7907acc67b..eaabe44be3 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -285,6 +285,7 @@ private void AddSerializationLayer() _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); + _services.AddSingleton(); } private void AddOperationsLayer() diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 8872e5447d..a88c276a0b 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -42,6 +42,7 @@ protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactor /// [HttpGet] + [HttpHead] public override async Task GetAsync(CancellationToken cancellationToken) { return await base.GetAsync(cancellationToken); @@ -49,6 +50,7 @@ public override async Task GetAsync(CancellationToken cancellatio /// [HttpGet("{id}")] + [HttpHead("{id}")] public override async Task GetAsync(TId id, CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); @@ -56,6 +58,7 @@ public override async Task GetAsync(TId id, CancellationToken can /// [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); @@ -63,6 +66,7 @@ public override async Task GetSecondaryAsync(TId id, string relat /// [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 453acd466b..bfb486ed68 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -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; @@ -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 @@ -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) @@ -77,6 +81,21 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin await _next(httpContext); } + private async Task 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) { @@ -130,7 +149,7 @@ private static async Task 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; @@ -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); diff --git a/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs new file mode 100644 index 0000000000..a16b8dc1cd --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs @@ -0,0 +1,46 @@ +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Serialization +{ + /// + 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); + } + + /// + 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); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs new file mode 100644 index 0000000000..5aa3abf759 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs @@ -0,0 +1,24 @@ +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Provides generation of an ETag HTTP response header. + /// + public interface IETagGenerator + { + /// + /// Generates an ETag HTTP response header value for the response to an incoming request. + /// + /// + /// The incoming request URL, including query string. + /// + /// + /// The produced response body. + /// + /// + /// The ETag, or null to disable saving bandwidth. + /// + public EntityTagHeaderValue Generate(string requestUrl, string responseBody); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs index 71f507e33e..fe5463def9 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs @@ -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 { @@ -26,16 +27,19 @@ public class JsonApiWriter : IJsonApiWriter { private readonly IJsonApiSerializer _serializer; private readonly IExceptionHandler _exceptionHandler; + private readonly IETagGenerator _eTagGenerator; private readonly TraceLogWriter _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(loggerFactory); } @@ -43,8 +47,8 @@ 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; @@ -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(); @@ -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 errors) @@ -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 requestETags)) + { + foreach (EntityTagHeaderValue requestETag in requestETags) + { + if (responseETag.Equals(requestETag)) + { + return true; + } + } + } + + return false; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 107464ba36..8d316708e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -30,10 +31,8 @@ public async Task Permits_no_Accept_headers() // Arrange const string route = "/policies"; - var acceptHeaders = new MediaTypeWithQualityHeaderValue[0]; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -65,10 +64,8 @@ public async Task Permits_no_Accept_headers_at_operations_endpoint() const string route = "/operations"; const string contentType = HeaderConstants.AtomicOperationsMediaType; - var acceptHeaders = new MediaTypeWithQualityHeaderValue[0]; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -80,14 +77,14 @@ public async Task Permits_global_wildcard_in_Accept_headers() // Arrange const string route = "/policies"; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse("text/html"), - MediaTypeWithQualityHeaderValue.Parse("*/*") + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); }; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -99,14 +96,14 @@ public async Task Permits_application_wildcard_in_Accept_headers() // Arrange const string route = "/policies"; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8"), - MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2") + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2")); }; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -118,17 +115,17 @@ public async Task Permits_JsonApi_without_parameters_in_Accept_headers() // Arrange const string route = "/policies"; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse("text/html"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; q=0.3") + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; q=0.3")); }; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -160,17 +157,17 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head const string route = "/operations"; const string contentType = HeaderConstants.AtomicOperationsMediaType; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse("text/html"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + ";ext=\"https://jsonapi.org/ext/atomic\"; q=0.2") + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + ";ext=\"https://jsonapi.org/ext/atomic\"; q=0.2")); }; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -182,17 +179,17 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() // Arrange const string route = "/policies"; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse("text/html"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected"), - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType) + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType + "; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType)); }; // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route, acceptHeaders); + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); @@ -231,14 +228,14 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() const string route = "/operations"; const string contentType = HeaderConstants.AtomicOperationsMediaType; - MediaTypeWithQualityHeaderValue[] acceptHeaders = + Action setRequestHeaders = headers => { - MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType) + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); }; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType, acceptHeaders); + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs index 11311d5ebf..cc9ab85ce4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -99,7 +99,7 @@ public async Task Logs_response_body_at_Trace_level() loggerFactory.Logger.Messages.Should().NotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Sending 200 response for request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/ETagTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/ETagTests.cs new file mode 100644 index 0000000000..63f1234fec --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/ETagTests.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class ETagTests : IClassFixture, SerializationDbContext>> + { + private readonly ExampleIntegrationTestContext, SerializationDbContext> _testContext; + private readonly SerializationFakers _fakers = new SerializationFakers(); + + public ETagTests(ExampleIntegrationTestContext, SerializationDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Returns_ETag_for_HEAD_request() + { + // Arrange + List meetings = _fakers.Meeting.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Meetings.AddRange(meetings); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/meetings"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteHeadAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + httpResponse.Headers.ETag.Should().NotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Returns_ETag_for_GET_request() + { + // Arrange + List meetings = _fakers.Meeting.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Meetings.AddRange(meetings); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/meetings"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + httpResponse.Headers.ETag.Should().NotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + + responseDocument.Should().NotBeEmpty(); + } + + [Fact] + public async Task Returns_no_ETag_for_failed_GET_request() + { + // Arrange + const string route = "/meetings/99999999"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + httpResponse.Headers.ETag.Should().BeNull(); + + responseDocument.Should().NotBeEmpty(); + } + + [Fact] + public async Task Returns_no_ETag_for_POST_request() + { + // Arrange + string newTitle = _fakers.Meeting.Generate().Title; + + var requestBody = new + { + data = new + { + type = "meetings", + attributes = new + { + title = newTitle + } + } + }; + + const string route = "/meetings"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + httpResponse.Headers.ETag.Should().BeNull(); + + responseDocument.Should().NotBeEmpty(); + } + + [Fact] + public async Task Fails_on_ETag_in_PATCH_request() + { + // Arrange + Meeting existingMeeting = _fakers.Meeting.Generate(); + + string newTitle = _fakers.Meeting.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Meetings.Add(existingMeeting); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "meetings", + id = existingMeeting.StringId, + attributes = new + { + title = newTitle + } + } + }; + + string route = "/meetings/" + existingMeeting.StringId; + + Action setRequestHeaders = headers => + { + headers.IfMatch.ParseAdd("\"12345\""); + }; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = + await _testContext.ExecutePatchAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.PreconditionFailed); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + error.Title.Should().Be("Detection of mid-air edit collisions using ETags is not supported."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Returns_NotModified_for_matching_ETag() + { + // Arrange + List meetings = _fakers.Meeting.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Meetings.AddRange(meetings); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/meetings"; + + (HttpResponseMessage httpResponse1, _) = await _testContext.ExecuteGetAsync(route); + + string responseETag = httpResponse1.Headers.ETag.Tag; + + Action setRequestHeaders2 = headers => + { + headers.IfNoneMatch.ParseAdd("\"12345\", W/\"67890\", " + responseETag); + }; + + // Act + (HttpResponseMessage httpResponse2, string responseDocument2) = await _testContext.ExecuteGetAsync(route, setRequestHeaders2); + + // Assert + httpResponse2.Should().HaveStatusCode(HttpStatusCode.NotModified); + + httpResponse2.Headers.ETag.Should().NotBeNull(); + httpResponse2.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse2.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + + responseDocument2.Should().BeEmpty(); + } + + [Fact] + public async Task Returns_content_for_mismatching_ETag() + { + // Arrange + List meetings = _fakers.Meeting.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Meetings.AddRange(meetings); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/meetings"; + + Action setRequestHeaders = headers => + { + headers.IfNoneMatch.ParseAdd("\"Not-a-matching-value\""); + }; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + httpResponse.Headers.ETag.Should().NotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + + responseDocument.Should().NotBeEmpty(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs index c9e5c821d7..02a0e5e84a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -39,6 +39,44 @@ public SerializationTests(ExampleIntegrationTestContext + { + dbContext.Meetings.Add(meeting); + await dbContext.SaveChangesAsync(); + }); + + string route = "/meetings/" + meeting.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteHeadAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeEmpty(); + } + + [Fact] + public async Task Returns_no_body_for_failed_HEAD_request() + { + // Arrange + const string route = "/meetings/99999999"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteHeadAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Should().BeEmpty(); + } + [Fact] public async Task Can_get_primary_resources_with_include() { diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index fbef01f42b..86dc7d4fb9 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; @@ -15,39 +14,44 @@ public abstract class IntegrationTest { private static readonly IntegrationTestConfiguration IntegrationTestConfiguration = new IntegrationTestConfiguration(); + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, + Action setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); + } + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, - IEnumerable acceptHeaders = null) + Action setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, IEnumerable acceptHeaders = null) + object requestBody, string contentType = HeaderConstants.MediaType, Action setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, acceptHeaders); + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, - IEnumerable acceptHeaders = null) + object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, Action setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, acceptHeaders); + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, IEnumerable acceptHeaders = null) + object requestBody, string contentType = HeaderConstants.MediaType, Action setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, acceptHeaders); + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, - object requestBody = null, string contentType = HeaderConstants.MediaType, IEnumerable acceptHeaders = null) + object requestBody = null, string contentType = HeaderConstants.MediaType, Action setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, acceptHeaders); + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, setRequestHeaders); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, - string requestUrl, object requestBody, string contentType, IEnumerable acceptHeaders) + string requestUrl, object requestBody, string contentType, Action setRequestHeaders) { using var request = new HttpRequestMessage(method, requestUrl); string requestText = SerializeRequest(requestBody); @@ -63,13 +67,7 @@ public abstract class IntegrationTest } } - if (acceptHeaders != null) - { - foreach (MediaTypeWithQualityHeaderValue acceptHeader in acceptHeaders) - { - request.Headers.Accept.Add(acceptHeader); - } - } + setRequestHeaders?.Invoke(request.Headers); using HttpClient client = CreateClient(); HttpResponseMessage responseMessage = await client.SendAsync(request); diff --git a/test/UnitTests/Middleware/JsonApiRequestTests.cs b/test/UnitTests/Middleware/JsonApiRequestTests.cs index 7bd2ffcbb5..5e2baeadaa 100644 --- a/test/UnitTests/Middleware/JsonApiRequestTests.cs +++ b/test/UnitTests/Middleware/JsonApiRequestTests.cs @@ -18,6 +18,12 @@ namespace UnitTests.Middleware public sealed class JsonApiRequestTests { [Theory] + [InlineData("HEAD", "/articles", true, EndpointKind.Primary, true)] + [InlineData("HEAD", "/articles/1", false, EndpointKind.Primary, true)] + [InlineData("HEAD", "/articles/1/author", false, EndpointKind.Secondary, true)] + [InlineData("HEAD", "/articles/1/tags", true, EndpointKind.Secondary, true)] + [InlineData("HEAD", "/articles/1/relationships/author", false, EndpointKind.Relationship, true)] + [InlineData("HEAD", "/articles/1/relationships/tags", true, EndpointKind.Relationship, true)] [InlineData("GET", "/articles", true, EndpointKind.Primary, true)] [InlineData("GET", "/articles/1", false, EndpointKind.Primary, true)] [InlineData("GET", "/articles/1/author", false, EndpointKind.Secondary, true)]