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);
}File names: FileName prefers the RFC 5987 filename* parameter over filename. For Content-Disposition: attachment; filename="Rapport-_.pdf"; filename*=UTF-8''Rapport-%C3%85.pdf (what ASP.NET Core sends for non-ASCII names), FileName is Rapport-Å.pdf.
Errors: a download endpoint's errors carry the same information as any other endpoint's. Register the error types on the builder, and read the typed error, the raw text, the content type and the headers:
var responseBuilder = messageFactory.FromResponse(response);
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.NotFound);
var binaryResponse = await responseBuilder.BuildBinaryResponseAsync(cancellationToken);
if (!binaryResponse.IsSuccess)
{
// Typed error body, or null when no type is registered for the status or it could not be deserialized.
var problem = binaryResponse.ErrorContentObject as ProblemDetails;
// Raw error body, always available (e.g. a proxy's HTML page).
var raw = binaryResponse.ErrorContent;
// Tell application/problem+json from text/html, and read headers such as Retry-After.
var contentType = binaryResponse.ContentType;
var retryAfter = binaryResponse.Headers.TryGetValue("Retry-After", out var values) ? values.First() : null;
}Note:
IsSuccessfor binary responses follows the HTTP status (2xx). A 3xx response (an unfollowed redirect or a304 Not Modified) is therefore not a success, and its body is returned asErrorContent.BuildResponseAsynclikewise reports 3xx as not successful and reads the body as text.HttpClientfollows redirects by default, so this only happens when redirects are disabled or for conditional requests.
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.
BuildResponseAsync reads a success body with a non-JSON, non-text media type (e.g. application/octet-stream, image/png) as a byte[] into ContentObject, leaving Content empty. Error bodies are treated differently: an error response is always read as a string, whatever its media type, because an error body is diagnostic text that callers log or show.
responseBuilder.AddErrorResponse<ProblemDetails>(HttpStatusCode.ServiceUnavailable);
// A gateway answers 503 with Content-Type: application/xml and body
// "<error><message>xml down</message></error>".
var result = await responseBuilder.BuildResponseAsync(x => x, cancellationToken);
// result.Content == "<error><message>xml down</message></error>"
// result.ContentObject == the same string (deserialization to ProblemDetails failed, so it falls back)- The body is decoded by the
Content-Typecharset (e.g.application/xml; charset=iso-8859-1), or UTF-8 when none is given. - "Error" follows the builder's configuration: a status registered with
AddErrorResponsecounts as an error even if it is 2xx, and one registered withAddSuccessResponsecounts as a success even if it is 4xx/5xx.
| 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; } // Raw error body if request failed
public object? ErrorContentObject { get; } // Error body as the type registered with AddErrorResponse<T>
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; }
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; } // Raw error body if request failed
public object? ErrorContentObject { get; } // Error body as the type registered with AddErrorResponse<T>
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; }
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