Skip to content

Commit 2c1c392

Browse files
authored
(#33) IHttpClientFactory with tests. (#46)
1 parent 381b108 commit 2c1c392

File tree

5 files changed

+386
-3
lines changed

5 files changed

+386
-3
lines changed

src/CommunityToolkit.Datasync.Client/Http/DefaultHttpClientFactory.cs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Collections.Concurrent;
56

67
namespace CommunityToolkit.Datasync.Client.Http;
78

@@ -13,6 +14,8 @@ namespace CommunityToolkit.Datasync.Client.Http;
1314
/// <param name="options">The options to use in creating new <see cref="HttpClient"/> objects.</param>
1415
public class DefaultHttpClientFactory(Uri endpoint, IHttpClientOptions options) : IHttpClientFactory
1516
{
17+
private readonly object _lock = new();
18+
1619
/// <summary>
1720
/// The base endpoint for the <see cref="HttpClient"/> that is produced.
1821
/// </summary>
@@ -23,9 +26,76 @@ public class DefaultHttpClientFactory(Uri endpoint, IHttpClientOptions options)
2326
/// </summary>
2427
internal IHttpClientOptions Options { get; } = options;
2528

29+
/// <summary>
30+
/// A cache of all the <see cref="HttpClient"/> objects that have been given out
31+
/// within the application.
32+
/// </summary>
33+
internal ConcurrentDictionary<string, HttpClient> _cache = new();
34+
2635
/// <inheritdoc />
2736
public HttpClient CreateClient(string name)
2837
{
29-
throw new NotImplementedException();
38+
lock (this._lock)
39+
{
40+
if (this._cache.TryGetValue(name, out HttpClient? client))
41+
{
42+
return client;
43+
}
44+
45+
HttpMessageHandler rootHandler = CreatePipeline(Options.HttpPipeline);
46+
HttpClient createdClient = new(rootHandler)
47+
{
48+
BaseAddress = Endpoint,
49+
Timeout = Options.HttpTimeout
50+
};
51+
_ = this._cache.TryAdd(name, createdClient);
52+
return createdClient;
53+
}
54+
}
55+
56+
/// <summary>
57+
/// Transform a list of <see cref="HttpMessageHandler"/> objects into a chain suitable for using
58+
/// as the pipeline of a <see cref="HttpClient"/>.
59+
/// </summary>
60+
/// <param name="handlers">The list of <see cref="HttpMessageHandler"/> objects to transform</param>
61+
/// <returns>The chained <see cref="HttpMessageHandler"/></returns>
62+
internal static HttpMessageHandler CreatePipeline(IEnumerable<HttpMessageHandler> handlers)
63+
{
64+
HttpMessageHandler pipeline = handlers.LastOrDefault() ?? GetDefaultHttpClientHandler();
65+
if (pipeline is DelegatingHandler lastPolicy && lastPolicy.InnerHandler == null)
66+
{
67+
lastPolicy.InnerHandler = GetDefaultHttpClientHandler();
68+
pipeline = lastPolicy;
69+
}
70+
71+
// Wire handlers up in reverse order
72+
foreach (HttpMessageHandler handler in handlers.Reverse().Skip(1))
73+
{
74+
if (handler is DelegatingHandler policy)
75+
{
76+
policy.InnerHandler = pipeline;
77+
pipeline = policy;
78+
}
79+
else
80+
{
81+
throw new ArgumentException("All message handlers except the last one must be 'DelegatingHandler'", nameof(handlers));
82+
}
83+
}
84+
85+
return pipeline;
86+
}
87+
88+
/// <summary>
89+
/// Returns a <see cref="HttpClientHandler"/> that supports automatic decompression.
90+
/// </summary>
91+
internal static HttpMessageHandler GetDefaultHttpClientHandler()
92+
{
93+
HttpClientHandler handler = new();
94+
if (handler.SupportsAutomaticDecompression)
95+
{
96+
handler.AutomaticDecompression = System.Net.DecompressionMethods.GZip;
97+
}
98+
99+
return handler;
30100
}
31101
}

src/CommunityToolkit.Datasync.Client/Http/HttpClientOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ public class HttpClientOptions : IHttpClientOptions
1313
public IEnumerable<HttpMessageHandler> HttpPipeline { get; set; } = [];
1414

1515
/// <inheritdoc />
16-
public TimeSpan? HttpTimeout { get; set; } = TimeSpan.FromSeconds(60);
16+
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(60);
1717
}

