-
-
Notifications
You must be signed in to change notification settings - Fork 397
Expand file tree
/
Copy pathFlurlClient.cs
More file actions
366 lines (298 loc) · 14.2 KB
/
FlurlClient.cs
File metadata and controls
366 lines (298 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Threading;
using Flurl.Http.Configuration;
using Flurl.Http.Testing;
using Flurl.Util;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Reflection;
namespace Flurl.Http
{
/// <summary>
/// Interface defining FlurlClient's contract (useful for mocking and DI)
/// </summary>
public interface IFlurlClient : ISettingsContainer, IHeadersContainer, IEventHandlerContainer, IDisposable {
/// <summary>
/// Gets the HttpClient that this IFlurlClient wraps.
/// </summary>
HttpClient HttpClient { get; }
/// <summary>
/// Gets or sets the base URL used for all calls made with this client.
/// </summary>
string BaseUrl { get; set; }
/// <summary>
/// Creates a new IFlurlRequest that can be further built and sent fluently.
/// </summary>
/// <param name="urlSegments">The URL or URL segments for the request. If BaseUrl is defined, it is assumed that these are path segments off that base.</param>
/// <returns>A new IFlurlRequest</returns>
IFlurlRequest Request(params object[] urlSegments);
/// <summary>
/// Gets a value indicating whether this instance (and its underlying HttpClient) has been disposed.
/// </summary>
bool IsDisposed { get; }
/// <summary>
/// Asynchronously sends an HTTP request.
/// </summary>
/// <param name="request">The IFlurlRequest to send.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <param name="completionOption">The HttpCompletionOption used in the request. Optional.</param>
/// <returns>A Task whose result is the received IFlurlResponse.</returns>
Task<IFlurlResponse> SendAsync(IFlurlRequest request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default);
}
/// <summary>
/// A reusable object for making HTTP calls.
/// </summary>
public class FlurlClient : IFlurlClient
{
private static readonly Lazy<IFlurlClientFactory> _defaultFactory = new(() => new DefaultFlurlClientFactory());
/// <summary>
/// Creates a new instance of <see cref="FlurlClient"/>.
/// </summary>
/// <param name="baseUrl">The base URL associated with this client.</param>
public FlurlClient(string baseUrl = null) : this(_defaultFactory.Value.CreateHttpClient(), baseUrl) { }
/// <summary>
/// Creates a new instance of <see cref="FlurlClient"/>, wrapping an existing HttpClient.
/// Generally, you should let Flurl create and manage HttpClient instances for you, but you might, for
/// example, have an HttpClient instance that was created by a 3rd-party library and you want to use
/// Flurl to build and send calls with it. Be aware that if the HttpClient has an underlying
/// HttpMessageHandler that processes cookies and automatic redirects (as is the case by default),
/// Flurl's re-implementation of those features may not work properly.
/// </summary>
/// <param name="httpClient">The instantiated HttpClient instance.</param>
/// <param name="baseUrl">Optional. The base URL associated with this client.</param>
public FlurlClient(HttpClient httpClient, string baseUrl = null) : this(httpClient, baseUrl, null, null, null) { }
// FlurlClientBuilder gets some special privileges
internal FlurlClient(HttpClient httpClient, string baseUrl, FlurlHttpSettings settings, INameValueList<string> headers, IList<(FlurlEventType, IFlurlEventHandler)> eventHandlers) {
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
BaseUrl = baseUrl ?? httpClient.BaseAddress?.ToString();
Settings = settings ?? new FlurlHttpSettings { Timeout = httpClient.Timeout };
// Timeout can be overridden per request, so don't constrain it by the underlying HttpClient
httpClient.Timeout = Timeout.InfiniteTimeSpan;
EventHandlers = eventHandlers ?? new List<(FlurlEventType, IFlurlEventHandler)>();
Headers = headers ?? new NameValueList<string>(false); // header names are case-insensitive https://stackoverflow.com/a/5259004/62600
foreach (var header in GetHeadersFromHttpClient(httpClient)) {
if (!Headers.Contains(header.Name))
Headers.Add(header);
}
}
// reflection is (relatively) expensive, so keep a cache of HttpRequestHeaders properties
// https://learn.microsoft.com/en-us/dotnet/api/system.net.http.headers.httprequestheaders?#properties
private static IDictionary<string, PropertyInfo> _reqHeaderProps =
typeof(HttpRequestHeaders).GetProperties().ToDictionary(p => p.Name.ToLower(), p => p);
private static IEnumerable<(string Name, string Value)> GetHeadersFromHttpClient(HttpClient httpClient) {
foreach (var h in httpClient.DefaultRequestHeaders) {
// MS isn't making this easy. In some cases, a header value will be split into multiple values, but when iterating the collection
// there's no way to know exactly how to piece them back together. The standard says multiple values should be comma-delimited,
// but with User-Agent they need to be space-delimited. ToString() on properties like UserAgent do this correctly though, so when spinning
// through the collection we'll try to match the header name to a property and ToString() it, otherwise we'll comma-delimit the values.
if (_reqHeaderProps.TryGetValue(h.Key.Replace("-", "").ToLower(), out var prop)) {
var val = prop.GetValue(httpClient.DefaultRequestHeaders).ToString();
yield return (h.Key, val);
}
else {
yield return (h.Key, string.Join(",", h.Value));
}
}
}
/// <inheritdoc />
public string BaseUrl { get; set; }
/// <inheritdoc />
public FlurlHttpSettings Settings { get; }
/// <inheritdoc />
public IList<(FlurlEventType, IFlurlEventHandler)> EventHandlers { get; }
/// <inheritdoc />
public INameValueList<string> Headers { get; }
/// <inheritdoc />
public HttpClient HttpClient { get; }
/// <inheritdoc />
public IFlurlRequest Request(params object[] urlSegments) => new FlurlRequest(this, urlSegments);
/// <inheritdoc />
public async Task<IFlurlResponse> SendAsync(IFlurlRequest request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) {
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Url == null)
throw new ArgumentException("Cannot send Request. Url property was not set.");
if (!Url.IsValid(request.Url))
throw new ArgumentException($"Cannot send Request. {request.Url} is a not a valid URL.");
var settings = request.Settings;
var reqMsg = new HttpRequestMessage(request.Verb, request.Url) {
Content = request.Content,
Version = Version.Parse(settings.HttpVersion)
};
SyncHeaders(request, reqMsg);
var call = new FlurlCall {
Client = this,
Request = request,
HttpRequestMessage = reqMsg
};
await RaiseEventAsync(FlurlEventType.BeforeCall, call).ConfigureAwait(false);
// in case URL or headers were modified in the handler above
reqMsg.RequestUri = request.Url.ToUri();
SyncHeaders(request, reqMsg);
call.StartedUtc = DateTime.UtcNow;
var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts);
HttpTest.Current?.LogCall(call);
try {
call.HttpResponseMessage =
HttpTest.Current?.FindSetup(call)?.GetNextResponse() ??
await HttpClient.SendAsync(reqMsg, completionOption, ct).ConfigureAwait(false);
call.HttpResponseMessage.RequestMessage = reqMsg;
call.Response = new FlurlResponse(call, request.CookieJar);
if (call.Succeeded) {
var redirResponse = await ProcessRedirectAsync(call, completionOption, cancellationToken).ConfigureAwait(false);
return redirResponse ?? call.Response;
}
else
throw new FlurlHttpException(call, null);
}
catch (Exception ex) {
return await HandleExceptionAsync(call, ex, cancellationToken).ConfigureAwait(false);
}
finally {
cts?.Dispose();
call.EndedUtc = DateTime.UtcNow;
await RaiseEventAsync(FlurlEventType.AfterCall, call).ConfigureAwait(false);
}
}
private void SyncHeaders(IFlurlRequest req, HttpRequestMessage reqMsg) {
// copy any client-level (default) headers to FlurlRequest
FlurlRequest.SyncHeaders(this, req);
// copy headers from FlurlRequest to HttpRequestMessage
foreach (var header in req.Headers)
reqMsg.SetHeader(header.Name, header.Value.Trim(), false);
if (reqMsg.Content == null)
return;
// copy headers from HttpContent to FlurlRequest
foreach (var header in reqMsg.Content.Headers.ToList()) {
if (!req.Headers.Contains(header.Key))
req.Headers.AddOrReplace(header.Key, string.Join(",", header.Value));
}
}
private async Task<IFlurlResponse> ProcessRedirectAsync(FlurlCall call, HttpCompletionOption completionOption, CancellationToken cancellationToken) {
var settings = call.Request.Settings;
if (settings.Redirects.Enabled)
call.Redirect = GetRedirect(call);
if (call.Redirect == null)
return null;
await RaiseEventAsync(FlurlEventType.OnRedirect, call).ConfigureAwait(false);
if (call.Redirect.Follow != true)
return null;
var changeToGet = call.Redirect.ChangeVerbToGet;
var redir = new FlurlRequest(this) {
Url = call.Redirect.Url,
Verb = changeToGet ? HttpMethod.Get : call.HttpRequestMessage.Method,
Content = changeToGet ? null : call.Request.Content,
RedirectedFrom = call,
Settings = { Parent = settings }
};
foreach (var handler in call.Request.EventHandlers)
redir.EventHandlers.Add(handler);
if (call.Request.CookieJar != null)
redir.CookieJar = call.Request.CookieJar;
redir.WithHeaders(call.Request.Headers.Where(h =>
h.Name.OrdinalEquals("Cookie", true) ? false : // never blindly forward Cookie header; CookieJar should be used to ensure rules are enforced
h.Name.OrdinalEquals("Authorization", true) ? settings.Redirects.ForwardAuthorizationHeader :
h.Name.OrdinalEquals("Transfer-Encoding", true) ? settings.Redirects.ForwardHeaders && !changeToGet :
settings.Redirects.ForwardHeaders));
var ct = GetCancellationTokenWithTimeout(cancellationToken, settings.Timeout, out var cts);
try {
return await SendAsync(redir, completionOption, ct).ConfigureAwait(false);
}
finally {
cts?.Dispose();
}
}
// partially lifted from https://github.com/dotnet/runtime/blob/master/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs
private static FlurlRedirect GetRedirect(FlurlCall call) {
if (call.Response.StatusCode < 300 || call.Response.StatusCode > 399)
return null;
if (!call.Response.Headers.TryGetFirst("Location", out var location))
return null;
var redir = new FlurlRedirect();
if (Url.IsValid(location))
redir.Url = new Url(location);
else if (location.OrdinalStartsWith("//"))
redir.Url = new Url(call.Request.Url.Scheme + ":" + location);
else if (location.OrdinalStartsWith("/"))
redir.Url = Url.Combine(call.Request.Url.Root, location);
else
redir.Url = Url.Combine(call.Request.Url.Root, call.Request.Url.Path, location);
// Per https://tools.ietf.org/html/rfc7231#section-7.1.2, a redirect location without a
// fragment should inherit the fragment from the original URI.
if (string.IsNullOrEmpty(redir.Url.Fragment))
redir.Url.Fragment = call.Request.Url.Fragment;
redir.Count = 1 + (call.Request.RedirectedFrom?.Redirect?.Count ?? 0);
var isSecureToInsecure = (call.Request.Url.IsSecureScheme && !redir.Url.IsSecureScheme);
redir.Follow =
new[] { 301, 302, 303, 307, 308 }.Contains(call.Response.StatusCode) &&
redir.Count <= call.Request.Settings.Redirects.MaxAutoRedirects &&
(call.Request.Settings.Redirects.AllowSecureToInsecure || !isSecureToInsecure);
bool ChangeVerbToGetOn(int statusCode, HttpMethod verb) {
switch (statusCode) {
// 301 and 302 are a bit ambiguous. The spec says to preserve the verb
// but most browsers rewrite it to GET. HttpClient stack changes it if
// only it's a POST, presumably since that's a browser-friendly verb.
// Seems odd, but sticking with that is probably the safest bet.
// https://github.com/dotnet/runtime/blob/master/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RedirectHandler.cs#L140
case 301:
case 302:
return verb == HttpMethod.Post;
case 303:
return true;
default: // 307 & 308 mainly
return false;
}
}
redir.ChangeVerbToGet =
redir.Follow &&
ChangeVerbToGetOn(call.Response.StatusCode, call.Request.Verb);
return redir;
}
internal static async Task RaiseEventAsync(FlurlEventType eventType, FlurlCall call) {
// client-level handlers first, then request-level
var handlers = call.Client.EventHandlers
.Concat(call.Request.EventHandlers)
.Where(h => h.EventType == eventType)
.Select(h => h.Handler)
.ToList();
foreach (var handler in handlers) {
// sync first, then async
handler.Handle(eventType, call);
await handler.HandleAsync(eventType, call);
}
}
internal static async Task<IFlurlResponse> HandleExceptionAsync(FlurlCall call, Exception ex, CancellationToken token) {
call.Exception = ex;
await RaiseEventAsync(FlurlEventType.OnError, call).ConfigureAwait(false);
if (call.ExceptionHandled)
return call.Response;
if (ex is OperationCanceledException && !token.IsCancellationRequested)
throw new FlurlHttpTimeoutException(call, ex);
if (ex is FlurlHttpException)
throw ex;
throw new FlurlHttpException(call, ex);
}
private static CancellationToken GetCancellationTokenWithTimeout(CancellationToken original, TimeSpan? timeout, out CancellationTokenSource timeoutTokenSource) {
timeoutTokenSource = null;
if (!timeout.HasValue)
return original;
timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(original);
timeoutTokenSource.CancelAfter(timeout.Value);
return timeoutTokenSource.Token;
}
/// <inheritdoc />
public bool IsDisposed { get; private set; }
/// <summary>
/// Disposes the underlying HttpClient and HttpMessageHandler.
/// </summary>
public virtual void Dispose() {
if (IsDisposed)
return;
HttpClient.Dispose();
IsDisposed = true;
}
}
}