ApiClient Library for .NET Core and .NET 5+ applications A modern, resilient HTTP API client library for .NET Core and .NET 5+ applications, providing clean, async HTTP operations with built-in retry policies using Polly v8, comprehensive error handling, and structured logging support. Optimized for high-performance API consumption in cross-platform and cloud-native environments. 🎯 Features Modern .NET Support: Built for .NET 6+ with nullable reference types Polly v8 Integration: Flexible resilience pipelines with exponential backoff and jitter Per-Instance HttpClient: Each ApiClient manages its own HttpClient for isolated configuration Structured Logging: ILogger integration for comprehensive request/response logging Flexible Authentication: Bearer, Basic, and API Key authentication support Custom Headers Support: Add dynamic headers per request or globally Thread-Safe Design: Proper concurrency handling with internal locking Clean Architecture: SOLID principles with dependency injection support Comprehensive Error Handling: Detailed error responses with HTTP status codes and timeout detection Async/Await: Full asynchronous operation support with cancellation tokens Configuration Validation: Prevents unsafe configuration changes after initialization Relative Path Support: Built-in support for relative URLs with BaseAddress configuration Streaming Performance: Efficient JSON deserialization using Newtonsoft.Json with streaming
$ dotnet add package ARSoft.RestApiClientA modern, resilient HTTP API client library for .NET Core and .NET 5+ applications, providing clean, async HTTP operations with built-in retry policies using Polly v8, comprehensive error handling, and structured logging support. Optimized for high-performance API consumption in cross-platform and cloud-native environments.
Add to your project via NuGet Package Manager or .csproj:
<PackageReference Include="Polly.Core" Version="8.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.8" />
// Create client with base address
var apiClient = new ApiClient(
baseAddress: new Uri("https://api.example.com"),
timeout: TimeSpan.FromSeconds(30));
// Make requests
var response = await apiClient.GetAsync<User>(new Uri("users/1", UriKind.Relative));
if (response.Success)
{
Console.WriteLine($"User: {response.Data.Name}");
}
// Program.cs - ASP.NET Core
builder.Services.AddSingleton<IApiClient>(sp =>
{
var logger = sp.GetRequiredService<ILogger<ApiClient>>();
var client = new ApiClient(
baseAddress: new Uri("https://api.example.com"),
timeout: TimeSpan.FromSeconds(60),
logger: logger);
// Add default headers before first use
client.AddDefaultRequestHeader("User-Agent", "MyApp/1.0");
client.AddDefaultRequestHeader("Accept-Language", "en-US");
return client;
});public class UserService
{
private readonly IApiClient _apiClient;
private readonly ILogger<UserService> _logger;
public UserService(IApiClient apiClient, ILogger<UserService> logger)
{
_apiClient = apiClient;
_logger = logger;
}
public async Task<List<User>> GetUsersAsync(string bearerToken, CancellationToken cancellationToken = default)
{
var response = await _apiClient.GetAsync<List<User>>(
new Uri("https://api.example.com/users"),
authToken: bearerToken,
authType: AuthType.Bearer,
cancellationToken: cancellationToken);
if (response.Success)
{
_logger.LogInformation("Retrieved {Count} users", response.Data?.Count ?? 0);
return response.Data ?? new List<User>();
}
_logger.LogError("Failed to retrieve users: {Error}", response.ErrorMessage);
throw new HttpRequestException($"Failed to get users: {response.ErrorMessage}");
}
}public class ApiService
{
private readonly IApiClient _apiClient;
public ApiService(IApiClient apiClient)
{
_apiClient = apiClient;
}
// Example 1: Request-specific tracing header
public async Task<Order> GetOrderAsync(string orderId, string traceId)
{
var customHeaders = new Dictionary<string, string>
{
{ "X-Trace-Id", traceId },
{ "X-Request-Source", "Mobile-App" }
};
var response = await _apiClient.GetAsync<Order>(
new Uri($"https://api.orders.com/orders/{orderId}"),
customHeaders: customHeaders);
return response.Data!;
}
// Example 2: API versioning with custom header
public async Task<Product> GetProductV2Async(int productId)
{
var headers = new Dictionary<string, string>
{
{ "X-API-Version", "2.0" },
{ "X-Feature-Flags", "new-pricing,bulk-discount" }
};
var response = await _apiClient.GetAsync<Product>(
new Uri($"https://api.store.com/products/{productId}"),
customHeaders: headers);
return response.Data!;
}
// Example 3: Combining authentication with custom headers
public async Task<Report> GenerateReportAsync(string apiKey, string reportType, string format)
{
var customHeaders = new Dictionary<string, string>
{
{ "X-Report-Type", reportType },
{ "X-Output-Format", format },
{ "X-Request-Priority", "high" }
};
var response = await _apiClient.GetAsync<Report>(
new Uri("https://api.analytics.com/reports/generate"),
authToken: apiKey,
authType: AuthType.ApiKey,
customHeaders: customHeaders);
return response.Data!;
}
}
public record Order(string Id, decimal Total, DateTime CreatedAt);
public record Product(int Id, string Name, decimal Price);
public record Report(string Id, byte[] Data, string Format);public class PaymentService
{
private readonly IApiClient _apiClient;
public PaymentService(IApiClient apiClient)
{
_apiClient = apiClient;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest payment, string idempotencyKey)
{
// Idempotency key prevents duplicate charges
var customHeaders = new Dictionary<string, string>
{
{ "X-Idempotency-Key", idempotencyKey },
{ "X-Client-Version", "1.2.3" },
{ "X-Device-Id", Environment.MachineName }
};
var response = await _apiClient.PostAsync<PaymentRequest, PaymentResult>(
new Uri("https://api.payments.com/v1/charge"),
payload: payment,
authToken: "sk_live_xyz123",
authType: AuthType.ApiKey,
customHeaders: customHeaders);
if (!response.Success)
{
throw new PaymentException($"Payment failed: {response.ErrorMessage}");
}
return response.Data!;
}
}
public record PaymentRequest(decimal Amount, string Currency, string CardToken);
public record PaymentResult(string TransactionId, string Status, DateTime ProcessedAt);
public class PaymentException : Exception
{
public PaymentException(string message) : base(message) { }
}public class EnterpriseApiClient
{
private readonly IApiClient _apiClient;
public EnterpriseApiClient(ILogger<ApiClient> logger, string environment)
{
_apiClient = new ApiClient(
baseAddress: new Uri("https://api.enterprise.com"),
timeout: TimeSpan.FromSeconds(120),
logger: logger);
// Set default headers that apply to ALL requests
_apiClient.AddDefaultRequestHeader("X-Environment", environment);
_apiClient.AddDefaultRequestHeader("X-Client-Type", "EnterpriseClient");
_apiClient.AddDefaultRequestHeader("User-Agent", "EnterpriseApp/2.0");
}
public async Task<Customer> CreateCustomerAsync(
CreateCustomerRequest request,
string apiKey,
string correlationId)
{
// These custom headers are ONLY for this specific request
var requestHeaders = new Dictionary<string, string>
{
{ "X-Correlation-Id", correlationId },
{ "X-Operation", "CreateCustomer" },
{ "X-Request-Timestamp", DateTime.UtcNow.ToString("o") }
};
var response = await _apiClient.PostAsync<CreateCustomerRequest, Customer>(
new Uri("customers", UriKind.Relative),
payload: request,
authToken: apiKey,
authType: AuthType.ApiKey,
customHeaders: requestHeaders);
return response.Data!;
}
// Headers can be built dynamically based on context
public async Task<List<Transaction>> GetTransactionsAsync(
string accountId,
string userId,
TransactionQueryOptions options)
{
var headers = new Dictionary<string, string>
{
{ "X-User-Id", userId },
{ "X-Account-Id", accountId }
};
// Add conditional headers based on options
if (options.IncludeMetadata)
{
headers["X-Include-Metadata"] = "true";
}
if (options.PageSize.HasValue)
{
headers["X-Page-Size"] = options.PageSize.Value.ToString();
}
var response = await _apiClient.GetAsync<List<Transaction>>(
new Uri($"accounts/{accountId}/transactions", UriKind.Relative),
customHeaders: headers);
return response.Data ?? new List<Transaction>();
}
}
public record CreateCustomerRequest(string Name, string Email);
public record Customer(string Id, string Name, string Email);
public record Transaction(string Id, decimal Amount, DateTime Date);
public record TransactionQueryOptions(bool IncludeMetadata, int? PageSize);public class MultiTenantApiService
{
private readonly IApiClient _apiClient;
private readonly ILogger<MultiTenantApiService> _logger;
public MultiTenantApiService(IApiClient apiClient, ILogger<MultiTenantApiService> logger)
{
_apiClient = apiClient;
_logger = logger;
}
public async Task<TenantData> GetTenantDataAsync(
string tenantId,
string userId,
string accessToken)
{
// Multi-tenant headers
var headers = new Dictionary<string, string>
{
{ "X-Tenant-Id", tenantId },
{ "X-User-Id", userId },
{ "X-Data-Region", "us-east-1" },
{ "X-Request-Context", $"tenant={tenantId},user={userId}" }
};
var response = await _apiClient.GetAsync<TenantData>(
new Uri($"https://api.saas-platform.com/tenants/{tenantId}/data"),
authToken: accessToken,
authType: AuthType.Bearer,
customHeaders: headers);
if (response.Success)
{
_logger.LogInformation(
"Retrieved data for tenant {TenantId} by user {UserId}",
tenantId,
userId);
return response.Data!;
}
throw new UnauthorizedAccessException(
$"Failed to access tenant data: {response.ErrorMessage}");
}
}
public record TenantData(string TenantId, string Name, Dictionary<string, object> Settings);public class WebhookClient
{
private readonly IApiClient _apiClient;
public WebhookClient(IApiClient apiClient)
{
_apiClient = apiClient;
}
public async Task<WebhookResponse> SendWebhookAsync(
string webhookUrl,
WebhookPayload payload,
string secret)
{
// Calculate signature
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = CalculateSignature(payload, secret, timestamp);
var headers = new Dictionary<string, string>
{
{ "X-Webhook-Signature", signature },
{ "X-Webhook-Timestamp", timestamp },
{ "X-Webhook-Id", Guid.NewGuid().ToString() }
};
var response = await _apiClient.PostAsync<WebhookPayload, WebhookResponse>(
new Uri(webhookUrl),
payload: payload,
customHeaders: headers);
return response.Data!;
}
private string CalculateSignature(WebhookPayload payload, string secret, string timestamp)
{
// Implementation of HMAC-SHA256 signature
var data = $"{timestamp}.{JsonSerializer.Serialize(payload)}";
using var hmac = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToBase64String(hash);
}
}
public record WebhookPayload(string Event, object Data);
public record WebhookResponse(bool Accepted, string Message);public class RateLimitedApiService
{
private readonly IApiClient _apiClient;
private readonly ILogger<RateLimitedApiService> _logger;
public RateLimitedApiService(IApiClient apiClient, ILogger<RateLimitedApiService> logger)
{
_apiClient = apiClient;
_logger = logger;
}
public async Task<SearchResults> SearchAsync(
string query,
string apiKey,
int priority = 1)
{
var headers = new Dictionary<string, string>
{
{ "X-Rate-Limit-Tier", "premium" },
{ "X-Request-Priority", priority.ToString() },
{ "X-Request-Id", Guid.NewGuid().ToString() }
};
var response = await _apiClient.GetAsync<SearchResults>(
new Uri($"https://api.search.com/search?q={Uri.EscapeDataString(query)}"),
authToken: apiKey,
authType: AuthType.ApiKey,
customHeaders: headers);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for query: {Query}", query);
// Response headers would contain rate limit info
throw new RateLimitException("Rate limit exceeded");
}
return response.Data!;
}
}
public record SearchResults(List<SearchResult> Results, int TotalCount);
public record SearchResult(string Id, string Title, string Url);
public class RateLimitException : Exception
{
public RateLimitException(string message) : base(message) { }
}// Bearer Token (JWT)
await apiClient.GetAsync<User>(url, authToken: "eyJhbGci...", authType: AuthType.Bearer);
// Basic Authentication (Base64 encoded username:password)
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
await apiClient.GetAsync<User>(url, authToken: credentials, authType: AuthType.Basic);
// API Key (X-API-Key header)
await apiClient.GetAsync<User>(url, authToken: "your-secret-api-key", authType: AuthType.ApiKey);
// No authentication
await apiClient.GetAsync<User>(url);// Pattern 1: Simple custom headers
var headers = new Dictionary<string, string>
{
{ "X-Custom-Header", "value" }
};
await apiClient.GetAsync<Data>(url, customHeaders: headers);
// Pattern 2: Multiple custom headers
var headers = new Dictionary<string, string>
{
{ "X-Trace-Id", Guid.NewGuid().ToString() },
{ "X-User-Agent", "MyApp/1.0" },
{ "X-Device-Type", "Mobile" }
};
await apiClient.PostAsync<Request, Response>(url, payload, customHeaders: headers);
// Pattern 3: Combining authentication and custom headers
await apiClient.GetAsync<User>(
url,
authToken: "token123",
authType: AuthType.Bearer,
customHeaders: new Dictionary<string, string>
{
{ "X-Request-Id", requestId }
});
// Pattern 4: Default headers + request-specific headers
var client = new ApiClient(new Uri("https://api.example.com"));
client.AddDefaultRequestHeader("X-Client-Id", "abc123"); // Applies to all requests
// This request will have BOTH the default header AND the custom header
await client.GetAsync<Data>(
new Uri("data", UriKind.Relative),
customHeaders: new Dictionary<string, string> { { "X-Request-Id", "xyz" } });var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() }
};
var apiClient = new ApiClient(
baseAddress: new Uri("https://api.example.com"),
jsonOptions: jsonOptions);// Custom retry pipeline with exponential backoff
var retryOptions = new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TaskCanceledException>()
.HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests ||
(int)r.StatusCode >= 500),
MaxRetryAttempts = 5,
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
OnRetry = args =>
{
Console.WriteLine($"Retry {args.AttemptNumber} after {args.RetryDelay}");
return ValueTask.CompletedTask;
}
};
var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(retryOptions)
.Build();
var apiClient = new ApiClient(
baseAddress: new Uri("https://api.example.com"),
retryPipeline: retryPipeline);public class ApiResponse<T>
{
public bool Success { get; set; } // Operation success status
public T? Data { get; set; } // Response data (if successful)
public string? ErrorMessage { get; set; } // Error description
public string? ErrorData { get; set; } // Raw error response
public HttpStatusCode StatusCode { get; set; } // HTTP status code
}var response = await apiClient.GetAsync<User>(userUrl);
// Pattern 1: Simple success check
if (response.Success)
{
ProcessUser(response.Data!);
}
else
{
_logger.LogError("API call failed: {Error}", response.ErrorMessage);
}
// Pattern 2: Status code specific handling
switch (response.StatusCode)
{
case HttpStatusCode.OK:
ProcessUser(response.Data!);
break;
case HttpStatusCode.NotFound:
// Handle user not found
break;
case HttpStatusCode.Unauthorized:
// Refresh token or redirect to login
break;
case HttpStatusCode.TooManyRequests:
// Rate limit exceeded
break;
default:
_logger.LogError("Unexpected error {StatusCode}: {Error}",
response.StatusCode, response.ErrorMessage);
break;
}
// Pattern 3: Exception-based handling
public async Task<User> GetUserOrThrowAsync(int userId)
{
var response = await apiClient.GetAsync<User>(
new Uri($"https://api.example.com/users/{userId}"));
return response.Success
? response.Data!
: throw new HttpRequestException(
$"Failed to get user {userId}: {response.ErrorMessage}");
}The ApiClient prevents configuration changes after the first request is sent to ensure thread-safety and predictable behavior:
var client = new ApiClient(new Uri("https://api.example.com"));
// ✅ OK - Before first request
client.AddDefaultRequestHeader("X-Custom", "value");
client.SetTimeout(TimeSpan.FromSeconds(30));
// Send first request
await client.GetAsync<Data>();
// ❌ THROWS ApiClientConfigurationException - After first request
try
{
client.SetTimeout(TimeSpan.FromSeconds(60));
}
catch (ApiClientConfigurationException ex)
{
Console.WriteLine($"Configuration error: {ex.Reason}");
// Output: Configuration error: TimeoutModificationNotAllowed
}public enum ApiClientConfigurationReason
{
HeadersModificationNotAllowed,
BaseAddressModificationNotAllowed,
TimeoutModificationNotAllowed,
ClientDisposed
}
// Example handling
try
{
client.AddDefaultRequestHeader("X-New-Header", "value");
}
catch (ApiClientConfigurationException ex) when
(ex.Reason == ApiClientConfigurationReason.HeadersModificationNotAllowed)
{
// Handle configuration lock
_logger.LogWarning("Cannot modify headers after requests have been sent");
}The ApiClient integrates with Microsoft.Extensions.Logging:
// Configure logging in appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"ARSoft.RestApiClient.ApiClient": "Debug"
}
}
}
// The ApiClient will automatically log:
// - Request cancellations (Debug level)
// - HTTP errors (Error level)[Test]
public async Task GetAsync_WithCustomHeaders_Success()
{
// Arrange
var mockHandler = new Mock<HttpMessageHandler>();
var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(
new User { Id = 1, Name = "Test User" }))
};
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Headers.Contains("X-Custom-Header")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
var apiClient = new ApiClient(new Uri("https://api.test.com"));
// Act
var headers = new Dictionary<string, string>
{
{ "X-Custom-Header", "test-value" }
};
var result = await apiClient.GetAsync<User>(
new Uri("users/1", UriKind.Relative),
customHeaders: headers);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual("Test User", result.Data!.Name);
}git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)This project is licensed under the MIT License - see the LICENSE file for details.
customHeaders parameter to all HTTP methods for request-specific headersDictionary<string, string>? customHeadersHttpClientHttpClient managed by libraryApiClientConfigurationException for post-start configuration changesApiClient no longer disposes shared HttpClientCustomAuthInfo record for flexible header configurationCustomAuthInfo usage with customHeaders dictionarynew CustomAuthInfo { HeaderName = "token", HeaderValue = "abc" }customHeaders: new Dictionary<string, string> { { "token", "abc" } }HttpClient instantiationnew ApiClient(baseAddress, timeout, logger)IAsyncPolicy<HttpResponseMessage> with ResiliencePipeline<HttpResponseMessage>ResiliencePipelineBuilder<T>RetryStrategyOptions configuration