A lightweight and flexible REST client library for .NET, providing a clean abstraction over HttpClient with built-in support for request building, response handling, and dependency injection.
- π ATC.Net REST Client
- π Table of Contents
- β¨ Features
- π Getting Started
- π‘ Usage Examples
- π Best Practices
- π API Reference
- π€ How to Contribute
- π Fluent HTTP Request Building: Build complex HTTP requests with a clean, chainable API
- π¦ Typed Response Handling: Strongly-typed success and error responses
- βοΈ Flexible Configuration: Multiple ways to configure HTTP clients
- π Dependency Injection Ready: Seamless integration with Microsoft.Extensions.DependencyInjection
- π·οΈ Path Templates: Support for URI templates with parameter replacement
- π Query & Header Parameters: Easy addition of query strings and headers
- π Custom Serialization: Pluggable contract serialization (defaults to JSON)
- β Response Processing: Built-in support for success/error response handling
- π Multipart Form Data: File upload support with Stream-based API
- π€ Binary Uploads: Raw binary stream uploads (application/octet-stream)
- πΎ Binary Responses: Handle file downloads with byte[] or Stream responses
- π Streaming Support: IAsyncEnumerable streaming for large datasets with proper lifecycle management
- β±οΈ HTTP Completion Options: Control response buffering for streaming scenarios
Install the package via NuGet:
dotnet add package Atc.Rest.ClientThere are multiple ways to register services with dependency injection:
Use this approach when you configure HttpClient separately or use source-generated endpoints:
using Atc.Rest.Client.Options;
// Registers IHttpMessageFactory and IContractSerializer (default JSON) only
services.AddAtcRestClientCore();
// Or with a custom serializer
services.AddAtcRestClientCore(myCustomSerializer);Use this approach when you have straightforward configuration needs:
using Atc.Rest.Client.Options;
services.AddAtcRestClient(
clientName: "MyApiClient",
baseAddress: new Uri("https://api.example.com"),
timeout: TimeSpan.FromSeconds(30));Use this approach when you need to register the options as a singleton for later retrieval:
// Define a custom options class
public sealed class MyApiClientOptions : AtcRestClientOptions
{
public string ApiKey { get; set; } = string.Empty;
}
// Register with custom options
var options = new MyApiClientOptions
{
BaseAddress = new Uri("https://api.example.com"),
Timeout = TimeSpan.FromSeconds(30),
ApiKey = "your-api-key"
};
services.AddAtcRestClient(
clientName: "MyApiClient",
options: options);Create an endpoint class that uses IHttpMessageFactory to build and send requests:
public interface IUsersEndpoint
{
Task<EndpointResponse<User>> GetUserAsync(int userId, CancellationToken cancellationToken = default);
}
public class UsersEndpoint : IUsersEndpoint
{
private readonly IHttpClientFactory clientFactory;
private readonly IHttpMessageFactory messageFactory;
public UsersEndpoint(
IHttpClientFactory clientFactory,
IHttpMessageFactory messageFactory)
{
this.clientFactory = clientFactory;
this.messageFactory = messageFactory;
}
public async Task<EndpointResponse<User>> GetUserAsync(
int userId,
CancellationToken cancellationToken = default)
{
var client = clientFactory.CreateClient("MyApiClient");
var requestBuilder = messageFactory.FromTemplate("/api/users/{userId}");
requestBuilder.WithPathParameter("userId", userId);
using var request = requestBuilder.Build(HttpMethod.Get);
using var response = await client.SendAsync(request, cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessResponse<User>(HttpStatusCode.OK);
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.NotFound);
return await responseBuilder.BuildResponseAsync<User>(cancellationToken);
}
}Register the endpoint:
services.AddSingleton<IUsersEndpoint, UsersEndpoint>();var requestBuilder = messageFactory.FromTemplate("/api/products");
using var request = requestBuilder.Build(HttpMethod.Get);
using var response = await client.SendAsync(request, cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessResponse<List<Product>>(HttpStatusCode.OK);
var result = await responseBuilder.BuildResponseAsync<List<Product>>(cancellationToken);
if (result.IsSuccess)
{
var products = result.SuccessContent;
// Process products π
}var newUser = new CreateUserRequest
{
Name = "John Doe",
Email = "john@example.com"
};
var requestBuilder = messageFactory.FromTemplate("/api/users");
requestBuilder.WithBody(newUser);
using var request = requestBuilder.Build(HttpMethod.Post);
using var response = await client.SendAsync(request, cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessResponse<User>(HttpStatusCode.Created);
responseBuilder.AddErrorResponse<ValidationProblemDetails>(HttpStatusCode.BadRequest);
var result = await responseBuilder.BuildResponseAsync<User>(cancellationToken);var requestBuilder = messageFactory.FromTemplate("/api/users/{userId}/posts");
requestBuilder.WithPathParameter("userId", 123);
requestBuilder.WithQueryParameter("pageSize", 10);
requestBuilder.WithQueryParameter("page", 1);
requestBuilder.WithQueryParameter("orderBy", "createdDate");
using var request = requestBuilder.Build(HttpMethod.Get);
// Results in: GET /api/users/123/posts?pageSize=10&page=1&orderBy=createdDateUpload files using the Stream-based multipart form data API:
// Single file upload
await using var fileStream = File.OpenRead("document.pdf");
var requestBuilder = messageFactory.FromTemplate("/api/files/upload");
requestBuilder.WithFile(fileStream, "file", "document.pdf", "application/pdf");
requestBuilder.WithFormField("description", "My document");
using var request = requestBuilder.Build(HttpMethod.Post);
using var response = await client.SendAsync(request, cancellationToken);Upload multiple files:
await using var file1 = File.OpenRead("image1.png");
await using var file2 = File.OpenRead("image2.png");
var files = new List<(Stream, string, string, string?)>
{
(file1, "images", "image1.png", "image/png"),
(file2, "images", "image2.png", "image/png")
};
var requestBuilder = messageFactory.FromTemplate("/api/files/upload-multiple");
requestBuilder.WithFiles(files);
using var request = requestBuilder.Build(HttpMethod.Post);For file uploads via WithBody(), implement the IFileContent interface:
using Atc.Rest.Client;
public class MyFile : IFileContent
{
public string FileName { get; init; }
public string? ContentType { get; init; }
public Stream OpenReadStream() => File.OpenRead(FileName);
}
var requestBuilder = messageFactory.FromTemplate("/api/files/upload");
requestBuilder.WithBody(new MyFile { FileName = "report.pdf", ContentType = "application/pdf" });
using var request = requestBuilder.Build(HttpMethod.Post);
using var response = await client.SendAsync(request, cancellationToken);WithBody() also accepts List<IFileContent> for multi-file uploads.
Platform compatibility:
WithBody()automatically detects file-like objects that have aFileName(orName) property and anOpenReadStream()method. This means ASP.NET CoreIFormFileand BlazorIBrowserFileobjects work without any additional packages or adapters:// ASP.NET Core controller - works automatically public async Task<IActionResult> Upload(IFormFile file) { requestBuilder.WithBody(file); } // Blazor WASM component - works automatically private async Task OnFileSelected(InputFileChangeEventArgs e) { requestBuilder.WithBody(e.File); }For compile-time type safety, implement
IFileContentexplicitly.
Upload a raw binary stream directly with application/octet-stream content type:
await using var fileStream = File.OpenRead("document.bin");
var requestBuilder = messageFactory.FromTemplate("/api/files/upload");
requestBuilder.WithBinaryBody(fileStream);
using var request = requestBuilder.Build(HttpMethod.Post);
using var response = await client.SendAsync(request, cancellationToken);Use a custom content type:
await using var imageStream = File.OpenRead("photo.png");
var requestBuilder = messageFactory.FromTemplate("/api/images/upload");
requestBuilder.WithBinaryBody(imageStream, "image/png");
using var request = requestBuilder.Build(HttpMethod.Post);π‘ When to use
WithBinaryBodyvsWithFile:
- Use
WithBinaryBodywhen the API expects raw binary data withapplication/octet-streamor similar content type- Use
WithFilewhen the API expectsmultipart/form-dataformat (typical file upload forms)
Download files as byte arrays or streams:
var requestBuilder = messageFactory.FromTemplate("/api/files/{fileId}");
requestBuilder.WithPathParameter("fileId", "123");
using var request = requestBuilder.Build(HttpMethod.Get);
using var response = await client.SendAsync(request, cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
// Option 1: Get as byte array π¦
var binaryResponse = await responseBuilder.BuildBinaryResponseAsync(cancellationToken);
if (binaryResponse.IsSuccess)
{
var content = binaryResponse.Content;
var fileName = binaryResponse.FileName;
var contentType = binaryResponse.ContentType;
// Save or process the file...
}
// Option 2: Get as stream (for large files) π
var streamResponse = await responseBuilder.BuildStreamBinaryResponseAsync(cancellationToken);
if (streamResponse.IsSuccess)
{
await using var content = streamResponse.Content;
await using var fileStream = File.Create(streamResponse.FileName ?? "download.bin");
await content!.CopyToAsync(fileStream, cancellationToken);
}Stream large datasets efficiently using IAsyncEnumerable. There are two approaches:
Use BuildStreamingResponseAsync<T> for simple streaming scenarios:
var requestBuilder = messageFactory.FromTemplate("/api/data/stream");
requestBuilder.WithHttpCompletionOption(HttpCompletionOption.ResponseHeadersRead);
using var request = requestBuilder.Build(HttpMethod.Get);
using var response = await client.SendAsync(
request,
requestBuilder.HttpCompletionOption,
cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
// β οΈ Note: The response must stay alive during enumeration
await foreach (var item in responseBuilder.BuildStreamingResponseAsync<DataItem>(cancellationToken))
{
if (item is not null)
{
Console.WriteLine($"Received: {item.Name}");
}
}Use BuildStreamingEndpointResponseAsync<T> for proper lifecycle management. This approach wraps the streaming content in a disposable response that manages the HttpResponseMessage lifecycle:
var requestBuilder = messageFactory.FromTemplate("/api/data/stream");
requestBuilder.WithHttpCompletionOption(HttpCompletionOption.ResponseHeadersRead);
using var request = requestBuilder.Build(HttpMethod.Get);
// Don't use 'using' here - the StreamingEndpointResponse will manage the lifecycle
var response = await client.SendAsync(
request,
requestBuilder.HttpCompletionOption,
cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
// π― The response manages HttpResponseMessage disposal
using var streamingResponse = await responseBuilder.BuildStreamingEndpointResponseAsync<DataItem>(cancellationToken);
if (streamingResponse.IsSuccess && streamingResponse.Content is not null)
{
await foreach (var item in streamingResponse.Content.WithCancellation(cancellationToken))
{
if (item is not null)
{
Console.WriteLine($"β
Received: {item.Name}");
}
}
}
else
{
// Handle error - error content is available
Console.WriteLine($"β Error: {streamingResponse.ErrorContent}");
}
// HttpResponseMessage is automatically disposed here π§Ήπ‘ Why use
BuildStreamingEndpointResponseAsync?
- β Proper lifecycle management - the
HttpResponseMessageis disposed when you dispose the response- β Error handling - access to
ErrorContentwhen the request fails- β Status code information - check
IsSuccessandStatusCode- β Avoids premature disposal - no risk of disposing the response before enumeration completes
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessResponse<User>(HttpStatusCode.OK);
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.BadRequest);
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.NotFound);
var result = await responseBuilder.BuildResponseAsync<User, ProblemDetails>(cancellationToken);
if (result.IsSuccess)
{
var user = result.SuccessContent;
Console.WriteLine($"β
Success: {user!.Name}");
}
else
{
var problem = result.ErrorContent;
Console.WriteLine($"β Error ({result.StatusCode}): {problem?.Detail}");
}var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessResponse<User>(HttpStatusCode.OK);
var result = await responseBuilder.BuildResponseAsync(
response => new CustomResult
{
Success = response.IsSuccess,
StatusCode = response.StatusCode,
User = response.ContentObject as User
},
cancellationToken);| Scenario | Recommended Approach |
|---|---|
| Simple HTTP client with just base URL and timeout | Non-generic overload (AddAtcRestClient(string, Uri, TimeSpan)) |
| Additional configuration properties needed | Generic overload with custom options type |
When registering multiple HTTP clients, consider using a consistent naming convention:
// β
Good: Clear, distinct names
services.AddAtcRestClient("Users-API", new Uri("https://users.api.com"), TimeSpan.FromSeconds(30));
services.AddAtcRestClient("Orders-API", new Uri("https://orders.api.com"), TimeSpan.FromSeconds(60));
services.AddAtcRestClient("Payments-API", new Uri("https://payments.api.com"), TimeSpan.FromSeconds(45));Registers core services (IHttpMessageFactory and IContractSerializer) without HttpClient configuration:
IServiceCollection AddAtcRestClientCore(
this IServiceCollection services,
IContractSerializer? contractSerializer = null)These methods are used by source-generated code and are hidden from IntelliSense:
// With HttpClient configuration
IServiceCollection AddAtcRestClient(
this IServiceCollection services,
string clientName,
Uri baseAddress,
TimeSpan timeout,
Action<IHttpClientBuilder>? httpClientBuilder = null,
IContractSerializer? contractSerializer = null)
// Generic overload for typed options
IServiceCollection AddAtcRestClient<TOptions>(
this IServiceCollection services,
string clientName,
TOptions options,
Action<IHttpClientBuilder>? httpClientBuilder = null,
IContractSerializer? contractSerializer = null)
where TOptions : AtcRestClientOptions, new()public class AtcRestClientOptions
{
public virtual Uri? BaseAddress { get; set; }
public virtual TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
}public interface IHttpMessageFactory
{
IMessageRequestBuilder FromTemplate(string pathTemplate);
IMessageResponseBuilder FromResponse(HttpResponseMessage? response);
}public interface IMessageRequestBuilder
{
IMessageRequestBuilder WithPathParameter(string name, object? value);
IMessageRequestBuilder WithQueryParameter(string name, object? value);
IMessageRequestBuilder WithHeaderParameter(string name, object? value);
IMessageRequestBuilder WithBody<TBody>(TBody body);
HttpRequestMessage Build(HttpMethod method);
// HTTP completion option for streaming
IMessageRequestBuilder WithHttpCompletionOption(HttpCompletionOption completionOption);
HttpCompletionOption HttpCompletionOption { get; }
// Binary upload support (raw stream)
IMessageRequestBuilder WithBinaryBody(Stream stream, string? contentType = null);
// Multipart form data support
IMessageRequestBuilder WithFile(Stream stream, string name, string fileName, string? contentType = null);
IMessageRequestBuilder WithFiles(IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files);
IMessageRequestBuilder WithFormField(string name, string value);
}
IFileContentinterface:public interface IFileContent { string FileName { get; } string? ContentType { get; } Stream OpenReadStream(); }Used with
WithBody<TBody>()for file uploads. Objects passed toWithBody()that have aFileName/Nameproperty and anOpenReadStream()method are automatically detected and uploaded as multipart form data β no explicitIFileContentimplementation required.
public interface IMessageResponseBuilder
{
IMessageResponseBuilder AddSuccessResponse(HttpStatusCode statusCode);
IMessageResponseBuilder AddSuccessResponse<TResponseContent>(HttpStatusCode statusCode);
IMessageResponseBuilder AddErrorResponse(HttpStatusCode statusCode);
IMessageResponseBuilder AddErrorResponse<TResponseContent>(HttpStatusCode statusCode);
Task<TResult> BuildResponseAsync<TResult>(
Func<EndpointResponse, TResult> factory,
CancellationToken cancellationToken);
Task<EndpointResponse<TSuccessContent>> BuildResponseAsync<TSuccessContent>(
CancellationToken cancellationToken)
where TSuccessContent : class;
Task<EndpointResponse<TSuccessContent, TErrorContent>> BuildResponseAsync<TSuccessContent, TErrorContent>(
CancellationToken cancellationToken)
where TSuccessContent : class
where TErrorContent : class;
// πΎ Binary response support
Task<BinaryEndpointResponse> BuildBinaryResponseAsync(CancellationToken cancellationToken);
Task<StreamBinaryEndpointResponse> BuildStreamBinaryResponseAsync(CancellationToken cancellationToken);
// π Streaming support
IAsyncEnumerable<T?> BuildStreamingResponseAsync<T>(CancellationToken cancellationToken = default);
Task<StreamingEndpointResponse<T>> BuildStreamingEndpointResponseAsync<T>(CancellationToken cancellationToken = default);
}All response types provide two status properties for checking request outcomes:
| Property | Meaning | Determination |
|---|---|---|
IsSuccess |
Request completed successfully | Based on HTTP 2xx status or configured status codes |
Examples:
| HTTP Status | IsSuccess |
|---|---|
| 200 OK | β
true |
| 201 Created | β
true |
| 204 NoContent | β
true |
| 400 BadRequest | β false |
| 404 NotFound | β false |
When to use each:
IsSuccess: General success check β "Did the request succeed?"
All response types support both properties:
| Type | IsSuccess |
|---|---|
EndpointResponse |
β |
EndpointResponse<TSuccess> |
β |
EndpointResponse<TSuccess, TError> |
β |
BinaryEndpointResponse |
β |
StreamBinaryEndpointResponse |
β |
StreamingEndpointResponse<T> |
β |
public class EndpointResponse : IEndpointResponse
{
public bool IsSuccess { get; }
public HttpStatusCode StatusCode { get; }
public string Content { get; }
public object? ContentObject { get; }
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; }
protected InvalidOperationException InvalidContentAccessException<TExpected>(
HttpStatusCode expectedStatusCode,
string propertyName);
}
// Generic variants available:
// - EndpointResponse<TSuccess>
// - EndpointResponse<TSuccess, TError>public class BinaryEndpointResponse : IBinaryEndpointResponse
{
public bool IsSuccess { get; }
public HttpStatusCode StatusCode { get; }
public byte[]? Content { get; }
public string? ContentType { get; }
public string? FileName { get; }
public long? ContentLength { get; }
public string? ErrorContent { get; } // Error message if request failed
protected InvalidOperationException InvalidContentAccessException(
HttpStatusCode expectedStatusCode,
string propertyName);
}public class StreamBinaryEndpointResponse : IStreamBinaryEndpointResponse, IDisposable
{
public bool IsSuccess { get; }
public HttpStatusCode StatusCode { get; }
public Stream? Content { get; }
public string? ContentType { get; }
public string? FileName { get; }
public long? ContentLength { get; }
public string? ErrorContent { get; } // Error message if request failed
public void Dispose();
protected InvalidOperationException InvalidContentAccessException(
HttpStatusCode expectedStatusCode,
string propertyName);
}A disposable response type for streaming IAsyncEnumerable<T> content with proper lifecycle management:
public class StreamingEndpointResponse<T> : IStreamingEndpointResponse<T>, IDisposable
{
public bool IsSuccess { get; }
public HttpStatusCode StatusCode { get; }
public IAsyncEnumerable<T?>? Content { get; } // π The streaming content
public string? ErrorContent { get; } // β Error message if request failed
public void Dispose(); // π§Ή Disposes the underlying HttpResponseMessage
protected InvalidOperationException InvalidContentAccessException(
HttpStatusCode expectedStatusCode,
string propertyName);
}π‘ Key Benefits:
- Manages
HttpResponseMessagelifecycle automatically- Provides
ErrorContentwhen the request fails- Prevents premature disposal during enumeration
- Inheritable for custom response types