Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,8 @@ dotnet_diagnostic.S6964.severity = none # Value type property used a
# Custom - Code Analyzers Rules
##########################################

dotnet_diagnostic.ATC210.max_line_length = 100

dotnet_diagnostic.CA1014.severity = none #
dotnet_diagnostic.CA1859.severity = none #

Expand Down
3 changes: 2 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@

<!-- Shared code analyzers used for all projects in the solution -->
<ItemGroup Label="Code Analyzers">
<PackageReference Include="Atc.Analyzer" Version="0.1.22" PrivateAssets="All" />
<PackageReference Include="AsyncFixer" Version="2.1.0" PrivateAssets="All" />
<PackageReference Include="Asyncify" Version="0.9.7" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.296" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.7" PrivateAssets="All" />
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.19.0.132793" PrivateAssets="All" />
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ A lightweight and flexible REST client library for .NET, providing a clean abstr
- [📤 POST Request with Body](#-post-request-with-body)
- [🔗 Using Path and Query Parameters](#-using-path-and-query-parameters)
- [📎 File Upload (Multipart Form Data)](#-file-upload-multipart-form-data)
- [📁 File Upload with IFileContent](#-file-upload-with-ifilecontent)
- [📤 Binary Upload (Raw Stream)](#-binary-upload-raw-stream)
- [💾 File Download (Binary Response)](#-file-download-binary-response)
- [🌊 Streaming Responses (IAsyncEnumerable)](#-streaming-responses-iasyncenumerable)
Expand Down Expand Up @@ -270,6 +271,49 @@ requestBuilder.WithFiles(files);
using var request = requestBuilder.Build(HttpMethod.Post);
```

### 📁 File Upload with IFileContent

For file uploads via `WithBody()`, implement the `IFileContent` interface:

```csharp
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
> a `FileName` (or `Name`) property and an `OpenReadStream()` method. This means ASP.NET Core
> `IFormFile` and Blazor `IBrowserFile` objects work without any additional packages or adapters:
>
> ```csharp
> // 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 `IFileContent` explicitly.

### 📤 Binary Upload (Raw Stream)

Upload a raw binary stream directly with `application/octet-stream` content type:
Expand Down Expand Up @@ -552,6 +596,21 @@ public interface IMessageRequestBuilder
}
```

> **`IFileContent` interface:**
>
> ```csharp
> public interface IFileContent
> {
> string FileName { get; }
> string? ContentType { get; }
> Stream OpenReadStream();
> }
> ```
>
> Used with `WithBody<TBody>()` for file uploads. Objects passed to `WithBody()` that have
> a `FileName`/`Name` property and an `OpenReadStream()` method are automatically detected
> and uploaded as multipart form data — no explicit `IFileContent` implementation required.

#### `IMessageResponseBuilder`

```csharp
Expand Down
13 changes: 6 additions & 7 deletions src/Atc.Rest.Client/Atc.Rest.Client.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
Expand All @@ -13,12 +13,11 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
<PackageReference Include="System.Text.Json" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageReference Include="System.Text.Json" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
</ItemGroup>

<ItemGroup>
Expand Down
9 changes: 3 additions & 6 deletions src/Atc.Rest.Client/Builder/HttpMessageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,17 @@ internal class HttpMessageFactory : IHttpMessageFactory
{
private readonly IContractSerializer serializer;

public HttpMessageFactory(
IContractSerializer serializer)
public HttpMessageFactory(IContractSerializer serializer)
{
this.serializer = serializer;
}

public IMessageRequestBuilder FromTemplate(
string pathTemplate)
public IMessageRequestBuilder FromTemplate(string pathTemplate)
=> new MessageRequestBuilder(
pathTemplate,
serializer);

public IMessageResponseBuilder FromResponse(
HttpResponseMessage? response)
public IMessageResponseBuilder FromResponse(HttpResponseMessage? response)
=> new MessageResponseBuilder(
response,
serializer);
Expand Down
6 changes: 2 additions & 4 deletions src/Atc.Rest.Client/Builder/IHttpMessageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,12 @@ public interface IHttpMessageFactory
/// that will be replaced with real values passed to the <see cref="IMessageRequestBuilder.WithPathParameter(string, object?)"/>
/// method.</param>
/// <returns>A new <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder FromTemplate(
string pathTemplate);
IMessageRequestBuilder FromTemplate(string pathTemplate);

/// <summary>
/// Creates a message response builder from an HTTP response message.
/// </summary>
/// <param name="response">The HTTP response message to process. Can be null.</param>
/// <returns>A new <see cref="IMessageResponseBuilder"/>.</returns>
IMessageResponseBuilder FromResponse(
HttpResponseMessage? response);
IMessageResponseBuilder FromResponse(HttpResponseMessage? response);
}
40 changes: 30 additions & 10 deletions src/Atc.Rest.Client/Builder/IMessageRequestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public interface IMessageRequestBuilder
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="value"/> is null or whitespace.</exception>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithPathParameter(string name, object? value);
IMessageRequestBuilder WithPathParameter(
string name,
object? value);

/// <summary>
/// Adds a value to a header parameter in the headers.
Expand All @@ -26,7 +28,9 @@ public interface IMessageRequestBuilder
/// <param name="value">Value to use as the header parameter.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace.</exception>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithHeaderParameter(string name, object? value);
IMessageRequestBuilder WithHeaderParameter(
string name,
object? value);

/// <summary>
/// Adds a query parameter to the created request URL.
Expand All @@ -38,7 +42,9 @@ public interface IMessageRequestBuilder
/// <param name="value">Value of the query parameter.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace.</exception>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithQueryParameter(string name, string? value);
IMessageRequestBuilder WithQueryParameter(
string name,
string? value);

/// <summary>
/// Adds a query parameter with multiple values to the created request URL.
Expand All @@ -51,7 +57,9 @@ public interface IMessageRequestBuilder
/// <param name="values">Collection of values for the query parameter.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace.</exception>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithQueryParameter(string name, IEnumerable? values);
IMessageRequestBuilder WithQueryParameter(
string name,
IEnumerable? values);

/// <summary>
/// Adds a query parameter to the created request URL.
Expand All @@ -64,7 +72,9 @@ public interface IMessageRequestBuilder
/// <param name="value">Value of the query parameter.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or whitespace.</exception>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithQueryParameter(string name, object? value);
IMessageRequestBuilder WithQueryParameter(
string name,
object? value);

/// <summary>
/// Adds the body of the request.
Expand All @@ -87,7 +97,9 @@ public interface IMessageRequestBuilder
/// <param name="stream">The stream to send as the request body.</param>
/// <param name="contentType">Optional content type. Defaults to application/octet-stream.</param>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithBinaryBody(Stream stream, string? contentType = null);
IMessageRequestBuilder WithBinaryBody(
Stream stream,
string? contentType = null);

/// <summary>
/// Builds a <see cref="HttpRequestMessage"/> with the added content.
Expand All @@ -102,7 +114,8 @@ public interface IMessageRequestBuilder
/// </summary>
/// <param name="completionOption">The HTTP completion option.</param>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithHttpCompletionOption(HttpCompletionOption completionOption);
IMessageRequestBuilder WithHttpCompletionOption(
HttpCompletionOption completionOption);

/// <summary>
/// Gets the HTTP completion option set for this request.
Expand All @@ -117,20 +130,27 @@ public interface IMessageRequestBuilder
/// <param name="fileName">The file name.</param>
/// <param name="contentType">Optional content type. Defaults to application/octet-stream.</param>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithFile(Stream stream, string name, string fileName, string? contentType = null);
IMessageRequestBuilder WithFile(
Stream stream,
string name,
string fileName,
string? contentType = null);

/// <summary>
/// Adds multiple files to the multipart form data content.
/// </summary>
/// <param name="files">Collection of files with Stream, Name, FileName, and optional ContentType.</param>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithFiles(IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files);
IMessageRequestBuilder WithFiles(
IEnumerable<(Stream Stream, string Name, string FileName, string? ContentType)> files);

/// <summary>
/// Adds a form field to the multipart form data content.
/// </summary>
/// <param name="name">The form field name.</param>
/// <param name="value">The form field value.</param>
/// <returns>The <see cref="IMessageRequestBuilder"/>.</returns>
IMessageRequestBuilder WithFormField(string name, string value);
IMessageRequestBuilder WithFormField(
string name,
string value);
}
9 changes: 3 additions & 6 deletions src/Atc.Rest.Client/Builder/IMessageResponseBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ public interface IMessageResponseBuilder
/// </summary>
/// <param name="statusCode">The HTTP status code to treat as success.</param>
/// <returns>The <see cref="IMessageResponseBuilder"/>.</returns>
IMessageResponseBuilder AddSuccessResponse(
HttpStatusCode statusCode);
IMessageResponseBuilder AddSuccessResponse(HttpStatusCode statusCode);

/// <summary>
/// Registers a status code as a success response with typed content deserialization.
Expand All @@ -27,8 +26,7 @@ IMessageResponseBuilder AddSuccessResponse<TResponseContent>(
/// </summary>
/// <param name="statusCode">The HTTP status code to treat as error.</param>
/// <returns>The <see cref="IMessageResponseBuilder"/>.</returns>
IMessageResponseBuilder AddErrorResponse(
HttpStatusCode statusCode);
IMessageResponseBuilder AddErrorResponse(HttpStatusCode statusCode);

/// <summary>
/// Registers a status code as an error response with typed content deserialization.
Expand Down Expand Up @@ -57,8 +55,7 @@ Task<TResult> BuildResponseAsync<TResult>(
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An <see cref="EndpointResponse{TSuccess}"/> with the typed success content.</returns>
Task<EndpointResponse<TSuccessContent>>
BuildResponseAsync<TSuccessContent>(
CancellationToken cancellationToken)
BuildResponseAsync<TSuccessContent>(CancellationToken cancellationToken)
where TSuccessContent : class;

/// <summary>
Expand Down
Loading