[feat] Add Response caching

This commit is contained in:
Yankai
2023-02-07 17:35:54 +00:00
committed by GitHub
parent 00b89daa23
commit 66587ee0d1
15 changed files with 879 additions and 5 deletions

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Net;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Octokit.Caching;
using Octokit.Internal;
using Octokit.Tests.Helpers;
using Xunit;
namespace Octokit.Tests.Caching
{
public class CachedResponseTests
{
public class V1
{
public class TheToResponseMethod
{
[Fact]
public void CreatesResponseWithSameProperties()
{
var body = new object();
var headers = new Dictionary<string, string>();
var apiInfo = new ApiInfo(new Dictionary<string, Uri>(), new List<string>(), new List<string>(), "etag", new RateLimit());
const HttpStatusCode httpStatusCode = HttpStatusCode.OK;
const string contentType = "content-type";
var v1 = new CachedResponse.V1(body, headers, apiInfo, httpStatusCode, contentType);
var response = v1.ToResponse();
Assert.Equal(new Response(httpStatusCode, body, headers, contentType), response, new ResponseComparer());
}
}
public class TheCreateMethod
{
[Fact]
public void EnsuresNonNullArguments()
{
Assert.Throws<ArgumentNullException>(() => CachedResponse.V1.Create(null));
}
[Fact]
public void EnsuresNonNullResponseHeader()
{
var response = Substitute.For<IResponse>();
response.Headers.ReturnsNull();
Assert.Throws<ArgumentNullException>(() => CachedResponse.V1.Create(response));
}
[Fact]
public void CreatesV1WithSameProperties()
{
var response = Substitute.For<IResponse>();
response.Headers.Returns(new Dictionary<string, string>());
var v1 = CachedResponse.V1.Create(response);
Assert.Equal(response.Body, v1.Body);
Assert.Equal(response.Headers, v1.Headers);
Assert.Equal(response.ApiInfo, v1.ApiInfo);
Assert.Equal(response.StatusCode, v1.StatusCode);
Assert.Equal(response.ContentType, v1.ContentType);
}
}
public class TheCtor
{
[Fact]
public void EnsuresNonNullHeaders()
{
Assert.Throws<ArgumentNullException>(() => new CachedResponse.V1("body", null, new ApiInfo(new Dictionary<string, Uri>(), new List<string>(), new List<string>(), "etag", new RateLimit()), HttpStatusCode.OK, "content-type"));
}
[Fact]
public void AllowsParametersOtherThanHeadersToBeNull()
{
new CachedResponse.V1(null, new Dictionary<string, string>(), null, HttpStatusCode.OK, null);
}
[Fact]
public void SetsProperties()
{
var body = new object();
var headers = new Dictionary<string, string>();
var apiInfo = new ApiInfo(new Dictionary<string, Uri>(), new List<string>(), new List<string>(), "etag", new RateLimit());
const HttpStatusCode httpStatusCode = HttpStatusCode.OK;
const string contentType = "content-type";
var v1 = new CachedResponse.V1(body, headers, apiInfo, httpStatusCode, contentType);
Assert.Equal(body, v1.Body);
Assert.Equal(headers, v1.Headers);
Assert.Equal(apiInfo, v1.ApiInfo);
Assert.Equal(httpStatusCode, v1.StatusCode);
Assert.Equal(contentType, v1.ContentType);
}
}
}
}
}

View File

