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
- 📝 Plain Text Responses: First-class
text/plainhandling that bypasses JSON deserialization - 🌊 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);For endpoints that return a text/plain (or other text/*) body with a type: string schema, use AddSuccessTextResponse / AddErrorTextResponse. These bypass the contract serializer entirely and return the body verbatim — no JSON parsing, no exceptions on non-JSON content:
var requestBuilder = messageFactory.FromTemplate("/api/reports/{id}/text");
requestBuilder.WithPathParameter("id", "123");
using var request = requestBuilder.Build(HttpMethod.Get);
using var response = await client.SendAsync(request, cancellationToken);
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddSuccessTextResponse(HttpStatusCode.OK);
responseBuilder.AddErrorTextResponse(HttpStatusCode.BadRequest);
var result = await responseBuilder.BuildResponseAsync<string, string>(cancellationToken);
if (result.IsSuccess)
{
// SuccessContent is the raw body, e.g. "file contents"
Console.WriteLine(result.SuccessContent);
}
else
{
// ErrorContent is the raw error body, e.g. "validation failed"
Console.WriteLine($"❌ {result.StatusCode}: {result.ErrorContent}");
}💡 When to use
AddSuccessTextResponsevsAddSuccessResponse<string>:
- Use
AddSuccessTextResponsewhen the server returns a raw text body (e.g.file contents). The default JSON serializer would reject this since it's not a valid JSON literal.- Use
AddSuccessResponse<string>only when the server returns a JSON-encoded string (e.g."file contents"with quotes).An empty or whitespace-only body yields a
nullContentObject, matching the behavior ofAddSuccessResponse<T>. Character encoding follows theContent-Typecharset.
When an error response (4xx/5xx) cannot be deserialized to the registered type (e.g., a server returns plain text instead of ProblemDetails JSON), the builder falls back to the raw string content instead of throwing. This allows generated result classes to handle the conversion gracefully:
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.NotFound);
// If the server returns plain text "Not Found" instead of JSON ProblemDetails,
// ContentObject will be the raw string "Not Found" (not a ProblemDetails instance).
// Generated result classes handle this via ProblemDetailsFactory.
var result = await responseBuilder.BuildResponseAsync(x => x, cancellationToken);Note: For success responses (2xx), deserialization failures still throw
RestClientDeserializationExceptionsince the response body is the primary payload.
| 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);
// 📝 Plain text response support (text/*, bypasses serializer)
IMessageResponseBuilder AddSuccessTextResponse(HttpStatusCode statusCode);
IMessageResponseBuilder AddErrorTextResponse(HttpStatusCode statusCode);
Task<TResult> BuildResponseAsync<TResult>(
Func<EndpointResponse, TResult> factory,
CancellationToken cancellationToken);
Task<EndpointResponse<TSuccessContent>> BuildResponseAsync<TSuccessContent>(
CancellationToken cancellationToken);
Task<EndpointResponse<TSuccessContent, TErrorContent>> BuildResponseAsync<TSuccessContent, TErrorContent>(
CancellationToken cancellationToken);
// 💾 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