Skip to content

Commit edd8432

Browse files
committed
Dynamic clients
1 parent b69abed commit edd8432

10 files changed

Lines changed: 1322 additions & 197 deletions

src/Cloudflare.NET/CloudflareApiClient.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,31 @@ public CloudflareApiClient(HttpClient httpClient, IOptions<CloudflareApiOptions>
115115
public ITurnstileApi Turnstile => _turnstile.Value;
116116

117117
#endregion
118+
119+
120+
#region Methods Impl - IDisposable
121+
122+
/// <summary>
123+
/// Disposes this client instance.
124+
/// </summary>
125+
/// <remarks>
126+
/// <para>
127+
/// For DI-managed clients, this method does nothing because the <see cref="HttpClient" />
128+
/// lifetime is managed by <see cref="IHttpClientFactory" />. The factory handles pooling
129+
/// and disposal of the underlying handlers automatically.
130+
/// </para>
131+
/// <para>
132+
/// For dynamic clients created via
133+
/// <see cref="ICloudflareApiClientFactory.CreateClient(CloudflareApiOptions)" />,
134+
/// the <see cref="DynamicCloudflareApiClient" /> wrapper handles actual disposal of resources.
135+
/// </para>
136+
/// </remarks>
137+
public void Dispose()
138+
{
139+
// No-op for DI-managed clients. The HttpClient is managed by IHttpClientFactory
140+
// and should not be disposed by the client. For dynamic clients, the
141+
// DynamicCloudflareApiClient wrapper handles disposal.
142+
}
143+
144+
#endregion
118145
}

src/Cloudflare.NET/Core/CloudflareApiClientFactory.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ namespace Cloudflare.NET.Core;
33
using Internal;
44
using Microsoft.Extensions.Logging;
55
using Microsoft.Extensions.Options;
6+
using Resilience;
67
using Validation;
78

89
/// <summary>
@@ -79,6 +80,47 @@ public ICloudflareApiClient CreateClient(string name)
7980
return new CloudflareApiClient(httpClient, optionsWrapper, _loggerFactory);
8081
}
8182

83+
84+
/// <inheritdoc />
85+
public ICloudflareApiClient CreateClient(CloudflareApiOptions options)
86+
{
87+
ArgumentNullException.ThrowIfNull(options);
88+
89+
// Validate the options using the shared validator for consistent, clear error messages.
90+
ValidateNamedClientConfiguration("dynamic", options);
91+
92+
// Build the resilience pipeline using the shared builder.
93+
// This ensures the same resilience behavior as DI-registered clients.
94+
var logger = _loggerFactory.CreateLogger(LoggingConstants.Categories.HttpResilience);
95+
var pipeline = CloudflareResiliencePipelineBuilder.Build(options, logger, clientName: "dynamic");
96+
97+
// Create the handler chain: ResilienceHandler → SocketsHttpHandler.
98+
// SocketsHttpHandler is used for proper connection pooling and lifetime management.
99+
var socketHandler = new SocketsHttpHandler
100+
{
101+
// PooledConnectionLifetime ensures connections are recycled periodically,
102+
// which helps with DNS changes and prevents stale connections.
103+
// This mirrors the behavior of IHttpClientFactory-managed handlers.
104+
PooledConnectionLifetime = TimeSpan.FromMinutes(2)
105+
};
106+
107+
var resilienceHandler = new ResilienceDelegatingHandler(pipeline, socketHandler);
108+
109+
// Create and configure the HttpClient using the shared configurator.
110+
// This ensures the same configuration as DI-registered clients.
111+
var httpClient = new HttpClient(resilienceHandler);
112+
CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: true);
113+
114+
// Wrap the options in IOptions<T> for the CloudflareApiClient constructor.
115+
var optionsWrapper = new NamedOptionsWrapper<CloudflareApiOptions>(options);
116+
117+
// Create the inner client that handles all API operations.
118+
var innerClient = new CloudflareApiClient(httpClient, optionsWrapper, _loggerFactory);
119+
120+
// Wrap in DynamicCloudflareApiClient which handles disposal of the owned HttpClient.
121+
return new DynamicCloudflareApiClient(innerClient, httpClient);
122+
}
123+
82124
#endregion
83125

