Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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" />
Comment thread
davidkallesen marked this conversation as resolved.
Outdated
<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(path);
Comment thread
davidkallesen marked this conversation as resolved.
Outdated
}

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