Skip to content

Commit

Permalink
Added support for caching-related headers: Last-Modified, Expires, Ca…
Browse files Browse the repository at this point in the history
…che-Control

Renamed ETagEndpointBase to CachingEndpointBase
  • Loading branch information
bastianeicher committed Sep 15, 2020
1 parent 20c91a5 commit 7cb1ea5
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@
namespace TypedRest.Endpoints.Generic
{
/// <summary>
/// Base class for building endpoints that use ETags (entity tags) for caching and to avoid lost updates.
/// Base class for building endpoints that use ETags and Last-Modified timestamps for caching and to avoid lost updates.
/// </summary>
public abstract class ETagEndpointBase : EndpointBase, ICachingEndpoint
public abstract class CachingEndpointBase : EndpointBase, ICachingEndpoint
{
/// <summary>
/// Creates a new endpoint with a relative URI.
/// </summary>
/// <param name="referrer">The endpoint used to navigate to this one.</param>
/// <param name="relativeUri">The URI of this endpoint relative to the <paramref name="referrer"/>'s. Add a <c>./</c> prefix here to imply a trailing slash <paramref name="referrer"/>'s URI.</param>
protected ETagEndpointBase(IEndpoint referrer, Uri relativeUri)
protected CachingEndpointBase(IEndpoint referrer, Uri relativeUri)
: base(referrer, relativeUri)
{}

Expand All @@ -31,7 +31,7 @@ protected ETagEndpointBase(IEndpoint referrer, Uri relativeUri)
/// </summary>
/// <param name="referrer">The endpoint used to navigate to this one.</param>
/// <param name="relativeUri">The URI of this endpoint relative to the <paramref name="referrer"/>'s. Add a <c>./</c> prefix here to imply a trailing slash <paramref name="referrer"/>'s URI.</param>
protected ETagEndpointBase(IEndpoint referrer, string relativeUri)
protected CachingEndpointBase(IEndpoint referrer, string relativeUri)
: base(referrer, relativeUri)
{}

Expand All @@ -52,17 +52,17 @@ protected async Task<HttpContent> GetContentAsync(CancellationToken cancellation
{
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
var cache = ResponseCache; // Copy reference for thread-safety
if (cache?.ETag != null) request.Headers.IfNoneMatch.Add(cache.ETag);
cache?.SetIfModifiedHeaders(request.Headers);

var response = await HttpClient.SendAsync(request, cancellationToken).NoContext();
if (response.StatusCode == HttpStatusCode.NotModified && cache != null)
if (response.StatusCode == HttpStatusCode.NotModified && cache != null && !cache.IsExpired)
return cache.GetContent();
else
{
await HandleAsync(() => Task.FromResult(response), caller).NoContext();
if (response.Content == null) throw new KeyNotFoundException($"{Uri} returned no body.");

ResponseCache = new ResponseCache(response);
ResponseCache = ResponseCache.From(response);
return response.Content;
}
}
Expand All @@ -84,7 +84,7 @@ protected Task<HttpResponseMessage> PutContentAsync(HttpContent content, Cancell
{
var request = new HttpRequestMessage(HttpMethod.Put, Uri) {Content = content};
var cache = ResponseCache; // Copy reference for thread-safety
if (cache?.ETag != null) request.Headers.IfMatch.Add(cache.ETag);
cache?.SetIfUnmodifiedHeaders(request.Headers);

ResponseCache = null;
return HandleAsync(() => HttpClient.SendAsync(request, cancellationToken), caller);
Expand All @@ -106,7 +106,7 @@ protected Task<HttpResponseMessage> DeleteContentAsync(CancellationToken cancell
{
var request = new HttpRequestMessage(HttpMethod.Delete, Uri);
var cache = ResponseCache; // Copy reference for thread-safety
if (cache?.ETag != null) request.Headers.IfMatch.Add(cache.ETag);
cache?.SetIfUnmodifiedHeaders(request.Headers);

ResponseCache = null;
return HandleAsync(() => HttpClient.SendAsync(request, cancellationToken), caller);
Expand Down
4 changes: 2 additions & 2 deletions src/TypedRest/Endpoints/Generic/CollectionEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace TypedRest.Endpoints.Generic
/// </summary>
/// <typeparam name="TEntity">The type of individual elements in the collection.</typeparam>
/// <typeparam name="TElementEndpoint">The type of <see cref="IElementEndpoint{TEntity}"/> to provide for individual <typeparamref name="TEntity"/>s. Must have a public constructor with an <see cref="IEndpoint"/> and an <see cref="Uri"/> or string parameter.</typeparam>
public class CollectionEndpoint<TEntity, TElementEndpoint> : ETagEndpointBase, ICollectionEndpoint<TEntity, TElementEndpoint>
public class CollectionEndpoint<TEntity, TElementEndpoint> : CachingEndpointBase, ICollectionEndpoint<TEntity, TElementEndpoint>
where TEntity : class
where TElementEndpoint : IElementEndpoint<TEntity>
{
Expand Down Expand Up @@ -123,7 +123,7 @@ public virtual async ITask<TElementEndpoint> CreateAsync(TEntity entity, Cancell
? this[await response.Content.ReadAsAsync<TEntity>(cancellationToken)]
: _getElementEndpoint(this, response.Headers.Location);
if (response.Content != null && elementEndpoint is ICachingEndpoint caching)
caching.ResponseCache = new ResponseCache(response);
caching.ResponseCache = ResponseCache.From(response);
return elementEndpoint;
}

Expand Down
2 changes: 1 addition & 1 deletion src/TypedRest/Endpoints/Generic/ElementEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace TypedRest.Endpoints.Generic
/// Endpoint for an individual resource.
/// </summary>
/// <typeparam name="TEntity">The type of entity the endpoint represents.</typeparam>
public class ElementEndpoint<TEntity> : ETagEndpointBase, IElementEndpoint<TEntity>
public class ElementEndpoint<TEntity> : CachingEndpointBase, IElementEndpoint<TEntity>
where TEntity : class
{
/// <summary>
Expand Down
73 changes: 59 additions & 14 deletions src/TypedRest/Http/ResponseCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,84 @@
namespace TypedRest.Http
{
/// <summary>
/// Caches the content of an <see cref="HttpResponseMessage"/>.
/// Captures the content of an <see cref="HttpResponseMessage"/> for caching.
/// </summary>
public class ResponseCache
{
private readonly byte[] _content;
private readonly MediaTypeHeaderValue? _contentType;
private readonly EntityTagHeaderValue? _eTag;
private readonly DateTimeOffset? _lastModified;
private readonly DateTimeOffset? _expires;

/// <summary>
/// The ETag header value of the cached <see cref="HttpResponseMessage"/>.
/// Creates a <see cref="ResponseCache"/> from a <paramref name="response"/> if it is eligible for caching.
/// </summary>
public EntityTagHeaderValue? ETag { get; }
/// <returns>The <see cref="ResponseCache"/>; <c>null</c> if the response is not eligible for caching.</returns>
public static ResponseCache? From(HttpResponseMessage response)
=> response.IsSuccessStatusCode && !(response.Headers.CacheControl?.NoStore ?? false)
? new ResponseCache(response)
: null;

/// <summary>
/// Caches the content of the <paramref name="response"/>.
/// </summary>
public ResponseCache(HttpResponseMessage response)
private ResponseCache(HttpResponseMessage response)
{
if (response.Content == null) throw new ArgumentException("Missing content.", nameof(response));
_content = response.Content.ReadAsByteArrayAsync().Result;
_content = ReadContent(response.Content ?? throw new ArgumentException("Missing content.", nameof(response)));

_contentType = response.Content.Headers.ContentType;
_eTag = response.Headers.ETag;
_lastModified = response.Content.Headers.LastModified;

_expires = response.Content.Headers.Expires;
if (_expires == null && response.Headers.CacheControl?.MaxAge != null)
_expires = DateTimeOffset.Now + response.Headers.CacheControl.MaxAge;

// Treat no-cache as expired immediately
if (response.Headers.CacheControl?.NoCache ?? false)
_expires = DateTimeOffset.Now;
}

// Rewind stream position
var stream = response.Content.ReadAsStreamAsync().Result;
private static byte[] ReadContent(HttpContent content)
{
var result = content.ReadAsByteArrayAsync().Result;

// Rewind stream if possible
var stream = content.ReadAsStreamAsync().Result;
if (stream.CanSeek) stream.Position = 0;

_contentType = response.Content.Headers.ContentType;
ETag = response.Headers.ETag;
return result;
}

/// <summary>
/// Indicates whether this cached response has expired.
/// </summary>
public bool IsExpired
=> _expires.HasValue && DateTime.Now >= _expires;

/// <summary>
/// Returns the cached <see cref="HttpClient"/>.
/// </summary>
public HttpContent GetContent()
{
// Build new response for each request to avoid shared Stream.Position
=> new ByteArrayContent(_content) {Headers = {ContentType = _contentType}};
return new ByteArrayContent(_content) {Headers = {ContentType = _contentType}};
}

/// <summary>
/// Sets request headers that require that the resource has been modified since it was cached.
/// </summary>
public void SetIfModifiedHeaders(HttpRequestHeaders headers)
{
if (_eTag != null) headers.IfNoneMatch.Add(_eTag);
else if (_lastModified != null) headers.IfModifiedSince = _lastModified;
}

/// <summary>
/// Sets request headers that require that the resource has not been modified since it was cached.
/// </summary>
public void SetIfUnmodifiedHeaders(HttpRequestHeaders headers)
{
if (_eTag != null) headers.IfMatch.Add(_eTag);
else if (_lastModified != null) headers.IfUnmodifiedSince = _lastModified;
}
}
}
46 changes: 45 additions & 1 deletion src/UnitTests/Endpoints/Generic/ElementEndpointTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public async Task TestReadCustomMimeWithJsonSuffix()
}

[Fact]
public async Task TestReadCache()
public async Task TestReadCacheETag()
{
Mock.Expect(HttpMethod.Get, "http://localhost/endpoint")
.Respond(_ => new HttpResponseMessage
Expand All @@ -63,6 +63,30 @@ public async Task TestReadCache()
because: "Cache responses, not deserialized objects");
}

[Fact]
public async Task TestReadCacheLastModified()
{
Mock.Expect(HttpMethod.Get, "http://localhost/endpoint")
.Respond(_ => new HttpResponseMessage
{
Content = new StringContent("{\"id\":5,\"name\":\"test\"}", Encoding.UTF8, JsonMime)
{
Headers = {LastModified = new DateTimeOffset(new DateTime(2015, 10, 21), TimeSpan.Zero)}
}
});
var result1 = await _endpoint.ReadAsync();
result1.Should().Be(new MockEntity(5, "test"));

Mock.Expect(HttpMethod.Get, "http://localhost/endpoint")
.WithHeaders("If-Modified-Since", "Wed, 21 Oct 2015 00:00:00 GMT")
.Respond(HttpStatusCode.NotModified);
var result2 = await _endpoint.ReadAsync();
result2.Should().Be(new MockEntity(5, "test"));

result2.Should().NotBeSameAs(result1,
because: "Cache responses, not deserialized objects");
}

[Fact]
public async Task TestExistsTrue()
{
Expand Down Expand Up @@ -123,6 +147,26 @@ public async Task TestSetETag()
await _endpoint.SetAsync(result);
}

[Fact]
public async Task TestSetLastModified()
{
Mock.Expect(HttpMethod.Get, "http://localhost/endpoint")
.Respond(_ => new HttpResponseMessage
{
Content = new StringContent("{\"id\":5,\"name\":\"test\"}", Encoding.UTF8, JsonMime)
{
Headers = {LastModified = new DateTimeOffset(new DateTime(2015, 10, 21), TimeSpan.Zero)}
}
});
var result = await _endpoint.ReadAsync();

Mock.Expect(HttpMethod.Put, "http://localhost/endpoint")
.WithContent("{\"id\":5,\"name\":\"test\"}")
.WithHeaders("If-Unmodified-Since", "Wed, 21 Oct 2015 00:00:00 GMT")
.Respond(HttpStatusCode.NoContent);
await _endpoint.SetAsync(result);
}

[Fact]
public async Task TestUpdateRetry()
{
Expand Down

0 comments on commit 7cb1ea5

Please sign in to comment.