diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..35639ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + # Exercises the library's net8.0 asset (modern SocketsHttpHandler path). + test-modern: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Test (net10.0) + run: dotnet test src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj -c Release -f net10.0 + + # Exercises the library's netstandard2.0 asset (rotating path + .NET Framework branch) + # that .NET Framework consumers actually receive. Requires a Windows runner. + test-netfx: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Test (net48) + run: dotnet test src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj -c Release -f net48 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bcf23b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +name: Release + +# Publishes to NuGet either: +# - manually (Actions > Release > Run workflow), choosing the semver part to bump, or +# - automatically when a PR is merged to main carrying a release:major|minor|patch label. +# +# The version is computed by bumping the latest v* git tag (falling back to the csproj +# when no tag exists yet), so never has to be edited by hand. Each +# successful publish creates the matching v tag and a GitHub Release. +# +# Requires a repository secret NUGET_API_KEY (Settings > Secrets and variables > Actions) +# with push rights for the SimpleHttpClient package. +# +# First-time setup: seed a tag matching the last published version so the first bump is +# correct, e.g. git tag v4.1.0 && git push origin v4.1.0 + +on: + workflow_dispatch: + inputs: + bump: + description: 'Semver part to bump' + required: true + default: patch + type: choice + options: [patch, minor, major] + pull_request: + types: [closed] + branches: [main] + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + # Decide whether to publish and which part to bump. + - name: Determine bump + id: bump + env: + EVENT: ${{ github.event_name }} + INPUT_BUMP: ${{ github.event.inputs.bump }} + MERGED: ${{ github.event.pull_request.merged }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + shell: bash + run: | + set -euo pipefail + if [ "$EVENT" = "workflow_dispatch" ]; then + echo "bump=$INPUT_BUMP" >> "$GITHUB_OUTPUT" + echo "proceed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # pull_request closed: only on a real merge carrying a release label + if [ "$MERGED" != "true" ]; then + echo "Not a merge; skipping." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + bump="" + for l in $(echo "$LABELS" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin)))"); do + case "$l" in + release:major) bump=major ;; + release:minor) bump=minor ;; + release:patch) bump=patch ;; + esac + done + if [ -z "$bump" ]; then + echo "No release:* label on the merged PR; skipping publish." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "bump=$bump" >> "$GITHUB_OUTPUT" + echo "proceed=true" >> "$GITHUB_OUTPUT" + + - name: Compute version + if: steps.bump.outputs.proceed == 'true' + id: version + shell: bash + run: | + set -euo pipefail + latest="$(git tag --list 'v*' --sort=-v:refname | head -n1)" + if [ -z "$latest" ]; then + base="$(grep -oPm1 '(?<=)[^<]+' src/SimpleHttpClient/SimpleHttpClient.csproj)" + else + base="${latest#v}" + fi + IFS='.' read -r MA MI PA <<< "$base" + case "${{ steps.bump.outputs.bump }}" in + major) MA=$((MA+1)); MI=0; PA=0 ;; + minor) MI=$((MI+1)); PA=0 ;; + patch) PA=$((PA+1)) ;; + esac + newver="$MA.$MI.$PA" + echo "version=$newver" >> "$GITHUB_OUTPUT" + echo "Releasing $newver (base $base, bump ${{ steps.bump.outputs.bump }})" + + - name: Test + if: steps.bump.outputs.proceed == 'true' + run: dotnet test src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj -c Release -f net10.0 + + # GeneratePackageOnBuild is enabled, so a Release build emits the .nupkg. + - name: Build package + if: steps.bump.outputs.proceed == 'true' + run: dotnet build src/SimpleHttpClient/SimpleHttpClient.csproj -c Release -p:Version=${{ steps.version.outputs.version }} + + - name: Push to NuGet + if: steps.bump.outputs.proceed == 'true' + run: dotnet nuget push "src/SimpleHttpClient/bin/Release/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Tag and create GitHub Release + if: steps.bump.outputs.proceed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="v${{ steps.version.outputs.version }}" + git tag "$tag" + git push origin "$tag" + gh release create "$tag" --title "$tag" --generate-notes diff --git a/README.md b/README.md index f75c2a8..8b01b11 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,36 @@ # SimpleHttpClient -An easy-to-use .NET wrapper for HttpClient. No extension methods, and included interfaces allow for easy unit test mocking, and straightforward properties allows for easier debugging (the response body is available as a string, byte array, and/or a typed object). +[![CI](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml/badge.svg)](https://github.com/Mako88/SimpleHttpClient/actions/workflows/ci.yml) + +An easy-to-use .NET wrapper for `HttpClient`. No extension methods, included interfaces allow for easy unit test mocking, and straightforward properties allow for easier debugging (the response body is available as a string, byte array, and/or a typed object). + +## Contents +- [Installation](#installation) +- [Basic Usage](#basic-usage) + - [With Dependency Injection](#with-dependency-injection) + - [Without Dependency Injection](#without-dependency-injection) + - [Typed Responses](#typed-responses) +- [Requests](#requests) + - [Query String Parameters](#query-string-parameters) + - [Form URL Encoded Parameters](#form-url-encoded-parameters) + - [Headers](#headers) + - [Request Bodies](#request-bodies) +- [Streaming Responses](#streaming-responses) +- [Configuration](#configuration) + - [Timeouts](#timeouts) + - [Additional Successful Status Codes](#additional-successful-status-codes) + - [Custom Serializers](#custom-serializers) + - [Logging](#logging) ## Installation SimpleHttpClient is available on [NuGet](https://www.nuget.org/packages/SimpleHttpClient) and can installed through the NuGet Package Manager or by running ``` nuget install SimpleHttpClient ``` +The package targets `netstandard2.0` (for .NET Framework and older runtimes) and `net8.0`. On modern runtimes it uses `SocketsHttpHandler` with a pooled connection lifetime to keep DNS fresh; on `netstandard2.0` it periodically rotates the underlying `HttpClient` to achieve the same. ## Basic Usage + +### With Dependency Injection SimpleHttpClient is designed to be used with dependency injection in order to avoid pitfalls that come with using an `HttpClient`: In `Program.cs`: @@ -22,17 +45,19 @@ await Host.CreateDefaultBuilder(args) .RunAsync(); ``` -Then, in the class that will use the SimpleHttpClient: +Then, inject `ISimpleClientFactory` and create a client with the host you want to call. This is the +preferred approach: each consumer gets its own client, so there's no shared, mutable `Host` to +collide over. ```csharp public class YourClientClass { - private readonly SimpleClient client; + private readonly ISimpleClient client; - // Retrieve an ISimpleHttpClient through dependency injection - public YourClientClass(ISimpleHttpClient client) + // Retrieve an ISimpleClientFactory through dependency injection + public YourClientClass(ISimpleClientFactory clientFactory) { - // Set the host on the retrieved client - client.Host = "https://api.sampleapis.com"; + // Create a client for the host you'll be calling + client = clientFactory.CreateClient("https://api.sampleapis.com"); } public async Task MakeRequest() @@ -48,21 +73,17 @@ public class YourClientClass } ``` -You can also call MakeRequest with a type to serialize to that type: +You can also inject `ISimpleClient` directly and set its `Host` (it's registered as transient, so +each consumer gets its own instance): ```csharp -public async Task MakeRequest() +public YourClientClass(ISimpleClient client) { - // Pass the path you want to call into the Request constructor - var request = new SimpleRequest("/get"); - - // Call MakeRequest on the client, passing your request, and get your response back - var response = await client.MakeRequest(request); - - return response.Body; + client.Host = "https://api.sampleapis.com"; } ``` -If you're using SimpleHttpClient without dependency injection, you can just create an instance of SimpleClient: +### Without Dependency Injection +If you're using SimpleHttpClient without dependency injection, you can just create an instance of `SimpleClient`: ```csharp public class YourClientClass { @@ -86,4 +107,144 @@ public class YourClientClass } } ``` -NOTE: Although `SimpleClient` implements `IDisposable`, it should NOT be created inside a `using` block, but instead should be disposed with the class that uses it. \ No newline at end of file +NOTE: Although `SimpleClient` implements `IDisposable`, it should NOT be created inside a `using` block, but instead should be disposed with the class that uses it. + +### Typed Responses +You can also call `MakeRequest` with a type to deserialize the response body into that type: +```csharp +public async Task MakeRequest() +{ + // Pass the path you want to call into the SimpleRequest constructor + var request = new SimpleRequest("/get"); + + // Call MakeRequest on the client, passing your request, and get your response back + var response = await client.MakeRequest(request); + + return response.Body; +} +``` +The untyped `StringBody` and `ByteBody` are still available on a typed response. If deserialization fails, `response.Body` will be `null` and the thrown exception is available on `response.SerializationException`. + +## Requests +A `SimpleRequest` defaults to a `GET`. Pass an `HttpMethod` to change it: +```csharp +var request = new SimpleRequest("/post", HttpMethod.Post); +``` + +### Query String Parameters +Query string parameters can be added directly to the path, via the `QueryStringParameters` dictionary, or both. Values in `QueryStringParameters` take precedence over duplicates in the path: +```csharp +var request = new SimpleRequest("/get?param1=value1"); +request.QueryStringParameters.Add("param2", "value2"); +``` + +### Form URL Encoded Parameters +Add `application/x-www-form-urlencoded` parameters via the `FormUrlEncodedParameters` dictionary. When present, these take precedence over any body set on the request: +```csharp +var request = new SimpleRequest("/post", HttpMethod.Post); +request.FormUrlEncodedParameters.Add("param1", "value1"); +request.FormUrlEncodedParameters.Add("param2", "value2"); +``` + +### Headers +Headers set on the request are merged with the client's `DefaultHeaders` (request headers win on conflicts): +```csharp +// Sent with every request made by this client +client.DefaultHeaders["Authorization"] = "Bearer "; + +// Sent with just this request +var request = new SimpleRequest("/get"); +request.Headers["X-Custom-Header"] = "value"; +``` + +### Request Bodies +Pass an object as the body and it will be serialized using the client's serializer (JSON by default): +```csharp +var request = new SimpleRequest("/post", HttpMethod.Post, new +{ + param1 = "value1", + param2 = "value2", +}); +``` +Alternatively, set `request.StringBody` to send a pre-serialized string body. You can control the content type and encoding via `request.ContentType` and `request.ContentEncoding`. + +After a request is sent, the request reflects what was actually sent: for an object `Body`, `request.StringBody` holds the serialized payload, and `request.ContentType` holds the resolved content type — handy for logging and debugging. An object `Body` is the source of truth and is re-serialized on every send (so changing `Body` and re-sending the same request sends the new value); a string body is sent as-is. + +> **Note:** On .NET Framework, sending a `GET` request with a body isn't supported — its `HttpClient` is backed by `HttpWebRequest`, which disallows it — and SimpleClient surfaces this as a `NotSupportedException` with an explanatory message. This works fine on modern runtimes (`net8.0`+); if you need to target .NET Framework, send the body with a `POST`/`PUT`/etc. instead. + +## Streaming Responses +For responses you want to consume as they arrive — for example Server-Sent Events (SSE) or large downloads — use `MakeStreamRequest`. Unlike `MakeRequest`, it does **not** buffer the body into memory; it returns the live network stream as soon as the response headers are available. + +```csharp +var request = new SimpleRequest("/stream"); + +// The response holds the connection open, so dispose it when you're done (a using block is ideal). +using var response = await client.MakeStreamRequest(request); + +if (!response.IsSuccessful) +{ + // response.StatusCode and response.Headers are available immediately +} + +// response.Body is the raw, unbuffered network stream +using var reader = new StreamReader(response.Body); + +string line; +while ((line = await reader.ReadLineAsync()) != null) +{ + // Process each line as it arrives + Console.WriteLine(line); +} +``` + +A few things to keep in mind: +- **Dispose the response.** The underlying `HttpResponseMessage` and connection are held open until you dispose the returned `ISimpleStreamResponse`. A `using` block is the simplest way to guarantee this. +- **`MakeStreamRequest` accepts a `CancellationToken`.** Pass one to cancel sending the request, waiting for the headers, and reading the stream (e.g. when a user aborts mid-stream). The token is observed by reads too, even through a `StreamReader` that gives you no place to pass it — so the `await reader.ReadLineAsync()` loop above stops promptly when the token fires. Async reads honor it even mid-read; synchronous reads observe it between reads, so to abort a synchronous read already blocked on the socket, dispose the response. A caller-requested cancellation surfaces as an `OperationCanceledException`; a timeout still surfaces as a `TimeoutException`. +- **The body is yours to frame.** `SimpleStreamResponse.Body` is a plain `Stream`, leaving any protocol-specific framing (such as SSE `event:`/`data:` parsing) to you. + +## Configuration + +### Timeouts +The client `Timeout` defaults to 30 seconds and can be overridden per-request. Set the value to `-1` to disable the timeout. A request that exceeds its timeout throws a `TimeoutException`: +```csharp +client.Timeout = 60; // 60 seconds for all requests on this client + +var request = new SimpleRequest("/slow"); +request.TimeoutOverride = 120; // 120 seconds for just this request +``` +For streaming requests, the timeout applies to receiving the response headers — not to how long you spend reading the stream. + +### Additional Successful Status Codes +By default, `IsSuccessful` is `true` for any 2xx status code. You can mark additional status codes as successful on the client (applies to all requests) and/or per-request: +```csharp +client.AdditionalSuccessfulStatusCodes.Add(HttpStatusCode.NotFound); + +var request = new SimpleRequest("/get"); +request.AdditionalSuccessfulStatusCodes.Add(HttpStatusCode.NotAcceptable); +``` + +### Custom Serializers +SimpleHttpClient ships with JSON (default) and XML serializers, and uses the serializer to both serialize request bodies and deserialize typed responses. Set one on the client, or override it per-request: +```csharp +client.Serializer = new SimpleHttpDefaultXmlSerializer(); + +var request = new SimpleRequest("/get"); +request.SerializerOverride = new SimpleHttpDefaultJsonSerializer(); +``` +You can supply your own serializer by implementing `ISimpleHttpSerializer`. + +#### System.Text.Json +The default JSON serializer uses `Newtonsoft.Json`. A `System.Text.Json`-based serializer is also included and can be opted into the same way: +```csharp +client.Serializer = new SimpleHttpSystemTextJsonSerializer(); +``` +Its settings mirror the default (camelCase names, null values omitted, indented output, case-insensitive deserialization), so it's a drop-in for most payloads. Note that `System.Text.Json` is stricter than `Newtonsoft.Json` — most notably it can't use a non-public parameterless constructor when deserializing, so such types need a public constructor or a `[JsonConstructor]`. + +> **Heads up:** `SimpleHttpSystemTextJsonSerializer` is slated to become the default in the next major version (v5), at which point the `Newtonsoft.Json` dependency will be removed. + +### Logging +You can log requests and responses by setting the `LogRequest` and `LogResponse` delegates (called immediately before a request is sent and immediately after a response is received), or by providing an `ISimpleHttpLogger`: +```csharp +client.LogRequest = (url, request) => Console.WriteLine($"--> {request.Method} {url}"); +client.LogResponse = (response) => Console.WriteLine($"<-- {response.StatusCode}"); +``` diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultXmlSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultXmlSerializerTests.cs index 9877781..3650c14 100644 --- a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultXmlSerializerTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpDefaultXmlSerializerTests.cs @@ -38,7 +38,16 @@ public void Serialization_Succeeds() var serialized = testObject.Serialize(objectToSerialize); - Assert.Equal(TestSerializationString, serialized); + var expected = TestSerializationString; +#if NETFRAMEWORK + // .NET Framework's XmlSerializer declares xmlns:xsd before xmlns:xsi (modern + // runtimes emit them in the opposite order). + expected = expected.Replace( + @"xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema""", + @"xmlns:xsd=""http://www.w3.org/2001/XMLSchema"" xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"""); +#endif + + Assert.Equal(expected, serialized); } [Fact] diff --git a/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs new file mode 100644 index 0000000..6085450 --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/Serialization/SimpleHttpSystemTextJsonSerializerTests.cs @@ -0,0 +1,90 @@ +using SimpleHttpClient.Serialization; + +namespace SimpleHttpClient.Tests.Serialization +{ + public class SimpleHttpSystemTextJsonSerializerTests + { + // Same shape and formatting the Newtonsoft-based default produces, so the + // System.Text.Json serializer is verified to be a drop-in for typical payloads. + private const string TestSerializationString = +@"{ + ""property1"": ""property1 value"", + ""property2"": 12, + ""property3"": true +}"; + + [Fact] + public void RoundTrip_Succeeds() + { + var objectToSerialize = new TestSerializationObject(); + + var testObject = new SimpleHttpSystemTextJsonSerializer(); + + var serialized = testObject.Serialize(objectToSerialize); + + var deserializedObject = testObject.Deserialize(serialized); + + Assert.NotNull(deserializedObject); + Assert.Equal(objectToSerialize.Property1, deserializedObject.Property1); + Assert.Equal(objectToSerialize.Property2, deserializedObject.Property2); + Assert.Equal(objectToSerialize.Property3, deserializedObject.Property3); + } + + [Fact] + public void Serialization_ProducesCamelCaseIndentedJson() + { + var objectToSerialize = new TestSerializationObject(); + + var testObject = new SimpleHttpSystemTextJsonSerializer(); + + var serialized = testObject.Serialize(objectToSerialize); + + // Normalize line endings so the comparison doesn't depend on the + // platform's newline or the checkout's git autocrlf setting. + Assert.Equal(Normalize(TestSerializationString), Normalize(serialized)); + } + + [Fact] + public void Deserialization_IsCaseInsensitive() + { + // PascalCase input must still bind even though we serialize camelCase, + // matching Newtonsoft's default leniency. + const string pascalCaseJson = +@"{ + ""Property1"": ""property1 value"", + ""Property2"": 12, + ""Property3"": true +}"; + + var testObject = new SimpleHttpSystemTextJsonSerializer(); + + var deserializedObject = testObject.Deserialize(pascalCaseJson); + + Assert.NotNull(deserializedObject); + Assert.Equal("property1 value", deserializedObject.Property1); + Assert.Equal(12, deserializedObject.Property2); + Assert.True(deserializedObject.Property3); + } + + [Fact] + public void Serialization_OmitsNullValues() + { + var testObject = new SimpleHttpSystemTextJsonSerializer(); + + var serialized = testObject.Serialize(new NullableSerializationObject()); + + // The null property is dropped entirely; the non-null one stays. + Assert.DoesNotContain("absent", serialized); + Assert.Contains("\"value\": 1", serialized); + } + + private static string Normalize(string value) => value.Replace("\r\n", "\n"); + + private class NullableSerializationObject + { + public string? Absent { get; set; } = null; + + public int Value { get; set; } = 1; + } + } +} diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientCancellationAndHeaderTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientCancellationAndHeaderTests.cs new file mode 100644 index 0000000..c290edc --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientCancellationAndHeaderTests.cs @@ -0,0 +1,100 @@ +using SimpleHttpClient.Models; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace SimpleHttpClient.Tests +{ + public class SimpleClientCancellationAndHeaderTests + { + [Fact] + public async Task MakeRequest_WithCancelledToken_ThrowsOperationCanceled() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/get").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("ok")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/get"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // A caller-requested cancellation should surface as OperationCanceledException, + // not be misreported as a TimeoutException like the timeout path is. + await Assert.ThrowsAnyAsync( + async () => await client.MakeRequest(request, cts.Token)); + + server.Stop(); + } + + [Fact] + public async Task MakeRequestTyped_WithCancelledToken_ThrowsOperationCanceled() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/get").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBodyAsJson(new { value = "ok" })); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/get"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await client.MakeRequest(request, cts.Token)); + + server.Stop(); + } + + [Fact] + public async Task Request_WithCustomRequestHeader_IsSent() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/get").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("ok")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/get"); + request.Headers["X-Custom"] = "custom-value"; + + var response = await client.MakeRequest(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var received = server.LogEntries.First().RequestMessage; + Assert.Contains(received.Headers.Keys, k => k.Equals("X-Custom", StringComparison.OrdinalIgnoreCase)); + + server.Stop(); + } + + [Fact] + public async Task Request_WithContentLevelHeader_DoesNotThrow_AndIsSent() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/post").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("ok")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/post", HttpMethod.Post, new { value = "test" }); + + // Content-Disposition is a content-level header; setting it on the request + // previously threw because it can't be added to the request headers collection. + request.Headers["Content-Disposition"] = "form-data; name=\"field\""; + + var response = await client.MakeRequest(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var received = server.LogEntries.First().RequestMessage; + Assert.Contains(received.Headers.Keys, k => k.Equals("Content-Disposition", StringComparison.OrdinalIgnoreCase)); + + server.Stop(); + } + } +} diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientFactoryTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientFactoryTests.cs new file mode 100644 index 0000000..5ddd6cc --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientFactoryTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleHttpClient.Extensions; +using SimpleHttpClient.Models; +using System.Net; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace SimpleHttpClient.Tests +{ + public class SimpleClientFactoryTests + { + [Fact] + public void Factory_IsRegistered_AndCreatesClientWithHost() + { + var provider = BuildProvider(); + + var factory = provider.GetRequiredService(); + var client = factory.CreateClient("https://example.com"); + + Assert.NotNull(client); + Assert.Equal("https://example.com", client.Host); + } + + [Fact] + public void Factory_CreatesIndependentClients_WithSeparateHosts() + { + var provider = BuildProvider(); + var factory = provider.GetRequiredService(); + + var client1 = factory.CreateClient("https://one.com"); + var client2 = factory.CreateClient("https://two.com"); + + Assert.NotSame(client1, client2); + Assert.Equal("https://one.com", client1.Host); + Assert.Equal("https://two.com", client2.Host); + } + + [Fact] + public void ISimpleClient_IsRegisteredAsTransient() + { + var provider = BuildProvider(); + + var client1 = provider.GetRequiredService(); + var client2 = provider.GetRequiredService(); + + // Transient => a new instance per resolution, so setting Host on one + // consumer's client doesn't stomp on another's. + Assert.NotSame(client1, client2); + } + + [Fact] + public async Task FactoryCreatedClient_CanMakeRequests() + { + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/get").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("ok")); + + var provider = BuildProvider(); + var factory = provider.GetRequiredService(); + + var client = factory.CreateClient(server.Url); + var response = await client.MakeRequest(new SimpleRequest("/get")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + server.Stop(); + } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddSimpleHttpClient(); + return services.BuildServiceProvider(); + } + } +} diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs index 6fcc43c..05eba09 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleClientTests.cs @@ -205,6 +205,22 @@ public async Task Request_WithBody_Succeeds() Assert.Equal("value2", response.Body?.Data?.Param2); } +#if NETFRAMEWORK + [Fact] + public async Task Get_Request_WithBody_ThrowsOnNetFramework() + { + // .NET Framework's HttpClient (backed by HttpWebRequest) rejects a GET with a body; + // SimpleClient surfaces this as a NotSupportedException with an actionable message. + var request = new SimpleRequest("/get", HttpMethod.Get, new + { + param1 = "value1", + param2 = "value2", + }); + + await Assert.ThrowsAsync( + async () => await client.MakeRequest(request)); + } +#else [Fact] public async Task Get_Request_WithBody_Succeeds() { @@ -220,6 +236,7 @@ public async Task Get_Request_WithBody_Succeeds() Assert.Equal("value1", response.Body?.Args?.Param1); Assert.Equal("value2", response.Body?.Args?.Param2); } +#endif [Fact] public async Task Request_WithUrlFormEncodedParameters_Overwrites_CustomContentType() @@ -279,15 +296,16 @@ public async Task UrlFormEncodedParamaters_OverwritesBody() } [Fact] - public async Task StringBody_OverwritesBody() + public async Task ObjectBody_TakesPrecedenceOver_StringBody() { var request = new SimpleRequest("/post", HttpMethod.Post, new { - param1 = "willbeoverwritten", - param2 = "alsooverwritten", + param1 = "value1", + param2 = "value2", }); - request.StringBody = "{ \"param1\": \"value1\", \"param2\": \"value2\"}"; + // A directly-set StringBody does not override an object Body - Body is the source of truth. + request.StringBody = "{ \"param1\": \"ignored\", \"param2\": \"ignored\"}"; var response = await client.MakeRequest(request); @@ -404,6 +422,21 @@ public async Task StringBody_IsSet_ToSerializedBody() Assert.Equal("value2", body["param2"]?.ToString()); } + [Fact] + public async Task ResendingRequest_WithChangedBody_SendsTheNewBody() + { + var request = new SimpleRequest("/post", HttpMethod.Post, new { param1 = "first" }); + + var first = await client.MakeRequest(request); + Assert.Equal("first", first.Body?.Data?.Param1); + + // Changing Body and re-sending the same request object must send the new body. + request.Body = new { param1 = "second" }; + + var second = await client.MakeRequest(request); + Assert.Equal("second", second.Body?.Data?.Param1); + } + [Fact] public async Task LogMethods_AreCalled() { @@ -433,10 +466,17 @@ public async Task Timeout_Throws_TimeoutException() [Fact] public async Task Timeout_WaitsTheCorrectAmountOfTime() { - var client = new SimpleClient("http://localhost/some/nonexistant/path"); + // Use a server that delays well past the timeout so the timeout reliably fires, + // rather than relying on a connection to a dead host hanging (which fails fast + // with a connection-refused on some platforms instead of timing out). + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/slow").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithDelay(TimeSpan.FromSeconds(30))); + + var client = new SimpleClient(server.Url); client.Timeout = 2; - var request = new SimpleRequest("/get"); + var request = new SimpleRequest("/slow"); Exception? exception = null; @@ -468,6 +508,8 @@ public async Task Timeout_WaitsTheCorrectAmountOfTime() await Task.WhenAll(new[] { task1, task2 }); Assert.IsType(exception); + + server.Stop(); } [Fact] @@ -569,7 +611,7 @@ public async Task LogResponse_IsCalled_WhenNotNull() "get" => HttpMethod.Get, "post" => HttpMethod.Post, "put" => HttpMethod.Put, - "patch" => HttpMethod.Patch, + "patch" => new HttpMethod("PATCH"), "delete" => HttpMethod.Delete, _ => HttpMethod.Get, }; diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj b/src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj index a04437d..7988e04 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj +++ b/src/SimpleHttpClient.Tests.Integration/SimpleHttpClient.Tests.csproj @@ -1,17 +1,28 @@ - net6.0 + + net10.0;net48 + latest enable enable false + + + + + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs index b9298e3..ff87de7 100644 --- a/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs +++ b/src/SimpleHttpClient.Tests.Integration/SimpleRequestTests.cs @@ -1,10 +1,13 @@ -using Moq; +using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System.Net; using System.Text; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; namespace SimpleHttpClient.Tests { @@ -100,10 +103,16 @@ public async Task AdditionalSuccessfullStatusCodes_AreUsedFor_IsSuccess() [Fact] public async Task TimeoutOverride_OverridesClientTimeout() { - var client = new SimpleClient("http://localhost/some/nonexistant/path"); - client.Timeout = 3; + // A server that delays past the timeout, so the timeout reliably fires + // (rather than depending on a connection to a dead host hanging). + var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath("/slow").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithDelay(TimeSpan.FromSeconds(30))); - var request = new SimpleRequest("/get"); + var client = new SimpleClient(server.Url); + client.Timeout = 5; + + var request = new SimpleRequest("/slow"); request.TimeoutOverride = 1; Exception? exception = null; @@ -136,6 +145,8 @@ public async Task TimeoutOverride_OverridesClientTimeout() await Task.WhenAll(new[] { task1, task2 }); Assert.IsType(exception); + + server.Stop(); } [Fact] diff --git a/src/SimpleHttpClient.Tests.Integration/SimpleStreamResponseTests.cs b/src/SimpleHttpClient.Tests.Integration/SimpleStreamResponseTests.cs new file mode 100644 index 0000000..c3b3f41 --- /dev/null +++ b/src/SimpleHttpClient.Tests.Integration/SimpleStreamResponseTests.cs @@ -0,0 +1,174 @@ +using SimpleHttpClient.Models; +using System.Net; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace SimpleHttpClient.Tests +{ + public class SimpleStreamResponseTests + { + [Fact] + public async Task StreamRequest_ReturnsReadableBodyStream() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("line1\nline2\nline3")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + using var response = await client.MakeStreamRequest(request); + + using var reader = new StreamReader(response.Body); + var body = await reader.ReadToEndAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessful); + Assert.Equal("line1\nline2\nline3", body); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_PopulatesHeaders() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("X-Custom-Header", "customValue") + .WithBody("body")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + using var response = await client.MakeStreamRequest(request); + + Assert.True(response.Headers.ContainsKey("X-Custom-Header")); + Assert.Equal("customValue", response.Headers["X-Custom-Header"]); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_HonorsAdditionalSuccessfulStatusCodes() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.NotFound).WithBody("nope")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + request.AdditionalSuccessfulStatusCodes.Add(HttpStatusCode.NotFound); + + using var response = await client.MakeStreamRequest(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.True(response.IsSuccessful); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_WithCancelledToken_ThrowsOperationCanceled() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("body")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // A caller-requested cancellation should surface as OperationCanceledException, + // not be misreported as a TimeoutException like the timeout path is. + await Assert.ThrowsAnyAsync( + async () => await client.MakeStreamRequest(request, cts.Token)); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_CancellingAfterHeaders_CancelsDirectReads() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("line1\nline2\nline3")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + using var cts = new CancellationTokenSource(); + using var response = await client.MakeStreamRequest(request, cts.Token); + + // The token is baked into the returned stream, so reads observe it even + // when the caller has no per-read token to pass. + cts.Cancel(); + + var buffer = new byte[16]; + await Assert.ThrowsAnyAsync( + async () => await response.Body.ReadAsync(buffer, 0, buffer.Length)); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_CancellingAfterHeaders_CancelsStreamReaderReads() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("line1\nline2\nline3")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + using var cts = new CancellationTokenSource(); + using var response = await client.MakeStreamRequest(request, cts.Token); + + using var reader = new StreamReader(response.Body); + + // StreamReader gives no place to pass a token, but its internal reads + // flow through the wrapper and pick up the baked-in token. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await reader.ReadLineAsync()); + + server.Stop(); + } + + [Fact] + public async Task StreamRequest_Dispose_IsCleanAndIdempotent() + { + var server = WireMockServer.Start(); + + server.Given(Request.Create().WithPath("/stream").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK).WithBody("body")); + + var client = new SimpleClient(server.Url); + var request = new SimpleRequest("/stream"); + + var response = await client.MakeStreamRequest(request); + + // Disposing should release the connection without throwing, and be safe to call twice. + var exception = Record.Exception(() => + { + response.Dispose(); + response.Dispose(); + }); + + Assert.Null(exception); + + server.Stop(); + } + } +} diff --git a/src/SimpleHttpClient/Extensions/IServiceCollectionExtensions.cs b/src/SimpleHttpClient/Extensions/IServiceCollectionExtensions.cs index 4dca04c..3230893 100644 --- a/src/SimpleHttpClient/Extensions/IServiceCollectionExtensions.cs +++ b/src/SimpleHttpClient/Extensions/IServiceCollectionExtensions.cs @@ -30,7 +30,11 @@ public static IServiceCollection AddSimpleHttpClient(this IServiceCollection ser services.AddScoped(getLogger); } - services.AddScoped(); + // Registered as transient so each consumer gets its own client instance. This avoids + // a shared, mutable Host being stomped on when multiple consumers share a scope. + // For most scenarios, prefer injecting ISimpleClientFactory and calling CreateClient. + services.AddTransient(); + services.AddSingleton(); return services; } diff --git a/src/SimpleHttpClient/HttpClientConfigurator.cs b/src/SimpleHttpClient/HttpClientConfigurator.cs index af5eb9b..71253db 100644 --- a/src/SimpleHttpClient/HttpClientConfigurator.cs +++ b/src/SimpleHttpClient/HttpClientConfigurator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Net.Http; @@ -10,10 +10,24 @@ namespace SimpleHttpClient internal static class HttpClientConfigurator { /// - /// Create a message handler with opinionated default settings. + /// Create a new HttpClient with the opinionated default handler and settings applied. /// - public static HttpClientHandler GetMessageHandler() + public static HttpClient GetConfiguredHttpClient() { + var client = new HttpClient(GetMessageHandler()); + + ConfigureHttpClient(client); + + return client; + } + + /// + /// Create a message handler with opinionated default settings. Used directly when + /// registering the named HttpClient with an IHttpClientFactory. + /// + public static HttpMessageHandler GetMessageHandler() + { +#if NETSTANDARD2_0 var handler = new HttpClientHandler(); // The checks/error handling below are thanks to Flurl's sourcecode @@ -38,10 +52,23 @@ public static HttpClientHandler GetMessageHandler() } return handler; +#else + // On modern runtimes SocketsHttpHandler rotates pooled connections on its own via + // PooledConnectionLifetime, which keeps DNS fresh without replacing the HttpClient + // (so none of the timer/replacement machinery the netstandard2.0 build needs). + return new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + UseCookies = false, + AllowAutoRedirect = true, + AutomaticDecompression = DecompressionMethods.All, + }; +#endif } /// - /// Configure the given HttpClient with opinionated default settings. + /// Configure the given HttpClient with opinionated default settings. Used directly when + /// registering the named HttpClient with an IHttpClientFactory. /// public static void ConfigureHttpClient(HttpClient client) { diff --git a/src/SimpleHttpClient/HttpClientProviderFactory.cs b/src/SimpleHttpClient/HttpClientProviderFactory.cs new file mode 100644 index 0000000..1686b62 --- /dev/null +++ b/src/SimpleHttpClient/HttpClientProviderFactory.cs @@ -0,0 +1,22 @@ +using System.Net.Http; + +namespace SimpleHttpClient +{ + /// + /// Creates the appropriate for the target framework. + /// This is the single place that varies by framework, keeping SimpleClient free of + /// conditional compilation. + /// + internal static class HttpClientProviderFactory + { + /// + /// Create a provider, optionally backed by an IHttpClientFactory. + /// + public static IHttpClientProvider Create(IHttpClientFactory httpClientFactory) => +#if NETSTANDARD2_0 + new RotatingHttpClientProvider(httpClientFactory); +#else + new PooledHttpClientProvider(httpClientFactory); +#endif + } +} diff --git a/src/SimpleHttpClient/IHttpClientProvider.cs b/src/SimpleHttpClient/IHttpClientProvider.cs new file mode 100644 index 0000000..9718fb8 --- /dev/null +++ b/src/SimpleHttpClient/IHttpClientProvider.cs @@ -0,0 +1,17 @@ +using System; +using System.Net.Http; + +namespace SimpleHttpClient +{ + /// + /// Provides the HttpClient used to send requests, encapsulating the platform-specific + /// strategy for keeping connections and DNS fresh over the client's lifetime. + /// + internal interface IHttpClientProvider : IDisposable + { + /// + /// Get an HttpClient to send a request with. + /// + HttpClient GetClient(); + } +} diff --git a/src/SimpleHttpClient/ISimpleClient.cs b/src/SimpleHttpClient/ISimpleClient.cs index 3b3f01f..96bebae 100644 --- a/src/SimpleHttpClient/ISimpleClient.cs +++ b/src/SimpleHttpClient/ISimpleClient.cs @@ -3,6 +3,7 @@ using SimpleHttpClient.Serialization; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; namespace SimpleHttpClient @@ -61,16 +62,34 @@ public interface ISimpleClient /// Make an untyped request. /// /// The request that will be sent. + /// A token to cancel the request. /// A response object without a strongly-typed body property. - Task MakeRequest(ISimpleRequest request); + Task MakeRequest(ISimpleRequest request, CancellationToken cancellationToken = default); /// /// Make a typed request. /// /// The type the response body will be serialized into. /// The request that will be sent. + /// A token to cancel the request. /// A response object with a strongly-typed body property. - Task> MakeRequest(ISimpleRequest request); + Task> MakeRequest(ISimpleRequest request, CancellationToken cancellationToken = default); + + /// + /// Make a request and get back the live, unbuffered response stream. + /// The body is not read into memory; the connection is held open until the + /// returned is disposed, so callers should + /// dispose it (ideally with a using block) once they're done reading. + /// + /// The request that will be sent. + /// + /// A token to cancel sending the request, waiting for the response headers, and reading + /// from the returned stream. Async reads honor it even mid-read; synchronous reads observe + /// it between reads. To abort a synchronous read already blocked on the socket, dispose the + /// response. + /// + /// A disposable response exposing the raw response stream. + Task MakeStreamRequest(ISimpleRequest request, CancellationToken cancellationToken = default); /// /// Get the URL the given request will be sent to by this client. diff --git a/src/SimpleHttpClient/ISimpleClientFactory.cs b/src/SimpleHttpClient/ISimpleClientFactory.cs new file mode 100644 index 0000000..22dcb4b --- /dev/null +++ b/src/SimpleHttpClient/ISimpleClientFactory.cs @@ -0,0 +1,18 @@ +namespace SimpleHttpClient +{ + /// + /// A factory for creating instances, each with their own host. + /// This is the preferred way to obtain a client when using dependency injection, as it avoids + /// sharing a single mutable client (and its Host) across consumers. + /// + public interface ISimpleClientFactory + { + /// + /// Create a new . + /// + /// The base url all requests sent through the client will use. If not + /// provided, it is assumed that the path property on requests will be full URLs. + /// A new client instance. + ISimpleClient CreateClient(string host = null); + } +} diff --git a/src/SimpleHttpClient/Models/CancellationAwareStream.cs b/src/SimpleHttpClient/Models/CancellationAwareStream.cs new file mode 100644 index 0000000..2221ba7 --- /dev/null +++ b/src/SimpleHttpClient/Models/CancellationAwareStream.cs @@ -0,0 +1,92 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SimpleHttpClient.Models +{ + /// + /// Wraps the raw response stream so the cancellation token supplied to + /// MakeStreamRequest is observed by reads, not just by sending the + /// request. The token is baked into the wrapper, so even readers that give + /// you no place to pass a token (such as ) still + /// honor cancellation through the reads they make internally. + /// + /// + /// Async reads link the baked-in token with any per-call token, so a + /// cancellation surfaces as an even + /// mid-read. Synchronous reads observe cancellation between reads; a single + /// synchronous read already blocked on the socket can only be aborted by + /// disposing the response. + /// + internal sealed class CancellationAwareStream : Stream + { + private readonly Stream inner; + private readonly CancellationToken token; + + public CancellationAwareStream(Stream inner, CancellationToken token) + { + this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); + this.token = token; + } + + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => inner.Position = value; + } + + public override void Flush() => inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + token.ThrowIfCancellationRequested(); + return inner.Read(buffer, offset, count); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, cancellationToken)) + { + return await inner.ReadAsync(buffer, offset, count, linked.Token).ConfigureAwait(false); + } + } + +#if NET8_0_OR_GREATER + public override int Read(Span buffer) + { + token.ThrowIfCancellationRequested(); + return inner.Read(buffer); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, cancellationToken)) + { + return await inner.ReadAsync(buffer, linked.Token).ConfigureAwait(false); + } + } +#endif + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs b/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs new file mode 100644 index 0000000..0a65730 --- /dev/null +++ b/src/SimpleHttpClient/Models/ISimpleStreamResponse.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; + +namespace SimpleHttpClient.Models +{ + /// + /// A streaming HTTP response that exposes the raw, unbuffered network stream. + /// The underlying connection is held open until this object is disposed, so callers + /// should dispose it (ideally with a using block) once they're done reading. + /// + public interface ISimpleStreamResponse : IDisposable + { + /// + /// The response status code. + /// + HttpStatusCode StatusCode { get; } + + /// + /// Whether or not the request was successful. + /// + bool IsSuccessful { get; } + + /// + /// The response headers. + /// + Dictionary Headers { get; } + + /// + /// The raw, unbuffered network stream containing the response body. + /// Read from this directly to consume the response as it arrives. + /// + Stream Body { get; } + } +} diff --git a/src/SimpleHttpClient/Models/SimpleStreamResponse.cs b/src/SimpleHttpClient/Models/SimpleStreamResponse.cs new file mode 100644 index 0000000..1fe016d --- /dev/null +++ b/src/SimpleHttpClient/Models/SimpleStreamResponse.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; + +namespace SimpleHttpClient.Models +{ + /// + /// A streaming HTTP response that exposes the raw, unbuffered network stream. + /// + public class SimpleStreamResponse : ISimpleStreamResponse + { + private readonly HttpResponseMessage httpResponse; + private bool disposedValue; + + /// + /// Creates a stream response that wraps the given HttpResponseMessage and body stream. + /// The HttpResponseMessage is owned by this object and disposed along with it. + /// + /// The HttpResponseMessage whose lifetime is tied to this response. + /// The raw, unbuffered network stream containing the response body. + public SimpleStreamResponse(HttpResponseMessage httpResponse, Stream body) + { + this.httpResponse = httpResponse; + Body = body; + } + + /// + /// The response status code. + /// + public HttpStatusCode StatusCode { get; set; } + + /// + /// Whether or not the request was successful. + /// + public bool IsSuccessful { get; set; } + + /// + /// The response headers. + /// + public Dictionary Headers { get; private set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// The raw, unbuffered network stream containing the response body. + /// + public Stream Body { get; private set; } + + /// + /// Dispose. + /// + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + Body?.Dispose(); + httpResponse?.Dispose(); + } + + disposedValue = true; + } + } + + /// + /// Dispose, releasing the underlying network stream and connection. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/SimpleHttpClient/PooledHttpClientProvider.cs b/src/SimpleHttpClient/PooledHttpClientProvider.cs new file mode 100644 index 0000000..644804b --- /dev/null +++ b/src/SimpleHttpClient/PooledHttpClientProvider.cs @@ -0,0 +1,54 @@ +#if NET8_0_OR_GREATER +using System.Net.Http; + +namespace SimpleHttpClient +{ + /// + /// HttpClient provider for modern runtimes. The handler created by HttpClientConfigurator + /// is a SocketsHttpHandler with a pooled connection lifetime, which keeps DNS fresh by + /// rotating connections - so a single client can be created once and reused. + /// + internal sealed class PooledHttpClientProvider : IHttpClientProvider + { + private readonly IHttpClientFactory httpClientFactory; + private readonly object clientLock = new object(); + + private HttpClient httpClient; + private bool disposedValue; + + public PooledHttpClientProvider(IHttpClientFactory httpClientFactory) + { + this.httpClientFactory = httpClientFactory; + } + + public HttpClient GetClient() + { + if (httpClientFactory != null) + { + return httpClientFactory.CreateClient(Constants.HttpClientNameString); + } + + lock (clientLock) + { + if (httpClient == null) + { + httpClient = HttpClientConfigurator.GetConfiguredHttpClient(); + } + + return httpClient; + } + } + + public void Dispose() + { + if (disposedValue) + { + return; + } + + httpClient?.Dispose(); + disposedValue = true; + } + } +} +#endif diff --git a/src/SimpleHttpClient/RotatingHttpClientProvider.cs b/src/SimpleHttpClient/RotatingHttpClientProvider.cs new file mode 100644 index 0000000..5f1de39 --- /dev/null +++ b/src/SimpleHttpClient/RotatingHttpClientProvider.cs @@ -0,0 +1,122 @@ +#if NETSTANDARD2_0 +using SimpleHttpClient.Extensions; +using System; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace SimpleHttpClient +{ + /// + /// HttpClient provider for netstandard2.0 (and .NET Framework), which can't use + /// SocketsHttpHandler.PooledConnectionLifetime. To keep DNS fresh it periodically replaces + /// the HttpClient, disposing the retired one after a grace period so in-flight requests can + /// finish. Ref: https://github.com/dotnet/runtime/issues/18348 + /// + internal sealed class RotatingHttpClientProvider : IHttpClientProvider + { + private const double ReplacementIntervalMs = 300000; // 5 minutes + + // How long a retired HttpClient is kept alive before being disposed, so in-flight + // requests (and reasonably-lived streams) using it can finish first. + private const int DisposeDelayMs = 300000; // 5 minutes + + private readonly IHttpClientFactory httpClientFactory; + private readonly object clientLock = new object(); + + private HttpClient httpClient; + private System.Timers.Timer replacementTimer; + private bool disposedValue; + + public RotatingHttpClientProvider(IHttpClientFactory httpClientFactory) + { + this.httpClientFactory = httpClientFactory; + } + + public HttpClient GetClient() + { + var hasFactory = httpClientFactory != null; + + // With a factory on a non-Framework runtime, the factory pools and rotates handlers, + // so just hand back a fresh client per request - no caching or rotation needed here. + if (hasFactory && !IsDotNetFramework) + { + return CreateClient(useFactory: true); + } + + // Otherwise (no factory, or on .NET Framework) cache a single client and rotate it + // periodically to keep DNS fresh. + lock (clientLock) + { + SetupTimerIfNeeded(hasFactory); + + if (httpClient == null) + { + httpClient = CreateClient(hasFactory); + } + + return httpClient; + } + } + + private static bool IsDotNetFramework => + RuntimeInformation.FrameworkDescription.Contains("Framework", StringComparison.OrdinalIgnoreCase); + + // Create either a factory-managed client or our own configured client. + private HttpClient CreateClient(bool useFactory) => + useFactory + ? httpClientFactory.CreateClient(Constants.HttpClientNameString) + : HttpClientConfigurator.GetConfiguredHttpClient(); + + // Callers must hold clientLock. + private void SetupTimerIfNeeded(bool useFactory) + { + if (replacementTimer == null) + { + replacementTimer = new System.Timers.Timer(); + replacementTimer.Elapsed += (sender, e) => ReplaceClient(useFactory); + replacementTimer.Interval = ReplacementIntervalMs; + replacementTimer.AutoReset = true; + replacementTimer.Start(); + } + } + + private void ReplaceClient(bool useFactory) + { + HttpClient retiredClient; + + lock (clientLock) + { + retiredClient = httpClient; + httpClient = CreateClient(useFactory); + } + + // Only dispose clients we own. Factory-created clients are managed by the factory. + if (retiredClient != null && !useFactory) + { + _ = DisposeAfterDelayAsync(retiredClient); + } + } + + private async Task DisposeAfterDelayAsync(HttpClient retiredClient) + { + await Task.Delay(DisposeDelayMs).ConfigureAwait(false); + + retiredClient.Dispose(); + } + + public void Dispose() + { + if (disposedValue) + { + return; + } + + replacementTimer?.Stop(); + replacementTimer?.Dispose(); + httpClient?.Dispose(); + disposedValue = true; + } + } +} +#endif diff --git a/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs new file mode 100644 index 0000000..b85158f --- /dev/null +++ b/src/SimpleHttpClient/Serialization/SimpleHttpSystemTextJsonSerializer.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SimpleHttpClient.Serialization +{ + /// + /// A JSON serializer backed by System.Text.Json. Opt in by setting it on the + /// client (or per-request) instead of the Newtonsoft-based default. Its settings + /// mirror the default serializer's behavior (camelCase names, null values omitted, + /// indented output, case-insensitive deserialization) so it's a drop-in for most + /// payloads. + /// + /// + /// System.Text.Json is stricter than Newtonsoft.Json. Notably, it cannot use a + /// non-public parameterless constructor when deserializing; such types need a public + /// constructor or a . This serializer is slated + /// to become the default in a future major version. + /// + public class SimpleHttpSystemTextJsonSerializer : ISimpleHttpSerializer + { + // Reused across calls: System.Text.Json caches type metadata per options + // instance, so sharing one avoids repeating that work on every (de)serialize. + private static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + /// + /// Serialize the given object into a string. + /// + public string Serialize(object obj) => JsonSerializer.Serialize(obj, Options); + + /// + /// Deserialize the given string into an object of type T. + /// + public T Deserialize(string data) => JsonSerializer.Deserialize(data, Options); + } +} diff --git a/src/SimpleHttpClient/SimpleClient.cs b/src/SimpleHttpClient/SimpleClient.cs index aa72491..0b45b99 100644 --- a/src/SimpleHttpClient/SimpleClient.cs +++ b/src/SimpleHttpClient/SimpleClient.cs @@ -1,13 +1,14 @@ -using SimpleHttpClient.Extensions; +using SimpleHttpClient.Extensions; using SimpleHttpClient.Logging; using SimpleHttpClient.Models; using SimpleHttpClient.Serialization; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; -using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -19,10 +20,7 @@ namespace SimpleHttpClient /// public class SimpleClient : ISimpleClient, IDisposable { - private readonly IHttpClientFactory httpClientFactory = null; - - private HttpClient httpClient = null; - private System.Timers.Timer httpClientReplacementTimer = null; + private readonly IHttpClientProvider httpClientProvider; private bool disposedValue; /// @@ -41,7 +39,7 @@ public SimpleClient(string host = null, LogRequest logRequest = null, LogResponse logResponse = null) { - this.httpClientFactory = httpClientFactory; + httpClientProvider = HttpClientProviderFactory.Create(httpClientFactory); Host = host; Serializer = serializer ?? new SimpleHttpDefaultJsonSerializer(); @@ -99,18 +97,71 @@ public SimpleClient(string host = null, /// Make an untyped request. /// /// The request that will be sent. + /// A token to cancel the request. /// A response object without a strongly-typed body property. - public async Task MakeRequest(ISimpleRequest request) => - await MakeRequestInternal(request, new SimpleResponse(request.Id), AddResponseBody).ConfigureAwait(false); + public async Task MakeRequest(ISimpleRequest request, CancellationToken cancellationToken = default) => + await MakeRequestInternal(request, new SimpleResponse(request.Id), AddResponseBody, cancellationToken).ConfigureAwait(false); /// /// Make a typed request. /// /// The type the response body will be serialized into. /// The request that will be sent. + /// A token to cancel the request. /// A response object with a strongly-typed body property. - public async Task> MakeRequest(ISimpleRequest request) => - await MakeRequestInternal(request, new SimpleResponse(request.Id), AddResponseBody).ConfigureAwait(false); + public async Task> MakeRequest(ISimpleRequest request, CancellationToken cancellationToken = default) => + await MakeRequestInternal(request, new SimpleResponse(request.Id), AddResponseBody, cancellationToken).ConfigureAwait(false); + + /// + /// Make a request and get back the live, unbuffered response stream. + /// The body is not read into memory; the connection is held open until the + /// returned ISimpleStreamResponse is disposed, so callers should dispose it + /// (ideally with a using block) once they're done reading. + /// + /// The request that will be sent. + /// + /// A token to cancel sending the request, waiting for the response headers, and reading + /// from the returned stream. Async reads honor it even mid-read; synchronous reads observe + /// it between reads. To abort a synchronous read already blocked on the socket, dispose the + /// response. + /// + /// A disposable response exposing the raw response stream. + public async Task MakeStreamRequest(ISimpleRequest request, CancellationToken cancellationToken = default) + { + var httpRequest = CreateHttpRequest(request); + AddRequestBody(httpRequest, request); + ApplyHeaders(httpRequest, request); + + var url = httpRequest.RequestUri.ToString(); + + Logger?.LogRequest(url, request); + + if (LogRequest != null) + { + LogRequest(url, request); + } + + // ResponseHeadersRead so SendAsync returns as soon as the headers are + // available instead of buffering the whole body, which is what lets us stream. + var httpResponse = await SendHttpRequest(request, httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + // Wrap the raw stream so the caller's token cancels reads, not just the send. + var body = new CancellationAwareStream( + await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false), + cancellationToken); + + // The HttpResponseMessage is handed to the response so its lifetime (and the + // underlying connection) is controlled by the caller disposing the response. + var response = new SimpleStreamResponse(httpResponse, body) + { + StatusCode = httpResponse.StatusCode, + IsSuccessful = ResponseIsSuccessful(httpResponse, request.AdditionalSuccessfulStatusCodes), + }; + + PopulateHeaders(httpResponse, response.Headers); + + return response; + } /// /// Get the URL the given request will be sent to by this client. @@ -122,10 +173,11 @@ public async Task> MakeRequest(ISimpleRequest request) => /// /// Execute a request. /// - private async Task MakeRequestInternal(ISimpleRequest request, T response, Func addResponseBody) where T : ISimpleResponse + private async Task MakeRequestInternal(ISimpleRequest request, T response, Func addResponseBody, CancellationToken cancellationToken) where T : ISimpleResponse { var httpRequest = CreateHttpRequest(request); AddRequestBody(httpRequest, request); + ApplyHeaders(httpRequest, request); var url = httpRequest.RequestUri.ToString(); @@ -136,10 +188,31 @@ private async Task MakeRequestInternal(ISimpleRequest request, T response, LogRequest(url, request); } + var httpResponse = await SendHttpRequest(request, httpRequest, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + + PopulateResponse(httpResponse, response, request.AdditionalSuccessfulStatusCodes); + await addResponseBody(httpResponse, response, request.SerializerOverride ?? Serializer); + + Logger?.LogResponse(response); + + if (LogResponse != null) + { + LogResponse(response); + } + + return response; + } + + /// + /// Send an HttpRequestMessage, applying the request/client timeout to the send and + /// honoring the caller's cancellation token. A timeout surfaces as a TimeoutException; + /// a caller-requested cancellation propagates as an OperationCanceledException. + /// + private async Task SendHttpRequest(ISimpleRequest request, HttpRequestMessage httpRequest, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { var timeout = request.TimeoutOverride ?? Timeout; - HttpResponseMessage httpResponse; - using (var cts = new CancellationTokenSource()) + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) { if (timeout != -1) { @@ -148,63 +221,68 @@ private async Task MakeRequestInternal(ISimpleRequest request, T response, try { - httpResponse = await GetHttpClient().SendAsync(httpRequest, cts.Token).ConfigureAwait(false); + return await httpClientProvider.GetClient().SendAsync(httpRequest, completionOption, cts.Token).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException($"Request timed out after {timeout} seconds"); } + catch (ProtocolViolationException ex) + { + // .NET Framework's HttpClient (backed by HttpWebRequest) rejects a request body + // on methods that don't allow one - most commonly a GET with a body. Surface a + // clearer, actionable error than the raw ProtocolViolationException. + throw new NotSupportedException( + "Sending a request body with this HTTP method isn't supported on this platform. " + + ".NET Framework's HttpClient rejects it (for example, a GET request with a body); " + + "use a body-bearing method such as POST or PUT, or target a modern runtime.", ex); + } } - - PopulateResponse(httpResponse, response, request.AdditionalSuccessfulStatusCodes); - await addResponseBody(httpResponse, response, request.SerializerOverride ?? Serializer); - - Logger?.LogResponse(response); - - if (LogResponse != null) - { - LogResponse(response); - } - - return response; } /// /// Create an HttpRequestMessage for use with HttpClient from an IRequest. /// - private HttpRequestMessage CreateHttpRequest(ISimpleRequest request) - { - var url = CreateUrl(request); - - var httpRequest = new HttpRequestMessage(request.Method, url); + private HttpRequestMessage CreateHttpRequest(ISimpleRequest request) => + new HttpRequestMessage(request.Method, CreateUrl(request)); - // Ensure that we only add default headers that aren't already set on the request - var headers = request.Headers.Concat(DefaultHeaders.Where(x => !request.Headers.Keys.Contains(x.Key))) + /// + /// Merge the request headers with the client's default headers, with the request's + /// headers taking precedence on any conflicts. + /// + private Dictionary MergeHeaders(ISimpleRequest request) => + request.Headers.Concat(DefaultHeaders.Where(x => !request.Headers.Keys.Contains(x.Key))) .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + /// + /// Apply the merged request/default headers to the HttpRequestMessage. Must be called + /// after the request body has been set so content-level headers can be applied to it. + /// + private void ApplyHeaders(HttpRequestMessage httpRequest, ISimpleRequest request) + { + var headers = MergeHeaders(request); + if (!headers.Keys.Contains("User-Agent", StringComparer.OrdinalIgnoreCase)) { - httpRequest.Headers.Add("User-Agent", Constants.DefaultUserAgent); + httpRequest.Headers.TryAddWithoutValidation("User-Agent", Constants.DefaultUserAgent); } foreach (var header in headers) { + // Content-Type is applied to the request body content (see AddRequestBody), not here. if (header.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) { - // Only update Content-Type if it's still the default value - // so we don't overwrite a custom Content-Type on the request - if (request.ContentType == Constants.DefaultContentType) - { - request.ContentType = header.Value; - } + continue; } - else + + // TryAddWithoutValidation avoids throwing on header values HttpClient would + // otherwise reject. Content-level headers can't go on the request headers, so + // fall back to applying them to the body content where they belong. + if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) { - httpRequest.Headers.Add(header.Key, header.Value); + httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); } } - - return httpRequest; } /// @@ -212,25 +290,43 @@ private HttpRequestMessage CreateHttpRequest(ISimpleRequest request) /// private void AddRequestBody(HttpRequestMessage httpRequest, ISimpleRequest request) { + // A Content-Type header overrides the default content type (but never an explicitly-set + // one). Reflect the resolved value back onto the request so it shows what was sent. + if (request.ContentType == Constants.DefaultContentType && + MergeHeaders(request).TryGetValue("Content-Type", out var headerContentType)) + { + request.ContentType = headerContentType; + } + if (request.FormUrlEncodedParameters.Any()) { httpRequest.Content = new FormUrlEncodedContent(request.FormUrlEncodedParameters); } - else if (!string.IsNullOrEmpty(request.StringBody)) + else if (request.Body is string stringBody) { - httpRequest.Content = new StringContent(request.StringBody, request.ContentEncoding, request.ContentType); + // A string body is sent as-is - never re-serialized. + httpRequest.Content = new StringContent(stringBody, request.ContentEncoding, request.ContentType); + + request.StringBody = stringBody; } else if (request.Body != null) { + // An object Body is the source of truth: it's serialized on every send (so re-sending + // after changing Body sends the new value), and the serialized form is reflected back + // onto StringBody. var serializer = request.SerializerOverride ?? Serializer; var serializedBody = serializer.Serialize(request.Body); httpRequest.Content = new StringContent(serializedBody, request.ContentEncoding, request.ContentType); - // Set StringBody to the serialized body for more accurate logging request.StringBody = serializedBody; } + else if (!string.IsNullOrEmpty(request.StringBody)) + { + // No Body, but a string body was set directly on the request. + httpRequest.Content = new StringContent(request.StringBody, request.ContentEncoding, request.ContentType); + } } /// @@ -238,9 +334,44 @@ private void AddRequestBody(HttpRequestMessage httpRequest, ISimpleRequest reque /// private async Task AddResponseBody(HttpResponseMessage httpResponse, ISimpleResponse response, ISimpleHttpSerializer serializer) { - response.StringBody = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + // Read the content once as bytes, then decode the string from those bytes, rather than + // reading (and copying) the whole body twice. + var bytes = await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false); - response.ByteBody = await httpResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + response.ByteBody = bytes; + response.StringBody = DecodeResponseBody(httpResponse.Content, bytes); + } + + /// + /// Decode response bytes to a string using the response's charset (falling back to UTF-8), + /// honoring a byte-order mark if present - matching HttpContent.ReadAsStringAsync closely. + /// + private static string DecodeResponseBody(HttpContent content, byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + { + return string.Empty; + } + + Encoding encoding = null; + var charSet = content.Headers.ContentType?.CharSet; + if (!string.IsNullOrWhiteSpace(charSet)) + { + try + { + encoding = Encoding.GetEncoding(charSet.Trim('"', '\'', ' ')); + } + catch (ArgumentException) + { + // Unknown/invalid charset - fall back to the default encoding below. + } + } + + using (var stream = new MemoryStream(bytes)) + using (var reader = new StreamReader(stream, encoding ?? Constants.DefaultEncoding, detectEncodingFromByteOrderMarks: true)) + { + return reader.ReadToEnd(); + } } /// @@ -264,19 +395,36 @@ private async Task AddResponseBody(HttpResponseMessage httpResponse, ISimpleR /// Create a response from an httpResponse. /// private void PopulateResponse(HttpResponseMessage httpResponse, ISimpleResponse response, IEnumerable successfulStatusCodes) + { + PopulateHeaders(httpResponse, response.Headers); + + response.StatusCode = httpResponse.StatusCode; + response.IsSuccessful = ResponseIsSuccessful(httpResponse, successfulStatusCodes); + } + + /// + /// Copy the response and content headers from an httpResponse into the given dictionary. + /// + private void PopulateHeaders(HttpResponseMessage httpResponse, Dictionary headers) { foreach (var header in httpResponse.Headers.Concat(httpResponse.Content.Headers)) { var value = string.Join(", ", header.Value); - response.Headers.Add(header.Key, value); + // Use the indexer rather than Add so a header appearing in both the response + // and content header collections doesn't throw on a duplicate key. + headers[header.Key] = value; } - - response.StatusCode = httpResponse.StatusCode; - response.IsSuccessful = httpResponse.IsSuccessStatusCode || - AdditionalSuccessfulStatusCodes.Concat(successfulStatusCodes).Any(x => x == httpResponse.StatusCode); } + /// + /// Determine whether an httpResponse should be considered successful, taking into + /// account both the client-level and request-level additional successful status codes. + /// + private bool ResponseIsSuccessful(HttpResponseMessage httpResponse, IEnumerable successfulStatusCodes) => + httpResponse.IsSuccessStatusCode || + AdditionalSuccessfulStatusCodes.Concat(successfulStatusCodes).Any(x => x == httpResponse.StatusCode); + /// /// Create a URL for the given request. /// @@ -318,88 +466,6 @@ private string CombineUrls(string url1, string url2) return $"{url1}/{url2}"; } - /// - /// Try to get an HttpClient using best practices (which don't actually exist for .NET Standard 2.0 projects - See Ref 1). - /// Ref 1: https://github.com/dotnet/aspnetcore/issues/28385#issuecomment-853766480 - /// Ref 2: https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines - /// Ref 3: https://www.siakabaro.com/how-to-manage-httpclient-connections-in-net/ - /// Ref 4: https://github.com/dotnet/runtime/issues/18348 - /// - private HttpClient GetHttpClient() - { - // If we don't have a factory, all we can do is new up an HttpClient. - // Per Ref 3, this will cause DNS issues for long-lived connections so - // we replace the httpClient instance every 5 minutes, per Ref 4 - if (httpClientFactory == null) - { - SetupHttpClientReplacementTimerIfNeeded(false); - - if (httpClient == null) - { - var handler = HttpClientConfigurator.GetMessageHandler(); - - httpClient = new HttpClient(handler); - - HttpClientConfigurator.ConfigureHttpClient(httpClient); - } - - return httpClient; - } - - // Per Ref 2, don't create a new HttpClient for each request on .NET Framework - if (RuntimeInformation.FrameworkDescription.Contains("Framework", StringComparison.OrdinalIgnoreCase)) - { - // Since this is a long-lived client, we need to setup - // periodic replacement using the factory, per Ref 4 - SetupHttpClientReplacementTimerIfNeeded(true); - - if (httpClient == null) - { - httpClient = httpClientFactory.CreateClient(Constants.HttpClientNameString); - } - - return httpClient; - } - - return httpClientFactory.CreateClient(Constants.HttpClientNameString); - } - - /// - /// Setup the HttpClient replacement timer if it hasn't already been setup. - /// - private void SetupHttpClientReplacementTimerIfNeeded(bool shouldUseFactory) - { - if (httpClientReplacementTimer == null) - { - httpClientReplacementTimer = new System.Timers.Timer(); - httpClientReplacementTimer.Elapsed += (sender, e) => ReplaceHttpClient(shouldUseFactory); - httpClientReplacementTimer.Interval = 300000; // 5 minutes in milliseconds - httpClientReplacementTimer.AutoReset = true; - httpClientReplacementTimer.Start(); - } - } - - /// - /// Update the HttpClient instance with a new one to prevent DNS going stale. - /// - private void ReplaceHttpClient(bool shouldUseFactory) - { - if (shouldUseFactory) - { - httpClient = httpClientFactory.CreateClient(Constants.HttpClientNameString); - } - else - { - var handler = HttpClientConfigurator.GetMessageHandler(); - - var newClient = new HttpClient(handler); - - HttpClientConfigurator.ConfigureHttpClient(newClient); - - httpClient = newClient; - } - } - /// /// Dispose. /// @@ -409,9 +475,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - httpClientReplacementTimer?.Stop(); - httpClientReplacementTimer?.Dispose(); - httpClient?.Dispose(); + httpClientProvider?.Dispose(); } disposedValue = true; diff --git a/src/SimpleHttpClient/SimpleClientFactory.cs b/src/SimpleHttpClient/SimpleClientFactory.cs new file mode 100644 index 0000000..3257b07 --- /dev/null +++ b/src/SimpleHttpClient/SimpleClientFactory.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleHttpClient.Logging; +using SimpleHttpClient.Serialization; +using System; +using System.Net.Http; + +namespace SimpleHttpClient +{ + /// + /// The default implementation. Resolves the + /// IHttpClientFactory (and optional serializer/logger) from the service provider and + /// hands each created client its own host. + /// + internal class SimpleClientFactory : ISimpleClientFactory + { + private readonly IServiceProvider services; + + /// + /// Creates a SimpleClientFactory. + /// + /// The service provider used to resolve client dependencies. + public SimpleClientFactory(IServiceProvider services) + { + this.services = services; + } + + /// + /// Create a new with the given host. + /// + public ISimpleClient CreateClient(string host = null) => + new SimpleClient( + host, + services.GetRequiredService(), + services.GetService(), + services.GetService()); + } +} diff --git a/src/SimpleHttpClient/SimpleHttpClient.csproj b/src/SimpleHttpClient/SimpleHttpClient.csproj index 7c293db..bb3dd5e 100644 --- a/src/SimpleHttpClient/SimpleHttpClient.csproj +++ b/src/SimpleHttpClient/SimpleHttpClient.csproj @@ -1,13 +1,13 @@  - netstandard2.0 + netstandard2.0;net8.0 True Simple HTTP Client A_Future_Pilot An easy-to-use .NET wrapper for HttpClient. No extension methods, and included interfaces allow for easy unit test mocking, and straightforward properties allows for easier debugging. http;rest;httpclient;client - 4.1.0 + 4.2.0 https://github.com/Mako88/SimpleHttpClient https://github.com/Mako88/SimpleHttpClient MIT @@ -23,8 +23,9 @@ - - + + +