src/CommunityToolkit.Datasync.Client/Http/IHttpClientOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@ public interface IHttpClientOptions
2222
/// If set, the timeout to use with <see cref="HttpClient"/> connections.
2323
/// If not set, the default of 100,000ms (100 seconds) will be used.
2424
/// </summary>
25-
TimeSpan? HttpTimeout { get; }
25+
TimeSpan HttpTimeout { get; }
2626
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Net;
6+
using System.Text;
7+
using System.Text.Encodings.Web;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization;
10+
11+
namespace CommunityToolkit.Datasync.Client.Test.Helpers;
12+
13+
/// <summary>
14+
/// A delegating handler for mocking responses.
15+
/// </summary>
16+
[ExcludeFromCodeCoverage]
17+
public class MockDelegatingHandler : DelegatingHandler
18+
{
19+
// For manipulating the request/response link - we need to surround it with a lock
20+
private readonly SemaphoreSlim requestLock = new(1, 1);
21+
22+
/// <summary>
23+
/// Used for serializing objects to be returned as responses.
24+
/// </summary>
25+
private static readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
26+
{
27+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
28+
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
29+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
30+
};
31+
32+
/// <summary>
33+
/// List of requests that have been received.
34+
/// </summary>
35+
public List<HttpRequestMessage> Requests { get; } = [];
36+
37+
/// <summary>
38+
/// List of responses that will be sent.
39+
/// </summary>
40+
public List<HttpResponseMessage> Responses { get; } = [];
41+
42+
/// <summary>
43+
/// Handler for the request/response
44+
/// </summary>
45+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token = default)
46+
{
47+
await this.requestLock.WaitAsync(token).ConfigureAwait(false);
48+
try
49+
{
50+
Requests.Add(await CloneRequest(request).ConfigureAwait(false));
51+
return Responses[Requests.Count - 1];
52+
}
53+
finally
54+
{
55+
this.requestLock.Release();
56+
}
57+
}
58+
59+
/// <summary>
60+
/// Clone the <see cref="HttpRequestMessage"/>.
61+
/// </summary>
62+
public static async Task<HttpRequestMessage> CloneRequest(HttpRequestMessage request)
63+
{
64+
HttpRequestMessage clone = new(request.Method, request.RequestUri)
65+
{
66+
Version = request.Version
67+
};
68+
request.Headers.ToList().ForEach(header => clone.Headers.TryAddWithoutValidation(header.Key, header.Value));
69+
70+
if (request.Content != null)
71+
{
72+
MemoryStream ms = new();
73+
await request.Content.CopyToAsync(ms).ConfigureAwait(false);
74+
ms.Position = 0;
75+
clone.Content = new StreamContent(ms);
76+
77+
request.Content.Headers?.ToList().ForEach(header => clone.Content.Headers.Add(header.Key, header.Value));
78+
}
79+
80+
return clone;
81+
}
82+
83+
/// <summary>
84+
/// Adds a response with no payload to the list of responses.
85+
/// </summary>
86+
/// <param name="statusCode"></param>
87+
/// <param name="headers"></param>
88+
public void AddResponse(HttpStatusCode statusCode, IDictionary<string, string> headers = null)
89+
=> Responses.Add(CreateResponse(statusCode, headers));
90+
91+
/// <summary>
92+
/// Adds a response with a string payload.
93+
/// </summary>
94+
/// <param name="content">The JSON content</param>
95+
public void AddResponseContent(string content, HttpStatusCode statusCode = HttpStatusCode.OK)
96+
{
97+
HttpResponseMessage response = CreateResponse(statusCode);
98+
response.Content = new StringContent(content, Encoding.UTF8, "application/json");
99+
Responses.Add(response);
100+
}
101+
102+
/// <summary>
103+
/// Adds a response with a payload to the list of responses.
104+
/// </summary>
105+
/// <typeparam name="T"></typeparam>
106+
/// <param name="statusCode"></param>
107+
/// <param name="payload"></param>
108+
/// <param name="headers"></param>
109+
public void AddResponse<T>(HttpStatusCode statusCode, T payload, IDictionary<string, string> headers = null)
110+
{
111+
HttpResponseMessage response = CreateResponse(statusCode, headers);
112+
response.Content = new StringContent(JsonSerializer.Serialize(payload, serializerOptions), Encoding.UTF8, "application/json");
113+
Responses.Add(response);
114+
}
115+
116+
/// <summary>
117+
/// Creates a <see cref="HttpResponseMessage"/> with no payload
118+
/// </summary>
119+
/// <param name="statusCode">The status code</param>
120+
/// <param name="headers">The headers (if any) to add</param>
121+
/// <returns>The <see cref="HttpResponseMessage"/></returns>
122+
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, IDictionary<string, string> headers = null)
123+
{
124+
HttpResponseMessage response = new(statusCode);
125+
if (headers != null)
126+
{
127+
foreach (KeyValuePair<string, string> kv in headers)
128+
{
129+
if (!response.Content.Headers.TryAddWithoutValidation(kv.Key, kv.Value))
130+
{
131+
response.Headers.Add(kv.Key, kv.Value);
132+
}
133+
}
134+
}
135+
136+
return response;
137+
}
138+
139+
protected override void Dispose(bool disposing)
140+
{
141+
if (disposing)
142+
{
143+
this.requestLock.Dispose();
144+
}
145+
146+
base.Dispose(disposing);
147+
}
148+
}

0 commit comments

Comments
 (0)