mirror of
https://github.com/zoriya/octokit.net.git
synced 2025-12-05 23:06:10 +00:00
[feat] Add Response caching
This commit is contained in:
102
Octokit.Tests/Caching/CachedResponseTests.cs
Normal file
102
Octokit.Tests/Caching/CachedResponseTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
331
Octokit.Tests/Caching/CachingHttpClientTests.cs
Normal file
331
Octokit.Tests/Caching/CachingHttpClientTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
41
Octokit.Tests/Helpers/ApiInfoComparer.cs
Normal file
41
Octokit.Tests/Helpers/ApiInfoComparer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Octokit.Tests/Helpers/CollectionComparer.cs
Normal file
23
Octokit.Tests/Helpers/CollectionComparer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Octokit.Tests/Helpers/RateLimitComparer.cs
Normal file
30
Octokit.Tests/Helpers/RateLimitComparer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Octokit.Tests/Helpers/ResponseComparer.cs
Normal file
37
Octokit.Tests/Helpers/ResponseComparer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Octokit.Tests/Helpers/StringKeyedDictionaryComparer.cs
Normal file
23
Octokit.Tests/Helpers/StringKeyedDictionaryComparer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
Octokit/Caching/CachedResponse.cs
Normal file
63
Octokit/Caching/CachedResponse.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Octokit/Caching/CachingHttpClient.cs
Normal file
86
Octokit/Caching/CachingHttpClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Octokit/Caching/IResponseCache.cs
Normal file
12
Octokit/Caching/IResponseCache.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user