@@ -0,0 +1,331 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using NSubstitute.ReturnsExtensions;
using Octokit.Caching;
using Octokit.Internal;
using Octokit.Tests.Helpers;
using Xunit;
namespace Octokit.Tests.Caching
{
public class CachingHttpClientTests
{
public class TheSendMethod
{
[Fact]
public void EnsuresNonNullArguments()
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act + assert
Assert.ThrowsAsync<ArgumentNullException>(() => cachingHttpClient.Send(null, CancellationToken.None));
}
[Theory]
[MemberData(nameof(NonGetHttpMethods))]
public async Task CallsUnderlyingHttpClientMethodForNonGetRequests(HttpMethod method)
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var request = Substitute.For<IRequest>();
var cancellationToken = CancellationToken.None;
var expectedResponse = Substitute.For<IResponse>();
request.Method.Returns(method);
underlyingClient.Send(request, cancellationToken).Returns(expectedResponse);
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, CancellationToken.None);
// assert
Assert.Equal(expectedResponse, response);
Assert.Empty(responseCache.ReceivedCalls());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
}
public static IEnumerable<object[]> NonGetHttpMethods()
{
yield return new object[] { HttpMethod.Delete };
yield return new object[] { HttpMethod.Post };
yield return new object[] { HttpMethod.Put };
yield return new object[] { HttpMethod.Trace };
yield return new object[] { HttpMethod.Options };
yield return new object[] { HttpMethod.Head };
}
[Fact]
public async Task UsesCachedResponseIfEtagIsPresentAndGithubReturns304()
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
const string etag = "my-etag";
var request = Substitute.For<IRequest>();
request.Method.Returns(HttpMethod.Get);
request.Headers.Returns(new Dictionary<string, string>());
var cachedResponse = Substitute.For<IResponse>();
cachedResponse.Headers.Returns(new Dictionary<string, string> { { "ETag", etag } });
var cachedV1Response = CachedResponse.V1.Create(cachedResponse);
var cancellationToken = CancellationToken.None;
var githubResponse = Substitute.For<IResponse>();
githubResponse.StatusCode.Returns(HttpStatusCode.NotModified);
underlyingClient.Send(Arg.Is<IRequest>(req => req == request && req.Headers["If-None-Matched"] == etag), cancellationToken).ReturnsForAnyArgs(githubResponse);
responseCache.GetAsync(request).Returns(cachedV1Response);
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, cancellationToken);
// assert
Assert.Equal(cachedV1Response.ToResponse(), response, new ResponseComparer());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
Assert.Single(responseCache.ReceivedCalls());
responseCache.Received(1).GetAsync(request);
}
[Theory]
[MemberData(nameof(NonNotModifiedHttpStatusCodesWithSetCacheFailure))]
public async Task UsesGithubResponseIfEtagIsPresentAndGithubReturnsNon304(HttpStatusCode httpStatusCode, bool setCacheThrows)
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
const string etag = "my-etag";
var request = Substitute.For<IRequest>();
request.Method.Returns(HttpMethod.Get);
request.Headers.Returns(new Dictionary<string, string>());
var cachedResponse = Substitute.For<IResponse>();
cachedResponse.Headers.Returns(new Dictionary<string, string> { { "ETag", etag } });
var cachedV1Response = CachedResponse.V1.Create(cachedResponse);
var cancellationToken = CancellationToken.None;
var githubResponse = Substitute.For<IResponse>();
githubResponse.StatusCode.Returns(httpStatusCode);
underlyingClient.Send(Arg.Is<IRequest>(req => req == request && req.Headers["If-None-Matched"] == etag), cancellationToken).ReturnsForAnyArgs(githubResponse);
responseCache.GetAsync(request).Returns(cachedV1Response);
if (setCacheThrows)
{
responseCache.SetAsync(request, Arg.Any<CachedResponse.V1>()).ThrowsForAnyArgs<Exception>();
}
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, cancellationToken);
// assert
Assert.Equal(githubResponse, response, new ResponseComparer());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
Assert.Equal(2, responseCache.ReceivedCalls().Count());
responseCache.Received(1).GetAsync(request);
responseCache.Received(1).SetAsync(request, Arg.Is<CachedResponse.V1>(v1 => new ResponseComparer().Equals(v1, CachedResponse.V1.Create(githubResponse))));
}
public static IEnumerable<object[]> NonNotModifiedHttpStatusCodesWithSetCacheFailure()
{
foreach (var statusCode in Enum.GetValues(typeof(HttpStatusCode)))
{
if (statusCode.Equals(HttpStatusCode.NotModified)) continue;
yield return new[] { statusCode, true };
yield return new[] { statusCode, false };
}
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task UsesGithubResponseIfCachedEntryIsNull(bool setCacheThrows)
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var request = Substitute.For<IRequest>();
request.Method.Returns(HttpMethod.Get);
request.Headers.Returns(new Dictionary<string, string>());
var cancellationToken = CancellationToken.None;
var githubResponse = Substitute.For<IResponse>();
underlyingClient.Send(Arg.Is<IRequest>(req => req == request && !req.Headers.Any()), cancellationToken).ReturnsForAnyArgs(githubResponse);
responseCache.GetAsync(request).ReturnsNull();
if (setCacheThrows)
{
responseCache.SetAsync(request, Arg.Any<CachedResponse.V1>()).ThrowsForAnyArgs<Exception>();
}
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, cancellationToken);
// assert
Assert.Equal(githubResponse, response, new ResponseComparer());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
Assert.Equal(2, responseCache.ReceivedCalls().Count());
responseCache.Received(1).GetAsync(request);
responseCache.Received(1).SetAsync(request, Arg.Is<CachedResponse.V1>(v1 => new ResponseComparer().Equals(v1, CachedResponse.V1.Create(githubResponse))));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task UsesGithubResponseIfGetCachedEntryThrows(bool setCacheThrows)
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var request = Substitute.For<IRequest>();
request.Method.Returns(HttpMethod.Get);
request.Headers.Returns(new Dictionary<string, string>());
var cancellationToken = CancellationToken.None;
var githubResponse = Substitute.For<IResponse>();
underlyingClient.Send(Arg.Is<IRequest>(req => req == request && !req.Headers.Any()), cancellationToken).ReturnsForAnyArgs(githubResponse);
responseCache.GetAsync(Args.Request).ThrowsForAnyArgs<Exception>();
if (setCacheThrows)
{
responseCache.SetAsync(request, Arg.Any<CachedResponse.V1>()).ThrowsForAnyArgs<Exception>();
}
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, cancellationToken);
// assert
Assert.Equal(githubResponse, response, new ResponseComparer());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
Assert.Equal(2, responseCache.ReceivedCalls().Count());
responseCache.Received(1).GetAsync(request);
responseCache.Received(1).SetAsync(request, Arg.Is<CachedResponse.V1>(v1 => new ResponseComparer().Equals(v1, CachedResponse.V1.Create(githubResponse))));
}
[Theory]
[InlineData(null, true)]
[InlineData(null, false)]
[InlineData("", true)]
[InlineData("", false)]
public async Task UsesGithubResponseIfCachedEntryEtagIsNullOrEmpty(string etag, bool setCacheThrows)
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var request = Substitute.For<IRequest>();
request.Method.Returns(HttpMethod.Get);
request.Headers.Returns(new Dictionary<string, string>());
var cachedResponse = Substitute.For<IResponse>();
cachedResponse.Headers.Returns(etag == null ? new Dictionary<string, string>() : new Dictionary<string, string> { { "ETag", etag } });
var cachedV1Response = CachedResponse.V1.Create(cachedResponse);
var cancellationToken = CancellationToken.None;
var githubResponse = Substitute.For<IResponse>();
underlyingClient.Send(Arg.Is<IRequest>(req => req == request && !req.Headers.Any()), cancellationToken).ReturnsForAnyArgs(githubResponse);
responseCache.GetAsync(request).Returns(cachedV1Response);
if (setCacheThrows)
{
responseCache.SetAsync(request, Arg.Any<CachedResponse.V1>()).ThrowsForAnyArgs<Exception>();
}
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
var response = await cachingHttpClient.Send(request, cancellationToken);
// assert
Assert.Equal(githubResponse, response, new ResponseComparer());
Assert.Single(underlyingClient.ReceivedCalls());
underlyingClient.Received(1).Send(request, cancellationToken);
Assert.Equal(2, responseCache.ReceivedCalls().Count());
responseCache.Received(1).GetAsync(request);
responseCache.Received(1).SetAsync(request, Arg.Is<CachedResponse.V1>(v1 => new ResponseComparer().Equals(v1, CachedResponse.V1.Create(githubResponse))));
}
}
public class TheSetRequestTimeoutMethod
{
[Fact]
public void SetsRequestTimeoutForUnderlyingClient()
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var timeout = TimeSpan.Zero;
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
cachingHttpClient.SetRequestTimeout(timeout);
// assert
underlyingClient.Received(1).SetRequestTimeout(timeout);
}
}
public class TheDisposeMethod
{
[Fact]
public void CallsDisposeForUnderlyingClient()
{
// arrange
var underlyingClient = Substitute.For<IHttpClient>();
var responseCache = Substitute.For<IResponseCache>();
var cachingHttpClient = new CachingHttpClient(underlyingClient, responseCache);
// act
cachingHttpClient.Dispose();
// assert
underlyingClient.Received(1).Dispose();
}
}
public class TheCtor
{
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
public void EnsuresNonNullArguments(bool httpClientIsNull, bool responseCacheIsNull)
{
var httpClient = httpClientIsNull ? null : Substitute.For<IHttpClient>();
var responseCache = responseCacheIsNull ? null : Substitute.For<IResponseCache>();
Assert.Throws<ArgumentNullException>(() => new CachingHttpClient(httpClient, responseCache));
}
}
}
}

