Skip to content

Commit

Permalink
Consumer Api: Support Http ETags and If-None-Match Header (#1062)
Browse files Browse the repository at this point in the history
* feat: Support ETag and If-None-Match header

* chore: Add Header to Bruno request

* fix: Enquote hashed string to comply with schema

* feat: Add CachedApiResponse and modify GET /Tags endpoint in SDK

* chore: Add integration test for cached GET /Tags

* chore: Remove TODO (resolved)

* chore: Address comments

* chore: Typo

* chore: Use Tag list parameter

* feat: Add custom filter for easier caching

* chore: use new custom filter

* chore: Remove TODO

* chore: Remove unused MD5 hasher

* chore: Write comment referring to the StackOverflow answer and simplify the header removal

* fix: Invert filter

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
MH321Productions and mergify[bot] authored Mar 5, 2025
1 parent 8fd8a24 commit cd3612e
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 4 deletions.
4 changes: 4 additions & 0 deletions Applications/ConsumerApi/src/http/Tags/ListTags.bru
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ get {
body: none
auth: none
}

headers {
If-None-Match: lvcnNwFiRx633qFRBMM5ew==
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ User requests available Tags
Then the response status code is 200 (OK)
And the response supports the English language
And the response attributes contain tags

Scenario: Requesting the tags with the current hash
Given a list of tags l with an ETag e
And l didn't change since the last fetch
When A GET request to the /Tags endpoint gets sent with the If-None-Match header set to e
Then the response status code is 304 (Not modified)
And the response content is empty
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Backbone.BuildingBlocks.SDK.Endpoints.Common.Types;
using Backbone.ConsumerApi.Sdk.Endpoints.Tags.Types.Responses;
using Backbone.ConsumerApi.Tests.Integration.Contexts;
using Backbone.ConsumerApi.Tests.Integration.Extensions;
using Backbone.ConsumerApi.Tests.Integration.Helpers;

namespace Backbone.ConsumerApi.Tests.Integration.StepDefinitions;
Expand All @@ -11,21 +12,52 @@ public class TagsStepDefinitions
private readonly ResponseContext _responseContext;
private readonly ClientPool _clientPool;

private ApiResponse<ListTagsResponse>? _listTagsResponse;
private CachedApiResponse<ListTagsResponse>? _listTagsResponse;

public TagsStepDefinitions(ResponseContext responseContext, ClientPool clientPool)
{
_responseContext = responseContext;
_clientPool = clientPool;
}

#region Given

[Given($@"a list of tags {RegexFor.SINGLE_THING} with an ETag {RegexFor.SINGLE_THING}")]
public async Task GivenAListOfTagsWithETag(string list, string hash)
{
await WhenAGETRequestToTheTagsEndpointGetsSent();

_listTagsResponse!.Should().BeASuccess();
}

[Given($@"{RegexFor.SINGLE_THING} didn't change since the last fetch")]
public void GivenListDidntChangeSinceLastFetch(string list)
{
}

#endregion


#region When

[When("A GET request to the /Tags endpoint gets sent")]
public async Task WhenAGETRequestToTheTagsEndpointGetsSent()
{
var client = _clientPool.Anonymous;
_responseContext.WhenResponse = _listTagsResponse = await client.Tags.ListTags();
}

[When($@"A GET request to the /Tags endpoint gets sent with the If-None-Match header set to {RegexFor.SINGLE_THING}")]
public async Task WhenAGETRequestToTheTagsEndpointGetsSentWithHash(string hash)
{
var client = _clientPool.Anonymous;
_responseContext.WhenResponse = _listTagsResponse = await client.Tags.ListTags(new CacheControl { ETag = _listTagsResponse!.ETag });
}

#endregion

#region Then

[Then("the response supports the English language")]
public void AndTheResponseSupportsTheEnglishLanguage()
{
Expand All @@ -40,4 +72,13 @@ public void AndTheResponseAttributesContainTags()
attr.Should().NotBeEmpty();
}
}

[Then("the response content is empty")]
public void ThenTheResponseContentIsEmpty()
{
_listTagsResponse!.NotModified.Should().BeTrue();
_listTagsResponse!.Result.Should().BeNull();
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Net.Http.Headers;

namespace Backbone.BuildingBlocks.API.Mvc.ControllerAttributes;

/**
* This filter handles Caching via the <c>If-None-Match</c> header in the request header
* and the <c>ETag</c> in the response header. This class is based on this StackOverflow answer:
* <see href="https://stackoverflow.com/a/76151167"></see>
*/
public class HandleHttpCachingAttribute : ResultFilterAttribute
{
private static readonly string[] HEADERS_TO_KEEP_FOR304 =
[
HeaderNames.CacheControl,
HeaderNames.ContentLocation,
HeaderNames.ETag,
HeaderNames.Expires,
HeaderNames.Vary
];

public override async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
var request = context.HttpContext.Request;
var response = context.HttpContext.Response;

var originalStream = response.Body;
using MemoryStream memoryStream = new();

response.Body = memoryStream;
await next();
memoryStream.Position = 0;

if (response.StatusCode == StatusCodes.Status200OK)
{
var requestHeaders = request.GetTypedHeaders();
var responseHeaders = response.GetTypedHeaders();

responseHeaders.ETag ??= GenerateETag(memoryStream);

if (IsClientCacheValid(requestHeaders, responseHeaders))
{
response.StatusCode = StatusCodes.Status304NotModified;

foreach (var header in response.Headers.Where(h => !HEADERS_TO_KEEP_FOR304.Contains(h.Key)))
response.Headers.Remove(header.Key);

return;
}
}

await memoryStream.CopyToAsync(originalStream);
}

private static EntityTagHeaderValue GenerateETag(Stream stream)
{
var hashBytes = MD5.HashData(stream);
stream.Position = 0;
var hashString = Convert.ToBase64String(hashBytes);
return new EntityTagHeaderValue($"\"{hashString}\"");
}

private static bool IsClientCacheValid(RequestHeaders reqHeaders, ResponseHeaders resHeaders)
{
if (reqHeaders.IfNoneMatch.Any() && resHeaders.ETag is not null)
return reqHeaders.IfNoneMatch.Any(etag =>
etag.Compare(resHeaders.ETag, useStrongComparison: false)
);

if (reqHeaders.IfModifiedSince is not null && resHeaders.LastModified is not null)
return reqHeaders.IfModifiedSince >= resHeaders.LastModified;

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public class EndpointClient
}
""";

private const string EMPTY_CACHED_RESULT = "{}";

private const string EMPTY_VALUE = """
{
"value": {}
Expand Down Expand Up @@ -67,6 +69,18 @@ public async Task<ApiResponse<T>> GetUnauthenticated<T>(string url, NameValueCol
.Execute();
}

public async Task<CachedApiResponse<T>> GetCachedUnauthenticated<T>(string url, NameValueCollection? queryParameters = null, PaginationFilter? pagination = null, CacheControl? cacheControl = null)
{
var builder = Request<T>(HttpMethod.Get, url)
.WithPagination(pagination)
.AddQueryParameters(queryParameters);

if (cacheControl != null)
builder.AddExtraHeader("If-None-Match", cacheControl.ETag);

return await builder.ExecuteCached();
}

public async Task<ApiResponse<T>> Put<T>(string url, object? requestContent = null)
{
return await Request<T>(HttpMethod.Put, url)
Expand Down Expand Up @@ -117,6 +131,28 @@ private async Task<ApiResponse<T>> Execute<T>(HttpRequestMessage request)
return deserializedResponseContent;
}

private async Task<CachedApiResponse<T>> ExecuteCached<T>(HttpRequestMessage request)
{
var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStreamAsync();
var statusCode = response.StatusCode;

if (statusCode == HttpStatusCode.NotModified || responseContent.Length == 0)
{
responseContent.Close();
responseContent = new MemoryStream(Encoding.UTF8.GetBytes(EMPTY_CACHED_RESULT));
}

var deserializedResponseContent = JsonSerializer.Deserialize<CachedApiResponse<T>>(responseContent, _jsonSerializerOptions);

deserializedResponseContent!.Status = statusCode;
deserializedResponseContent.RawContent = await response.Content.ReadAsStringAsync();
deserializedResponseContent.ContentType = response.Content.Headers.ContentType?.MediaType;
deserializedResponseContent.ETag = response.Headers.ETag!.Tag;

return deserializedResponseContent;
}

private async Task<ApiResponse<T>> ExecuteOData<T>(HttpRequestMessage request)
{
var response = await _httpClient.SendAsync(request);
Expand Down Expand Up @@ -279,6 +315,11 @@ public async Task<RawApiResponse> ExecuteRaw()
return await _client.ExecuteRaw(await CreateRequestMessage());
}

public async Task<CachedApiResponse<T>> ExecuteCached()
{
return await _client.ExecuteCached<T>(await CreateRequestMessage());
}

private async Task<HttpRequestMessage> CreateRequestMessage()
{
var request = new HttpRequestMessage(_method, EncodeParametersInUrl())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Backbone.BuildingBlocks.SDK.Endpoints.Common.Types;

public class CacheControl
{
public required string ETag { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Backbone.BuildingBlocks.SDK.Endpoints.Common.Types;

public class CachedApiResponse<TResult> : ApiResponse<TResult>
{
public bool NotModified => Result == null;

public string ETag { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Backbone.BuildingBlocks.API;
using Backbone.BuildingBlocks.API;
using Backbone.BuildingBlocks.API.Mvc;
using Backbone.BuildingBlocks.API.Mvc.ControllerAttributes;
using Backbone.Modules.Tags.Application.Tags.Queries.ListTags;
using MediatR;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -19,9 +20,11 @@ public TagsController(IMediator mediator) : base(mediator)
[HttpGet]
[ProducesResponseType(typeof(HttpResponseEnvelopeResult<ListTagsResponse>), StatusCodes.Status200OK)]
[AllowAnonymous]
[HandleHttpCaching]
public async Task<IActionResult> ListTags(CancellationToken cancellationToken)
{
var response = await _mediator.Send(new ListTagsQuery(), cancellationToken);

return Ok(response);
}
}
4 changes: 2 additions & 2 deletions Sdks/ConsumerApi.Sdk/src/Endpoints/Tags/TagsEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ namespace Backbone.ConsumerApi.Sdk.Endpoints.Tags;

public class TagsEndpoint(EndpointClient client) : ConsumerApiEndpoint(client)
{
public async Task<ApiResponse<ListTagsResponse>> ListTags()
public async Task<CachedApiResponse<ListTagsResponse>> ListTags(CacheControl? cacheControl = null)
{
return await _client.GetUnauthenticated<ListTagsResponse>($"api/{API_VERSION}/Tags");
return await _client.GetCachedUnauthenticated<ListTagsResponse>($"api/{API_VERSION}/Tags", cacheControl: cacheControl);
}
}

0 comments on commit cd3612e

Please sign in to comment.