84126
#region Methods
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
namespace Cloudflare.NET.Core;
2+
3+
using System.Net.Http.Headers;
4+
5+
/// <summary>
6+
/// Configures <see cref="HttpClient" /> instances for Cloudflare API communication.
7+
/// Used by both DI registration and dynamic client creation paths to ensure
8+
/// consistent HTTP client configuration.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// This class centralizes the HttpClient configuration logic that was previously
13+
/// duplicated between the DI registration path and the dynamic client creation path.
14+
/// </para>
15+
/// <para>
16+
/// Configuration includes:
17+
/// </para>
18+
/// <list type="bullet">
19+
/// <item>
20+
/// <description>
21+
/// <b>Base Address</b> - Set from <see cref="CloudflareApiOptions.ApiBaseUrl" />.
22+
/// </description>
23+
/// </item>
24+
/// <item>
25+
/// <description>
26+
/// <b>Timeout</b> - Set to a long value (5 minutes) to allow the resilience pipeline
27+
/// to handle timeouts. The actual timeout is controlled by the pipeline.
28+
/// </description>
29+
/// </item>
30+
/// <item>
31+
/// <description>
32+
/// <b>Authorization Header</b> - Optionally set from <see cref="CloudflareApiOptions.ApiToken" />.
33+
/// </description>
34+
/// </item>
35+
/// </list>
36+
/// </remarks>
37+
public static class CloudflareHttpClientConfigurator
38+
{
39+
#region Constants
40+
41+
/// <summary>
42+
/// The HttpClient timeout. This is intentionally long to allow the resilience
43+
/// pipeline's timeout strategies to be the effective timeout controllers.
44+
/// </summary>
45+
/// <remarks>
46+
/// Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#httpclient-timeout
47+
/// </remarks>
48+
private static readonly TimeSpan HttpClientTimeout = TimeSpan.FromMinutes(5);
49+
50+
#endregion
51+
52+
53+
#region Methods - Public
54+
55+
/// <summary>
56+
/// Configures an <see cref="HttpClient" /> for Cloudflare API communication.
57+
/// </summary>
58+
/// <param name="client">The HttpClient to configure.</param>
59+
/// <param name="options">The Cloudflare API options containing configuration values.</param>
60+
/// <param name="setAuthorizationHeader">
61+
/// If true, sets the Authorization header from the options. Set to false when
62+
/// authentication is handled separately (e.g., via <see cref="Auth.AuthenticationHandler" />).
63+
/// </param>
64+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client" /> or <paramref name="options" /> is null.</exception>
65+
/// <exception cref="InvalidOperationException">Thrown when <see cref="CloudflareApiOptions.ApiBaseUrl" /> is null or whitespace.</exception>
66+
/// <remarks>
67+
/// <para>
68+
/// The HttpClient timeout is set to a long value (5 minutes) to ensure that the resilience
69+
/// pipeline's timeout strategies are the effective timeout controllers. Without this, the
70+
/// HttpClient's default 100-second timeout would interfere with retry attempts.
71+
/// </para>
72+
/// </remarks>
73+
/// <example>
74+
/// <code>
75+
/// // For DI registration path (auth handled by AuthenticationHandler)
76+
/// CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: false);
77+
///
78+
/// // For named clients or dynamic clients (auth header set directly)
79+
/// CloudflareHttpClientConfigurator.Configure(httpClient, options, setAuthorizationHeader: true);
80+
/// </code>
81+
/// </example>
82+
public static void Configure(HttpClient client,
83+
CloudflareApiOptions options,
84+
bool setAuthorizationHeader = true)
85+
{
86+
ArgumentNullException.ThrowIfNull(client);
87+
ArgumentNullException.ThrowIfNull(options);
88+
89+
// Validate the API base URL.
90+
if (string.IsNullOrWhiteSpace(options.ApiBaseUrl))
91+
throw new InvalidOperationException(
92+
"Cloudflare API Base URL is missing. Please configure it in the 'Cloudflare' settings section.");
93+
94+
// Set the base address for all requests.
95+
client.BaseAddress = new Uri(options.ApiBaseUrl);
96+
97+
// Set a long HttpClient.Timeout so that our resilience pipeline's TotalRequestTimeout is the effective timeout.
98+
// Ref: https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience#httpclient-timeout
99+
client.Timeout = HttpClientTimeout;
100+
101+
// Optionally set the Authorization header.
102+
if (setAuthorizationHeader && !string.IsNullOrWhiteSpace(options.ApiToken))
103+
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken);
104+
}
105+
106+
107+
/// <summary>
108+
/// Configures only the base properties of an <see cref="HttpClient" /> (base address and timeout),
109+
/// without setting the Authorization header.
110+
/// </summary>
111+
/// <param name="client">The HttpClient to configure.</param>
112+
/// <param name="options">The Cloudflare API options containing configuration values.</param>
113+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client" /> or <paramref name="options" /> is null.</exception>
114+
/// <exception cref="InvalidOperationException">Thrown when <see cref="CloudflareApiOptions.ApiBaseUrl" /> is null or whitespace.</exception>
115+
/// <remarks>
116+
/// <para>
117+
/// This overload is provided for backward compatibility and convenience when authentication
118+
/// is handled by a separate <see cref="Auth.AuthenticationHandler" />.
119+
/// </para>
120+
/// </remarks>
121+
public static void ConfigureBase(HttpClient client, CloudflareApiOptions options)
122+
{
123+
Configure(client, options, setAuthorizationHeader: false);
124+
}
125+
126+
#endregion
127+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
namespace Cloudflare.NET.Core;
2+
3+
using Accounts;
4+
using ApiTokens;
5+
using AuditLogs;
6+
using Dns;
7+
using Members;
8+
using Roles;
9+
using Subscriptions;
10+
using Turnstile;
11+
using User;
12+
using Workers;
13+
using Zones;
14+
15+
/// <summary>
16+
/// A Cloudflare API client created dynamically (at runtime) that owns its
17+
/// <see cref="HttpClient" /> and disposes it when disposed.
18+
/// </summary>
19+
/// <remarks>
20+
/// <para>
21+
/// Unlike DI-managed clients where the <see cref="HttpClient" /> lifetime is managed by
22+
/// <see cref="IHttpClientFactory" />, dynamic clients own their <see cref="HttpClient" />
23+
/// and must dispose it to release the underlying <see cref="System.Net.Http.SocketsHttpHandler" />
24+
/// and connections.
25+
/// </para>
26+
/// <para>
27+
/// This class wraps a standard <see cref="CloudflareApiClient" /> and adds disposal semantics.
28+
/// All API operations are delegated to the inner client.
29+
/// </para>
30+
/// <para>
31+
/// Users should dispose this client when it is no longer needed:
32+
/// </para>
33+
/// <code>
34+
/// using var client = factory.CreateClient(options);
35+
/// // Use the client...
36+
/// </code>
37+
/// </remarks>
38+
internal sealed class DynamicCloudflareApiClient : ICloudflareApiClient
39+
{
40+
#region Properties & Fields - Non-Public
41+
42+
/// <summary>The inner Cloudflare API client that handles all API operations.</summary>
43+
private readonly CloudflareApiClient _innerClient;
44+
45+
/// <summary>The HttpClient that this instance owns and will dispose.</summary>
46+
private readonly HttpClient _ownedHttpClient;
47+
48+
/// <summary>Indicates whether this instance has been disposed.</summary>
49+
private bool _disposed;
50+
51+
#endregion
52+
53+
54+
#region Constructors
55+
56+
/// <summary>
57+
/// Initializes a new instance of the <see cref="DynamicCloudflareApiClient" /> class.
58+
/// </summary>
59+
/// <param name="innerClient">The inner Cloudflare API client.</param>
60+
/// <param name="ownedHttpClient">The HttpClient that this instance owns and will dispose.</param>
61+
internal DynamicCloudflareApiClient(CloudflareApiClient innerClient, HttpClient ownedHttpClient)
62+
{
63+
_innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient));
64+
_ownedHttpClient = ownedHttpClient ?? throw new ArgumentNullException(nameof(ownedHttpClient));
65+
}
66+
67+
#endregion
68+
69+
70+
#region Properties Impl - ICloudflareApiClient
71+
72+
/// <inheritdoc />
73+
public IAccountsApi Accounts => ThrowIfDisposed()._innerClient.Accounts;
74+
75+
/// <inheritdoc />
76+
public IUserApi User => ThrowIfDisposed()._innerClient.User;
77+
78+
/// <inheritdoc />
79+
public IZonesApi Zones => ThrowIfDisposed()._innerClient.Zones;
80+
81+
/// <inheritdoc />
82+
public IDnsApi Dns => ThrowIfDisposed()._innerClient.Dns;
83+
84+
/// <inheritdoc />
85+
public IAuditLogsApi AuditLogs => ThrowIfDisposed()._innerClient.AuditLogs;
86+
87+
/// <inheritdoc />
88+
public IApiTokensApi ApiTokens => ThrowIfDisposed()._innerClient.ApiTokens;
89+
90+
/// <inheritdoc />
91+
public IRolesApi Roles => ThrowIfDisposed()._innerClient.Roles;
92+
93+
/// <inheritdoc />
94+
public IMembersApi Members => ThrowIfDisposed()._innerClient.Members;
95+
96+
/// <inheritdoc />
97+
public ISubscriptionsApi Subscriptions => ThrowIfDisposed()._innerClient.Subscriptions;
98+
99+
/// <inheritdoc />
100+
public IWorkersApi Workers => ThrowIfDisposed()._innerClient.Workers;
101+
102+
/// <inheritdoc />
103+
public ITurnstileApi Turnstile => ThrowIfDisposed()._innerClient.Turnstile;
104+
105+
#endregion
106+
107+
108+
#region Methods Impl - IDisposable
109+
110+
/// <summary>
111+
/// Releases the resources used by this client, including the owned <see cref="HttpClient" />.
112+
/// </summary>
113+
/// <remarks>
114+
/// <para>
115+
/// After disposal, any attempt to access the API properties will throw
116+
/// <see cref="ObjectDisposedException" />.
117+
/// </para>
118+
/// </remarks>
119+
public void Dispose()
120+
{
121+
if (_disposed)
122+
return;
123+
124+
_ownedHttpClient.Dispose();
125+
_disposed = true;
126+
}
127+
128+
#endregion
129+
130+
131+
#region Methods - Private
132+
133+
/// <summary>
134+
/// Throws <see cref="ObjectDisposedException" /> if this instance has been disposed.
135+
/// </summary>
136+
/// <returns>This instance, for fluent chaining.</returns>
137+
/// <exception cref="ObjectDisposedException">Thrown if this instance has been disposed.</exception>
138+
private DynamicCloudflareApiClient ThrowIfDisposed()
139+
{
140+
ObjectDisposedException.ThrowIf(_disposed, this);
141+
142+
return this;
143+
}
144+
145+
#endregion
146+
}

0 commit comments

Comments
 (0)