View File

@@ -6,6 +6,7 @@ using Xunit;
using System.Collections.Generic;
using System.Reflection;
using System.Linq;
using Octokit.Caching;
namespace Octokit.Tests
{
@@ -134,6 +135,47 @@ namespace Octokit.Tests
}
}
public class TheResponseCacheProperty
{
[Fact]
public void WhenSetWrapsExistingHttpClientWithCachingHttpClient()
{
var responseCache = Substitute.For<IResponseCache>();
var client = new GitHubClient(new ProductHeaderValue("OctokitTests"));
Assert.IsType<Connection>(client.Connection);
var existingConnection = (Connection) client.Connection;
var existingHttpClient = existingConnection._httpClient;
client.ResponseCache = responseCache;
Assert.Equal(existingConnection, client.Connection);
Assert.IsType<CachingHttpClient>(existingConnection._httpClient);
var cachingHttpClient = (CachingHttpClient) existingConnection._httpClient;
Assert.Equal(existingHttpClient, cachingHttpClient._httpClient);
Assert.Equal(responseCache, cachingHttpClient._responseCache);
}
[Fact]
public void WhenResetWrapsUnderlyingHttpClientWithCachingHttpClient()
{
var responseCache = Substitute.For<IResponseCache>();
var client = new GitHubClient(new ProductHeaderValue("OctokitTests"));
Assert.IsType<Connection>(client.Connection);
var existingConnection = (Connection) client.Connection;
var existingHttpClient = existingConnection._httpClient;
client.ResponseCache = Substitute.For<IResponseCache>();
client.ResponseCache = responseCache;
Assert.Equal(existingConnection, client.Connection);
Assert.IsType<CachingHttpClient>(existingConnection._httpClient);
var cachingHttpClient = (CachingHttpClient) existingConnection._httpClient;
Assert.Equal(existingHttpClient, cachingHttpClient._httpClient);
Assert.Equal(responseCache, cachingHttpClient._responseCache);
}
}
public class TheLastApiInfoProperty
{
[Fact]

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
namespace Octokit.Tests.Helpers
{
public class ApiInfoComparer : IEqualityComparer<ApiInfo>
{
private static readonly CollectionComparer<string> _stringCollectionComparer = new CollectionComparer<string>();
private static readonly StringKeyedDictionaryComparer<Uri> _stringKeyedDictionaryComparer = new StringKeyedDictionaryComparer<Uri>();
private static readonly RateLimitComparer _rateLimitComparer = new RateLimitComparer();
public bool Equals(ApiInfo x, ApiInfo y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null)) return false;
if (ReferenceEquals(y, null)) return false;
if (x.GetType() != y.GetType()) return false;
return _stringCollectionComparer.Equals(x.OauthScopes, y.OauthScopes) &&
_stringCollectionComparer.Equals(x.AcceptedOauthScopes, y.AcceptedOauthScopes) &&
x.Etag == y.Etag &&
_stringKeyedDictionaryComparer.Equals(x.Links, y.Links) &&
_rateLimitComparer.Equals(x.RateLimit, y.RateLimit) &&
x.ServerTimeDifference.Equals(y.ServerTimeDifference);
}
public int GetHashCode(ApiInfo obj)
{
unchecked
{
var hashCode = (obj.OauthScopes != null ? _stringCollectionComparer.GetHashCode(obj.OauthScopes) : 0);
hashCode = (hashCode * 397) ^ (obj.AcceptedOauthScopes != null ? _stringCollectionComparer.GetHashCode(obj.AcceptedOauthScopes) : 0);
hashCode = (hashCode * 397) ^ (obj.Etag != null ? obj.Etag.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (obj.Links != null ? _stringKeyedDictionaryComparer.GetHashCode(obj.Links) : 0);
hashCode = (hashCode * 397) ^ (obj.RateLimit != null ? _rateLimitComparer.GetHashCode(obj.RateLimit) : 0);
hashCode = (hashCode * 397) ^ obj.ServerTimeDifference.GetHashCode();
return hashCode;
}
}
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
namespace Octokit.Tests.Helpers
{
public class CollectionComparer<T> : IEqualityComparer<IReadOnlyCollection<T>>
{
public bool Equals(IReadOnlyCollection<T> x, IReadOnlyCollection<T> y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null)) return false;
if (ReferenceEquals(y, null)) return false;
if (x.GetType() != y.GetType()) return false;
return x.Count == y.Count && x.Intersect(y).Count() == x.Count();
}
public int GetHashCode(IReadOnlyCollection<T> obj)
{
return obj.Count;
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace Octokit.Tests.Helpers
{
public class RateLimitComparer : IEqualityComparer<RateLimit>
{
public bool Equals(RateLimit x, RateLimit y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null)) return false;
if (ReferenceEquals(y, null)) return false;
if (x.GetType() != y.GetType()) return false;
return x.Limit == y.Limit &&
x.Remaining == y.Remaining &&
x.ResetAsUtcEpochSeconds == y.ResetAsUtcEpochSeconds;
}
public int GetHashCode(RateLimit obj)
{
unchecked
{
var hashCode = obj.Limit;
hashCode = (hashCode * 397) ^ obj.Remaining;
hashCode = (hashCode * 397) ^ obj.ResetAsUtcEpochSeconds.GetHashCode();
return hashCode;
}
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Collections.Generic;
namespace Octokit.Tests.Helpers
{
public class ResponseComparer : IEqualityComparer<IResponse>
{
private static readonly StringKeyedDictionaryComparer<string> _stringKeyedDictionaryComparer = new StringKeyedDictionaryComparer<string>();
private static readonly ApiInfoComparer _apiInfoComparer = new ApiInfoComparer();
public bool Equals(IResponse x, IResponse y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null)) return false;
if (ReferenceEquals(y, null)) return false;
if (x.GetType() != y.GetType()) return false;
return Equals(x.Body, y.Body) &&
_stringKeyedDictionaryComparer.Equals(x.Headers, y.Headers) &&
_apiInfoComparer.Equals(x.ApiInfo, y.ApiInfo) &&
x.StatusCode == y.StatusCode &&
x.ContentType == y.ContentType;
}
public int GetHashCode(IResponse obj)
{
unchecked
{
var hashCode = (obj.Body != null ? obj.Body.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (obj.Headers != null ? _stringKeyedDictionaryComparer.GetHashCode(obj.Headers) : 0);
hashCode = (hashCode * 397) ^ (obj.ApiInfo != null ? _apiInfoComparer.GetHashCode(obj.ApiInfo) : 0);
hashCode = (hashCode * 397) ^ (int)obj.StatusCode;
hashCode = (hashCode * 397) ^ (obj.ContentType != null ? obj.ContentType.GetHashCode() : 0);
return hashCode;
}
}
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
namespace Octokit.Tests.Helpers
{
public class StringKeyedDictionaryComparer<T> : IEqualityComparer<IReadOnlyDictionary<string, T>>
{
public bool Equals(IReadOnlyDictionary<string, T> x, IReadOnlyDictionary<string, T> y)
{
if (ReferenceEquals(x, y)) return true;
if (ReferenceEquals(x, null)) return false;
if (ReferenceEquals(y, null)) return false;
if (x.GetType() != y.GetType()) return false;
return x.Count == y.Count && x.Intersect(y).Count() == x.Count;
}
public int GetHashCode(IReadOnlyDictionary<string, T> obj)
{
return obj.Count;
}
}
}

View File

@@ -7,6 +7,7 @@ using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using NSubstitute;
using Octokit.Caching;
using Octokit.Internal;
using Xunit;
@@ -859,5 +860,47 @@ namespace Octokit.Tests.Http
Assert.Equal(100, result.RateLimit.Limit);
}
}
public class TheResponseCacheProperty
{
[Fact]
public void WhenSetWrapsExistingHttpClientWithCachingHttpClient()
{
var responseCache = Substitute.For<IResponseCache>();
var httpClient = Substitute.For<IHttpClient>();
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());
connection.ResponseCache = responseCache;
Assert.IsType<CachingHttpClient>(connection._httpClient);
var cachingHttpClient = (CachingHttpClient) connection._httpClient;
Assert.Equal(httpClient, cachingHttpClient._httpClient);
Assert.Equal(responseCache, cachingHttpClient._responseCache);
}
[Fact]
public void WhenResetWrapsUnderlyingHttpClientWithCachingHttpClient()
{
var responseCache = Substitute.For<IResponseCache>();
var httpClient = Substitute.For<IHttpClient>();
var connection = new Connection(new ProductHeaderValue("OctokitTests"),
_exampleUri,
Substitute.For<ICredentialStore>(),
httpClient,
Substitute.For<IJsonSerializer>());
connection.ResponseCache = Substitute.For<IResponseCache>();
connection.ResponseCache = responseCache;
Assert.IsType<CachingHttpClient>(connection._httpClient);
var cachingHttpClient = (CachingHttpClient) connection._httpClient;
Assert.Equal(httpClient, cachingHttpClient._httpClient);
Assert.Equal(responseCache, cachingHttpClient._responseCache);
}
}
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Octokit.Internal;
namespace Octokit.Caching
{
/// <remarks>
/// When implementation details of <see cref="Response"/> changes:
/// <list type="number">
/// <item>mark <see cref="V1"/> as Obsolete</item>
/// <item>create a V2</item>
/// <item>update usages of <see cref="V1"/> to V2</item>
/// </list>
/// </remarks>
public static class CachedResponse
{
public sealed class V1 : IResponse
{
public V1(object body, IReadOnlyDictionary<string, string> headers, ApiInfo apiInfo, HttpStatusCode statusCode, string contentType)
{
Ensure.ArgumentNotNull(headers, nameof(headers));
StatusCode = statusCode;
Body = body;
Headers = headers;
ApiInfo = apiInfo;
ContentType = contentType;
}
/// <summary>
/// Raw response body. Typically a string, but when requesting images, it will be a byte array.
/// </summary>
public object Body { get; private set; }
/// <summary>
/// Information about the API.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; private set; }
/// <summary>
/// Information about the API response parsed from the response headers.
/// </summary>
public ApiInfo ApiInfo { get; internal set; } // This setter is internal for use in tests.
/// <summary>
/// The response status code.
/// </summary>
public HttpStatusCode StatusCode { get; private set; }
/// <summary>
/// The content type of the response.
/// </summary>
public string ContentType { get; private set; }
internal Response ToResponse() =>
new Response(StatusCode, Body, Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), ContentType);
public static V1 Create(IResponse response)
{
Ensure.ArgumentNotNull(response, nameof(response));
return new V1(response.Body, response.Headers, response.ApiInfo, response.StatusCode, response.ContentType);
}
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Octokit.Internal;
namespace Octokit.Caching
{
public sealed class CachingHttpClient : IHttpClient
{
internal readonly IHttpClient _httpClient;
internal readonly IResponseCache _responseCache;
public CachingHttpClient(IHttpClient httpClient, IResponseCache responseCache)
{
Ensure.ArgumentNotNull(httpClient, nameof(httpClient));
Ensure.ArgumentNotNull(responseCache, nameof(responseCache));
_httpClient = httpClient is CachingHttpClient cachingHttpClient ? cachingHttpClient._httpClient : httpClient;
_responseCache = responseCache;
}
public async Task<IResponse> Send(IRequest request, CancellationToken cancellationToken)
{
Ensure.ArgumentNotNull(request, nameof(request));
if (request.Method != HttpMethod.Get)
{
return await _httpClient.Send(request, cancellationToken);
}
var cachedResponse = await TryGetCachedResponse(request);
if (cachedResponse != null && !string.IsNullOrEmpty(cachedResponse.ApiInfo.Etag))
{
request.Headers["If-None-Match"] = cachedResponse.ApiInfo.Etag;
var conditionalResponse = await _httpClient.Send(request, cancellationToken);
if (conditionalResponse.StatusCode == HttpStatusCode.NotModified)
{
return cachedResponse;
}
TrySetCachedResponse(request, conditionalResponse);
return conditionalResponse;
}
var response = await _httpClient.Send(request, cancellationToken);
TrySetCachedResponse(request, response);
return response;
}
private async Task<IResponse> TryGetCachedResponse(IRequest request)
{
try
{
return (await _responseCache.GetAsync(request))?.ToResponse();
}
catch (Exception)
{
return null;
}
}
private async Task TrySetCachedResponse(IRequest request, IResponse response)
{
try
{
await _responseCache.SetAsync(request, CachedResponse.V1.Create(response));
}
catch (Exception)
{
// ignored
}
}
public void SetRequestTimeout(TimeSpan timeout)
{
_httpClient.SetRequestTimeout(timeout);
}
public void Dispose()
{
_httpClient.Dispose();
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using Octokit.Internal;
namespace Octokit.Caching
{
public interface IResponseCache
{
Task<CachedResponse.V1> GetAsync(IRequest request);
Task SetAsync(IRequest request, CachedResponse.V1 cachedResponse);
}
}

View File

@@ -1,4 +1,5 @@
using System;
using Octokit.Caching;
using Octokit.Internal;
namespace Octokit
@@ -163,6 +164,21 @@ namespace Octokit
}
}
/// <summary>
/// Convenience property for setting response cache.
/// </summary>
/// <remarks>
/// Setting this property will wrap existing <see cref="IHttpClient"/> in <see cref="CachingHttpClient"/>.
/// </remarks>
public IResponseCache ResponseCache
{
set
{
Ensure.ArgumentNotNull(value, nameof(value));
Connection.ResponseCache = value;
}
}
/// <summary>
/// The base address of the GitHub API. This defaults to https://api.github.com,
/// but you can change it if needed (to talk to a GitHub:Enterprise server for instance).

View File

@@ -7,6 +7,7 @@ using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Octokit.Caching;
using Octokit.Internal;
namespace Octokit
@@ -23,7 +24,7 @@ namespace Octokit
readonly Authenticator _authenticator;
readonly JsonHttpPipeline _jsonPipeline;
readonly IHttpClient _httpClient;
internal IHttpClient _httpClient;
/// <summary>
/// Creates a new connection instance used to make requests of the GitHub API.
@@ -650,6 +651,21 @@ namespace Octokit
}
}
/// <summary>
/// Sets response cache used by the connection.
/// </summary>
/// <remarks>
/// Setting this property will wrap existing <see cref="IHttpClient"/> in <see cref="CachingHttpClient"/>.
/// </remarks>
public IResponseCache ResponseCache
{
set
{
Ensure.ArgumentNotNull(value, nameof(value));
_httpClient = new CachingHttpClient(_httpClient, value);
}
}
async Task<IApiResponse<string>> GetHtml(IRequest request)
{
request.Headers.Add("Accept", AcceptHeaders.StableVersionHtml);

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Octokit.Caching;
using Octokit.Internal;
namespace Octokit
@@ -302,7 +303,7 @@ namespace Octokit
/// <typeparam name="T">The type to map the response to</typeparam>
/// <param name="uri">URI endpoint to send request to</param>
/// <param name="data">The object to serialize as the body of the request</param>
/// <param name="accepts">Specifies accept response media type</param>
/// <param name="accepts">Specifies accept response media type</param>
Task<IApiResponse<T>> Delete<T>(Uri uri, object data, string accepts);
/// <summary>
@@ -319,13 +320,21 @@ namespace Octokit
/// Gets or sets the credentials used by the connection.
/// </summary>
/// <remarks>
/// You can use this property if you only have a single hard-coded credential. Otherwise, pass in an
/// <see cref="ICredentialStore"/> to the constructor.
/// Setting this property will change the <see cref="ICredentialStore"/> to use
/// You can use this property if you only have a single hard-coded credential. Otherwise, pass in an
/// <see cref="ICredentialStore"/> to the constructor.
/// Setting this property will change the <see cref="ICredentialStore"/> to use
/// the default <see cref="InMemoryCredentialStore"/> with just these credentials.
/// </remarks>
Credentials Credentials { get; set; }
/// <summary>
/// Sets response cache used by the connection.
/// </summary>
/// <remarks>
/// Setting this property will wrap existing <see cref="IHttpClient"/> in <see cref="CachingHttpClient"/>.
/// </remarks>
IResponseCache ResponseCache { set; }
/// <summary>
/// Sets the timeout for the connection between the client and the server.
/// </summary>