diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 9602363..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,221 +0,0 @@ -# Kampute.HttpClient - AI Coding Assistant Instructions - -## Project Overview - -Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, automatic content deserialization, and built-in retry strategies. - -## Architecture & Design Patterns - -### Core Components -- **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features -- **Extension Packages**: Modular serialization support (`Json`, `Xml`, `DataContract`, `NewtonsoftJson`) -- **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management -- **Scoped Collections**: `ScopedCollection` for temporary header/property overrides - -### Key Design Patterns -- **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) -- **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception -- **Strategy Pattern**: `IHttpBackoffProvider` for configurable retry logic -- **Factory Pattern**: `BackoffStrategies` for creating retry policies -- **Decorator Pattern**: `HttpRequestScope` for fluent request configuration - -### Request Flow -1. **Request Creation**: `CreateHttpRequest()` builds `HttpRequestMessage` with headers/properties -2. **Pre-processing**: `BeforeSendingRequest` event allows modification -3. **Dispatch**: `DispatchAsync()` sends via underlying `HttpClient` -4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with backoff strategies -5. **Response Processing**: `DeserializeContentAsync()` converts response to .NET objects -6. **Post-processing**: `AfterReceivingResponse` event for inspection/logging - -## Critical Developer Workflows - -### Building & Testing -```bash -# Build solution -dotnet build -c Release - -# Run all tests -dotnet test --verbosity minimal - -# Run specific test project -dotnet test tests/Kampute.HttpClient.Test/ - -# Generate documentation -kampose build -``` - -### Adding New Features -1. **Core Features**: Modify `HttpRestClient.cs` and add tests in corresponding test file -2. **Extensions**: Create new package in `src/Kampute.HttpClient.*` with matching test project -3. **Serialization**: Implement `IHttpContentDeserializer` and add to `ResponseDeserializers` - -### Debugging Common Issues -- **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal -- **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` -- **Serialization Failures**: Check `ResponseDeserializers` collection has appropriate deserializer -- **Retry Behavior**: Verify `BackoffStrategy` is set and `ErrorHandlers` are configured - -## Project-Specific Conventions - -### Code Style & Structure -- **Namespaces**: `Kampute.HttpClient` (core), `Kampute.HttpClient.*` (extensions) -- **Naming**: PascalCase for public APIs, consistent with .NET conventions -- **Documentation**: XML comments with ``, ``, and `` sections -- **Nullability**: `Nullable` enabled with proper `?` annotations -- **Async/Await**: Fully asynchronous APIs with `CancellationToken` support - -### Testing Patterns -- **Framework**: NUnit with Moq for mocking -- **Structure**: Test classes mirror source structure (`HttpRestClientTests.cs`) -- **Mocking**: `Mock` for HTTP interactions -- **Helpers**: `TestHelpers` namespace for shared test utilities -- **Coverage**: Comprehensive unit tests for all public APIs - -### Extension Package Pattern -```csharp -// Extension method pattern -public static class HttpRestClientJsonExtensions -{ - public static void AcceptJson(this HttpRestClient client) - { - client.ResponseDeserializers.Add(new JsonContentDeserializer()); - } - - public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload) - { - return client.SendAsync(HttpVerb.Post, uri, CreateJsonContent(payload)); - } -} -``` - -### Error Handling -- **Exceptions**: `HttpResponseException` for HTTP errors, `HttpContentException` for deserialization failures -- **Custom Errors**: Implement `IHttpErrorResponse` for structured error responses -- **Retry Logic**: `IHttpErrorHandler` implementations for status-code specific handling -- **Logging**: Use request/response events for comprehensive logging - -### Configuration Management -- **Base Address**: Trailing slash handling in `BaseAddress` setter -- **Headers**: `DefaultRequestHeaders` vs scoped headers precedence -- **Accept Headers**: Auto-generated from `ResponseDeserializers` if not specified -- **Properties**: Request-scoped properties via `HttpRequestMessagePropertyKeys` - -## Integration Points - -### External Dependencies -- **Core**: `System.Net.Http` (native .NET) -- **JSON**: `System.Text.Json` or `Newtonsoft.Json` -- **XML**: `System.Runtime.Serialization` or `System.Xml.Serialization` -- **Testing**: NUnit, Moq, Microsoft.NET.Test.Sdk - -### Cross-Component Communication -- **Events**: `BeforeSendingRequest`/`AfterReceivingResponse` for observability -- **Scopes**: `BeginHeaderScope()`/`BeginPropertyScope()` for request customization -- **Extensions**: Fluent chaining via `HttpRequestScope.WithScope().SetHeader()...PerformAsync()` -- **Handlers**: `ErrorHandlers` collection for pluggable error handling - -## Key Files & Directories - -### Core Implementation -- `src/Kampute.HttpClient/HttpRestClient.cs` - Main client implementation -- `src/Kampute.HttpClient/HttpRestClientExtensions.cs` - HTTP verb extensions -- `src/Kampute.HttpClient/BackoffStrategies.cs` - Retry strategy factories -- `src/Kampute.HttpClient/Utilities/ScopedCollection.cs` - Scoped state management - -### Extension Packages -- `src/Kampute.HttpClient.Json/` - System.Text.Json integration -- `src/Kampute.HttpClient.Xml/` - XML serialization support -- `src/Kampute.HttpClient.DataContract/` - DataContractSerializer integration -- `src/Kampute.HttpClient.NewtonsoftJson/` - Newtonsoft.Json support - -### Testing -- `tests/Kampute.HttpClient.Test/` - Core functionality tests -- `tests/Kampute.HttpClient.Json.Test/` - JSON extension tests -- `TestHelpers/` - Shared testing utilities - -### Build & CI/CD -- `Kampute.HttpClient.sln` - Solution file -- `.github/workflows/main.yml` - GitHub Actions CI/CD -- `kampose.json` - Documentation generation config - -## Common Patterns & Examples - -### Basic Usage -```csharp -using var client = new HttpRestClient(); -client.AcceptJson(); // Add JSON deserializer - -var data = await client.GetAsync("https://api.example.com/data"); -``` - -### Scoped Configuration -```csharp -using var client = new HttpRestClient(); - -var result = await client - .WithScope() - .SetHeader("Authorization", $"Bearer {token}") - .SetProperty("CustomData", context) - .PerformAsync(scoped => scoped.GetAsync("endpoint")); -``` - -### Error Handling & Retry -```csharp -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); - -client.ErrorHandlers.Add(new HttpError401Handler(async (client, challenges, token) => { - var auth = await client.PostAsFormAsync("auth/refresh", new { refreshToken }); - return new AuthenticationHeaderValue("Bearer", auth.AccessToken); -})); -``` - -### Custom Serialization -```csharp -public class CustomDeserializer : IHttpContentDeserializer -{ - public bool CanDeserialize(string mediaType, Type objectType) => - mediaType == "application/custom" && objectType == typeof(CustomType); - - public Task DeserializeAsync(HttpContent content, Type objectType, CancellationToken token) => - // Custom deserialization logic -} - -client.ResponseDeserializers.Add(new CustomDeserializer()); -``` - -## Quality Assurance - -### Code Quality Checks -- **Build**: `dotnet build -c Release` ensures compilation -- **Tests**: `dotnet test` runs full test suite -- **Documentation**: `kampose build` generates API docs -- **Analysis**: Nullable reference types enabled for null safety - -### Performance Considerations -- **Connection Pooling**: Use `SharedHttpClient` for multiple client instances -- **Async Operations**: All I/O operations are fully asynchronous -- **Memory Management**: Proper `IDisposable` implementation with reference counting -- **Serialization**: Efficient deserialization with content-type matching - -### Security Best Practices -- **Header Injection**: Scoped headers prevent accidental global state changes -- **Authentication**: Built-in support for various auth schemes via error handlers -- **Cancellation**: `CancellationToken` support throughout async APIs -- **Error Handling**: Structured error responses prevent information leakage - -## Development Guidelines -- **SOLID principles**: Prioritize Single Responsibility and Open/Closed principles -- **Simplicity over complexity**: Avoid excessive helper methods and unnecessary abstractions -- **Self-documenting code**: Code should be readable without redundant inline comments -- **Interface pragmatism**: Use interfaces judiciously - avoid "Ravioli" (too many small interfaces) and "Lasagna" (too many layers) -- **Performance & clarity**: Optimize for both execution speed and code understanding -- **Problem-solving approach**: Question existing solutions, propose improvements, document limitations when needed - -## Documentation Guidelines -- Avoid promotional, flowery, or overly embellished language, and use adjectives and adverbs only when strictly necessary. -- Emphasize purpose and usage; do not document implementation details or obvious information. -- Provide meaningful context and call out important behavioral nuances or edge cases. -- Keep content concise and focused by using short sentences and brief paragraphs to convey information clearly. -- Organize content using bullet points, numbered lists, or tables when appropriate, and explain the context or purpose of the list or table in at least one paragraph. -- Numbered lists should be used for steps in a process or sequence only. -- When writing XML documentation comments, use appropriate XML tags for references, language keywords, and for organizing information in lists and tables. Ensure tags are used to clarify context, structure information, and improve readability for consumers of the documentation. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 38d78dc..3d917cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,3 +44,17 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./.site keep_files: false + + test-net-framework: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '10.0.x' + + - name: Test .NET Framework Build + run: dotnet test tests/Kampute.HttpClient.NetFramework.Test --verbosity minimal diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..08ed889 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Kampute.HttpClient - AI Coding Assistant Instructions + +## Project Overview + +Kampute.HttpClient is a .NET library that enhances the native `HttpClient` for simplified RESTful API communication. It provides a modular, extensible architecture with shared connection pooling, scoped request customization, and automatic content deserialization. + +## Architecture & Design Patterns + +### Core Components +- **`HttpRestClient`**: Main client class wrapping `HttpClient` with enhanced features +- **Content Formats**: XML support in the core (`Kampute.HttpClient.Xml` namespace) and extension packages for JSON (`Json`, `NewtonsoftJson`) +- **Shared HttpClient**: Connection pooling via `SharedHttpClient` for efficient resource management +- **Scoped Collections**: `ScopedCollection` for temporary header/property overrides + +### Key Design Patterns +- **Fluent API**: Extension methods for HTTP verbs (`GetAsync`, `PostAsJsonAsync`, etc.) +- **Event-driven**: `BeforeSendingRequest`/`AfterReceivingResponse` events for interception +- **Strategy Pattern**: `IHttpRetryPolicy` for configurable retry logic, built on `IRetryStrategy` from the `Kampute.Resilience` package +- **Factory Pattern**: `RetryStrategies` (in `Kampute.Resilience`) for creating retry strategies, and `ToHttpRetryPolicy()` to use one for HTTP requests +- **Decorator Pattern**: `HttpRequestScope` for fluent request configuration + +### Request Flow +1. **Request Creation**: `CreateHttpRequest()` builds `HttpRequestMessage` with headers/properties +2. **Pre-processing**: `BeforeSendingRequest` event allows modification +3. **Dispatch**: `DispatchAsync()` sends via underlying `HttpClient` +4. **Retry Logic**: `DispatchWithRetriesAsync()` handles failures with retry policies +5. **Response Processing**: `DeserializeContentAsync()` converts response to .NET objects +6. **Post-processing**: `AfterReceivingResponse` event for inspection/logging + +## Critical Developer Workflows + +### Building & Testing +```bash +# Build solution +dotnet build -c Release + +# Run all tests +dotnet test --verbosity minimal + +# Run specific test project +dotnet test tests/Kampute.HttpClient.Test/ + +# Generate documentation +kampose build +``` + +### Adding New Features +1. **Core Features**: Modify `HttpRestClient.cs` and add tests in corresponding test file +2. **Extensions**: Create new package in `src/Kampute.HttpClient.*` with matching test project +3. **Serialization**: Derive from `HttpContentFormatter` (or implement `IHttpContentFormatter`) and add to `ContentFormatters` + +### Debugging Common Issues +- **Connection Pooling**: Use `SharedHttpClient` reference counting for proper disposal +- **Header Conflicts**: Scoped headers override defaults; avoid setting headers on underlying `HttpClient` +- **Serialization Failures**: Check that the `ContentFormatters` collection has a formatter that reads (for responses) or writes (for `SendObjectAsync` payloads) the media type +- **Retry Behavior**: Verify `RetryPolicy` is set and `ErrorHandlers` are configured diff --git a/Kampute.HttpClient.sln b/Kampute.HttpClient.sln index 0471d0f..2ba940d 100644 --- a/Kampute.HttpClient.sln +++ b/Kampute.HttpClient.sln @@ -28,64 +28,100 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Newtonso EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.NewtonsoftJson.Test", "tests\Kampute.HttpClient.NewtonsoftJson.Test\Kampute.HttpClient.NewtonsoftJson.Test.csproj", "{57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Xml", "src\Kampute.HttpClient.Xml\Kampute.HttpClient.Xml.csproj", "{4608503D-49BF-40ED-9374-B571B798198F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.Xml.Test", "tests\Kampute.HttpClient.Xml.Test\Kampute.HttpClient.Xml.Test.csproj", "{E4423156-F2C9-4B30-82ED-A33FA2B66381}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.DataContract", "src\Kampute.HttpClient.DataContract\Kampute.HttpClient.DataContract.csproj", "{90D06076-641E-4A0A-BD76-4BACC7F500D6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kampute.HttpClient.DataContract.Test", "tests\Kampute.HttpClient.DataContract.Test\Kampute.HttpClient.DataContract.Test.csproj", "{B699745B-1850-4309-8312-E01E2DF9CB50}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x64.ActiveCfg = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x64.Build.0 = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x86.ActiveCfg = Debug|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Debug|x86.Build.0 = Debug|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|Any CPU.ActiveCfg = Release|Any CPU {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|Any CPU.Build.0 = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x64.ActiveCfg = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x64.Build.0 = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x86.ActiveCfg = Release|Any CPU + {12FA6975-9FA7-447A-A1CA-513550F67DD8}.Release|x86.Build.0 = Release|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x64.Build.0 = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Debug|x86.Build.0 = Debug|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|Any CPU.ActiveCfg = Release|Any CPU {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|Any CPU.Build.0 = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x64.ActiveCfg = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x64.Build.0 = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x86.ActiveCfg = Release|Any CPU + {8675C4E8-DDAF-4303-A263-610CD18A20A8}.Release|x86.Build.0 = Release|Any CPU {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x64.ActiveCfg = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x64.Build.0 = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x86.ActiveCfg = Debug|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Debug|x86.Build.0 = Debug|Any CPU {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|Any CPU.ActiveCfg = Release|Any CPU {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|Any CPU.Build.0 = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x64.ActiveCfg = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x64.Build.0 = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x86.ActiveCfg = Release|Any CPU + {42B55A45-5DD1-4F49-9286-7FBA7FA88F07}.Release|x86.Build.0 = Release|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x64.Build.0 = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Debug|x86.Build.0 = Debug|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|Any CPU.ActiveCfg = Release|Any CPU {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|Any CPU.Build.0 = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x64.ActiveCfg = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x64.Build.0 = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x86.ActiveCfg = Release|Any CPU + {A6C59FE1-D230-4AF8-AC2C-AE0C7CD0FA17}.Release|x86.Build.0 = Release|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x64.Build.0 = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Debug|x86.Build.0 = Debug|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|Any CPU.Build.0 = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x64.ActiveCfg = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x64.Build.0 = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x86.ActiveCfg = Release|Any CPU + {A861BB86-73F3-4DE4-AC47-3EB67CD977C1}.Release|x86.Build.0 = Release|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x64.Build.0 = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Debug|x86.Build.0 = Debug|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|Any CPU.Build.0 = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x64.ActiveCfg = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x64.Build.0 = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x86.ActiveCfg = Release|Any CPU + {88DCA26B-D7D5-40FB-AE8E-E8CE92E916C2}.Release|x86.Build.0 = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x64.Build.0 = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Debug|x86.Build.0 = Debug|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|Any CPU.Build.0 = Release|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4608503D-49BF-40ED-9374-B571B798198F}.Release|Any CPU.Build.0 = Release|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4423156-F2C9-4B30-82ED-A33FA2B66381}.Release|Any CPU.Build.0 = Release|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90D06076-641E-4A0A-BD76-4BACC7F500D6}.Release|Any CPU.Build.0 = Release|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B699745B-1850-4309-8312-E01E2DF9CB50}.Release|Any CPU.Build.0 = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x64.ActiveCfg = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x64.Build.0 = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.ActiveCfg = Release|Any CPU + {57C3C6E3-BEF0-4122-814F-D5566F4AE8CE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 8aaddcd..1521976 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,59 @@ # Kampute.HttpClient -`Kampute.HttpClient` is a .NET library designed to simplify HTTP communication with RESTful APIs by enhancing the native `HttpClient` capabilities. Tailored for -developers seeking a potent yet flexible HTTP client for API integration within .NET applications, it combines ease of use with a wide array of functionalities -to address the complexities of web service consumption. +A .NET library for REST API clients built on `HttpClient`, with scoped request configuration, typed responses, configurable retries, and error handlers. -[Explore the API documentation](https://kampute.github.io/http-client/) for detailed insights. +[Documentation](https://kampute.github.io/http-client/) · [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) · [API reference](https://kampute.github.io/http-client/api/) -## Key Features +## Features -- **Shared HttpClient Instances:** - Facilitates the reuse of a single `HttpClient` instance across multiple `HttpRestClient` instances, promoting efficient resource and connection - management. This approach significantly boosts performance in scenarios involving concurrent access to multiple services or API endpoints. +- Shared connections or an application-managed `HttpClient`. +- Temporary headers and request properties through scopes. +- JSON, XML, and custom content formatters for requests and responses. +- Retry strategies for transient connection failures and handlers for HTTP errors. +- Request and response events for customization and inspection. -- **Flexible HttpClient Configuration:** - Allows the integration of custom or shared `HttpClient` instances, complete with configurations for message handlers, timeouts, and advanced authentication - mechanisms to fit specific application needs. +## Packages -- **Dynamic Request Customization:** - Offers the capability to define request headers and properties scoped to specific request blocks, allowing for temporary changes that do not affect the global - configuration. Scoped headers and properties ensure that modifications are contextually isolated, enhancing maintainability and reducing the risk of configuration - errors during runtime. +The packages target .NET Standard 2.0 and .NET 10. -- **Custom Error Handling and Exception Management:** - Converts HTTP response errors into detailed, meaningful exceptions, streamlining the process of interpreting API-specific errors with the aid of a customizable - error response type set through the `ResponseErrorType` property. Furthermore, it enhances flexibility in error management with the `ErrorHandlers` collection, - allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors - directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. +| Package | Purpose | +| --- | --- | +| [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient) | Core client, scopes, error handlers, and XML support. | +| [Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json) | JSON with System.Text.Json. | +| [Kampute.HttpClient.NewtonsoftJson](https://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson) | JSON with Newtonsoft.Json. | -- **Retry Strategies with Backoff Mechanisms:** - Implements backoff strategies to handle transient failures and network interruptions effectively. These strategies, configurable via the `BackoffStrategy` - property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. +## Quick Start -- **Modular Content Processing:** - Supports extendable serialization/deserialization modules for seamless integration with common and custom content types. It uses a collection of response - deserializers that automatically convert HTTP response content into .NET objects based on the response's `Content-Type`, and proactively informs the service - of the content types it is configured to accept by setting the appropriate `Accept` headers. This dual-functionality simplifies the process of working with - API responses and ensures seamless data integration by aligning expected response formats with the client’s capabilities. - -- **Streamlined Authentication and Authorization:** - Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication - strategies. - -- **Request and Response Interception:** - Provides events such as `BeforeSendingRequest` and `AfterReceivingResponse` for executing custom logic before sending a request or after receiving a response. - This feature enables detailed request modification, response inspection, and logging, offering developers full control over the HTTP communication process. - -- **Asynchronous API for Enhanced Performance:** - Promotes fully asynchronous network operations with support for cancellation tokens, ensuring efficient management of long-running requests in line with modern - asynchronous programming practices in .NET. - -## Serialization Support - -By default, `Kampute.HttpClient` does not include any content deserializer. To accommodate popular content types, the following extension packages are available: - -- **[Kampute.HttpClient.Json](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html)**: - Utilizes the `System.Text.Json` library for handling JSON content types, offering high-performance serialization and deserialization that integrates tightly - with the .NET ecosystem. - -- **[Kampute.HttpClient.NewtonsoftJson](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html)**: - Leverages the `Newtonsoft.Json` library for handling JSON content types, providing extensive customization options and compatibility with a vast number of JSON - features and formats. - -- **[Kampute.HttpClient.Xml](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html)**: - Employs the `XmlSerializer` for handling XML content types, enabling straightforward serialization and deserialization of XML into .NET objects using custom - class structures. - -- **[Kampute.HttpClient.DataContract](https://kampute.github.io/http-client/api/Kampute.HttpClient.DataContract.html)**: - Utilizes the `DataContractSerializer` for handling XML content types, focusing on serialization and deserialization of .NET objects into XML based on data contract - attributes for fine-grained control over the XML output. - -For scenarios where the provided serialization packages do not meet specific requirements, `Kampute.HttpClient` allows the implementation of custom deserializers. -Developers can create their own serialization modules by implementing interfaces for deserialization, thus enabling support for custom content types or proprietary -data formats. - -## Installation - -Install `Kampute.HttpClient` via NuGet: +For a JSON API, install the System.Text.Json extension. It includes the core client as a dependency. ```shell -dotnet add package Kampute.HttpClient +dotnet add package Kampute.HttpClient.Json ``` -## Usage Examples - -The examples below demonstrate how to use the library for common tasks. - -### Basic Usage - -To get started with `HttpRestClient`, simply instantiate it and use it to perform HTTP requests. +In a .NET application, register the formatter before requesting a typed response. Replace the example URL with your API; this model assumes a JSON response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.Json; -// Create a new instance of the HttpRestClient using var client = new HttpRestClient(); +client.UseJson(); -// Configure the client to accept JSON responses, using System.Text.Json library. -// This is an extension method provided by the Kampute.HttpClient.Json package. -client.AcceptJson(); - -// Perform a GET request. -// The GetAsync method will automatically deserialize the JSON response -// into the specified MyModel type. -var data = await client.GetAsync("https://api.example.com/resource"); -``` - -### Scoped Request Headers - -In addition to setting default request headers that apply to all requests, you can define headers for a specific set of requests using a scoped approach. This feature -allows for temporary modifications to headers that override default settings within a defined context. This is particularly useful for handling varying endpoint requirements -or for testing scenarios. +var resource = await client.GetAsync("https://api.example.com/resource"); -Below is an example that demonstrates how to temporarily override the `Accept` header for a series of requests, ensuring that all requests within the scope explicitly request -a specific media type. - -```csharp -using Kampute.HttpClient; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -string csv; - -// Begin a scoped block where the 'Accept' header is set to 'text/csv'. -// All HTTP requests within this using block will include this 'Accept' header. -using (client.BeginHeaderScope(new Dictionary { ["Accept"] = MediaTypeNames.Text.Csv })) +public sealed class Resource { - // Perform a GET request to retrieve data as CSV. The 'Accept' header for this request - // will be 'text/csv', as specified by the scoped header. - csv = await client.GetAsStringAsync("https://api.example.com/resource/csv"); + public int Id { get; set; } + public string? Name { get; set; } } ``` -Alternatively, you can use the `WithScope` extension method to simplify the code as follows: - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -var csv = await client - .WithScope() - .SetHeader("Accept", MediaTypeNames.Text.Csv) - .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/resource/csv")); -``` - -### Scoped Request Properties - -Similar to headers, you can also scope request properties. This capability is invaluable in scenarios where you need to maintain state or context-specific information temporarily -during a series of HTTP operations. Scoped properties work similarly to scoped headers, allowing developers to define temporary data attached to requests that are automatically -cleared once the scope is exited. This feature enhances the adaptability of your HTTP interactions, especially in complex or state-dependent communication scenarios. - -### Custom Retry Strategies - -The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci backoff strategy, which gradually increases the delay between retries, balancing -the need to retry soon against the need to wait longer as the number of attempts increases. - -```csharp -using Kampute.HttpClient; - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Configure the client's retry mechanism. -// The Fibonacci strategy will retry up to 5 times -// with an initial delay of 1 second between retries -// and delay increases following the Fibonacci sequence for subsequent retries. -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); -``` - -### Handling HTTP Errors - -The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. -Here's how to utilize the built-in '401 Unauthorized' error handler: - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.ErrorHandlers; - -// Create an instance of the built-in '401 Unauthorized' error handler. -// This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => -{ - // In this example, we're handling the unauthorized error by making a POST request to an - // authentication endpoint to obtain a new authentication token. - var auth = await client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); - - // Return a new AuthenticationHeaderValue with the obtained token. - // This will be used to include the authentication header in subsequent requests. - return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); -}); - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Register the unauthorized error handler with the client. -// This allows the client to handle '401 Unauthorized' responses automatically. -client.ErrorHandlers.Add(unauthorizedErrorHandler); -``` - -Additionally, handling '503 Service Unavailable' and '429 Too Many Requests' errors is simplified with the built-in handler, ensuring your application can gracefully -retry requests during service outages and rate limit encounters. - -### Handling Content Types - -For handling specific content types like JSON or XML, consider using the available extension packages. - -In the example below, we assume that both the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, and the `Kampute.HttpClient.DataContract` package, enabling XML content management via `DataContractSerializer`, have been installed. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.DataContract; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept JSON responses, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package -client.AcceptJson(); - -// Configure the client to accept XML responses, using DataContractSerializer. -// This is an extension method provided by the Kampute.HttpClient.DataContract package -client.AcceptXml(); - -// Execute a GET request. The server may respond in either JSON or XML format. -// The GetAsync method will automatically deserialize the response -// into the specified MyResource type, based on the response content type (JSON or XML). -var result = await client.GetAsync("https://api.example.com/resource"); - -// Send a PATCH request with a payload in JSON format. -// The PatchAsJsonAsync method is provided by the Kampute.HttpClient.NewtonsoftJson package. -await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); - -// Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the Kampute.HttpClient.DataContract package. -var newResource = new MyResource(); -await client.PostAsXmlAsync("https://api.example.com/resource", newResource); -``` - -## Documentation - -Explore the `Kampute.HttpClient` library's [API Documentation](https://kampute.github.io/http-client/api/) for an in-depth understanding of its -functionalities. You'll find detailed class references, method signatures, and descriptions of properties to guide your implementation and leverage the library's full -potential. +See [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) for package selection and [the user guide](https://kampute.github.io/http-client/overview/index.html) for request configuration, content formats, retries, and error handling. ## Contributing -Contributions welcome! Please follow the existing coding and documentation conventions to maintain consistency across the codebase. - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature-name` -3. Commit changes: `git commit -m 'Add feature'` -4. Push branch: `git push origin feature-name` -5. Open a pull request +Report bugs and suggest features in [GitHub issues](https://github.com/kampute/http-client/issues). Pull requests are welcome; run `dotnet test` from the repository root before submitting one. ## License -Licensed under the [MIT License](LICENSE). +Kampute.HttpClient is released under the [MIT License](LICENSE). diff --git a/docs/client-configuration.md b/docs/client-configuration.md new file mode 100644 index 0000000..5eddcb2 --- /dev/null +++ b/docs/client-configuration.md @@ -0,0 +1,103 @@ +--- +title: Client Configuration +summary: Manage HttpClient lifetime, configure a base address, and wrap an API. +--- + +# Client Configuration + +See [Getting started](getting-started.md) for package installation. Replace the example URLs and models with your API's values. + +## HttpClient Lifetime + +By default, [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) acquires a shared [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instance. This avoids creating a new connection pool for every short-lived client wrapper. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); +``` + +If your application already manages [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instances, pass one in directly. This is useful when you configure handlers, proxies, default timeouts, or dependency-injection lifetimes elsewhere. + +```csharp +using System; +using System.Net.Http; +using Kampute.HttpClient; + +using var httpClient = new HttpClient +{ + Timeout = TimeSpan.FromSeconds(30) +}; + +using var client = new HttpRestClient(httpClient, disposeClient: false); +``` + +When passing an application-managed [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), use [`disposeClient: false`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient__ctor_System_Net_Http_HttpClient_System_Boolean_) to keep ownership with the application. Otherwise, disposing [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) also disposes the supplied client. + +## Base Address + +Set [`BaseAddress`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BaseAddress) when most requests target the same API, and use relative request paths without a leading slash: a leading slash resolves the path from the host root and drops the path of the base address. The client adds a trailing slash to the base address if it has none. Register a formatter before reading typed responses. + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient +{ + BaseAddress = new Uri("https://api.example.com/v1/") +}; + +client.UseJson(); +var resource = await client.GetAsync("resources/42"); +``` + +## Build an API Wrapper + +A typical API wrapper keeps one configured [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) and exposes domain-specific methods around it. + +The `Account` type below is your application's response model. Both methods use relative URLs and pass cancellation to the request helper. `RenameAccountAsync` sends a PATCH that updates the remote account. + +```csharp +using System; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +public sealed class AccountApiClient : IDisposable +{ + private readonly HttpRestClient _client; + + public AccountApiClient(Uri baseAddress, string bearerToken) + { + _client = new HttpRestClient + { + BaseAddress = baseAddress + }; + + _client.UseJson(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + + public Task GetCurrentAccountAsync(CancellationToken cancellationToken = default) + { + return _client.GetAsync("accounts/current", cancellationToken); + } + + public Task RenameAccountAsync(string name, CancellationToken cancellationToken = default) + { + return _client.PatchAsJsonAsync("accounts/current", new { name }, cancellationToken); + } + + public void Dispose() + { + _client.Dispose(); + } +} +``` + +This keeps the rest of the application focused on business operations instead of repeated HTTP setup. + +For temporary request settings, see [Request customization](request-customization.md). diff --git a/docs/content-formats.md b/docs/content-formats.md new file mode 100644 index 0000000..54ca02f --- /dev/null +++ b/docs/content-formats.md @@ -0,0 +1,119 @@ +--- +title: Content Formats +summary: Register JSON, XML, or custom formatters to read responses and write payloads. +--- + +# Content Formats + +The base package registers no content formatter. Each format registers its formatter in [`ContentFormatters`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ContentFormatters) and exposes payload helpers for its content type. + +- [`Kampute.HttpClient.Xml`](~/api/Kampute.HttpClient.Xml.html), in the base package: XML support through [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer) and [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). +- [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html): JSON support through [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). +- [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html): JSON support through [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm). + +## JSON + +Install either JSON package as described in [Getting started](getting-started.md). Pass serializer options when registering the formatter: + +```csharp +using System.Text.Json; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(new JsonSerializerOptions(JsonSerializerDefaults.Web)); +``` + +For [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), import [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) and call [`UseNewtonsoftJson(settings)`](~/api/Kampute.HttpClient.NewtonsoftJson.HttpRestClientJsonExtensions.html#Kampute_HttpClient_NewtonsoftJson_HttpRestClientJsonExtensions_UseNewtonsoftJson_Kampute_HttpClient_HttpRestClient_Newtonsoft_Json_JsonSerializerSettings_), passing a [`JsonSerializerSettings`](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_JsonSerializerSettings.htm) instance. The registered options or settings apply to both reading responses and writing payloads. + +## XML + +[`UseXml()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_UseXml_Kampute_HttpClient_HttpRestClient_System_Action{Kampute_HttpClient_Xml_XmlFormatter}_) registers an [`XmlFormatter`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html). Its [`Serializer`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_Serializer) setting chooses the serializer. With the default, [`XmlSerializerKind.Auto`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields), types marked with [`[DataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractattribute) or [`[CollectionDataContract]`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.collectiondatacontractattribute) use [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer), and all other types use [`XmlSerializer`](https://learn.microsoft.com/dotnet/api/system.xml.serialization.xmlserializer). The rule applies to responses by the requested type and to payloads by their runtime type. Set [`XmlSerializerKind.XmlSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) or [`XmlSerializerKind.DataContractSerializer`](~/api/Kampute.HttpClient.Xml.XmlSerializerKind.html#fields) to use one serializer for every type, and [`DataContractSettings`](~/api/Kampute.HttpClient.Xml.XmlFormatter.html#Kampute_HttpClient_Xml_XmlFormatter_DataContractSettings) to configure [`DataContractSerializer`](https://learn.microsoft.com/dotnet/api/system.runtime.serialization.datacontractserializer). + +The following POST writes to your API; `Resource` is your application's serializable model. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Xml; + +using var client = new HttpRestClient(); + +client.UseXml(xml => xml.Serializer = XmlSerializerKind.DataContractSerializer); + +await client.PostAsXmlAsync("https://api.example.com/resources", new Resource { Name = "Example" }); +``` + +## Custom Formatters + +You can also implement a content formatter for an application-specific content type. Derive from [`HttpContentFormatter`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html) and pass the media types it reads and the media types it writes to the base constructor. Override [`ReadContentAsync`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_ReadContentAsync_System_Net_Http_HttpContent_System_Type_System_Threading_CancellationToken_) to read responses, [`CreateContent`](~/api/Kampute.HttpClient.Content.Abstracts.HttpContentFormatter.html#Kampute_HttpClient_Content_Abstracts_HttpContentFormatter_CreateContent_System_Object_System_String_) to write request payloads, or both. A formatter that only reads passes an empty list of writable media types, and one that only writes passes an empty list of readable media types. + +This skeleton shows the overrides to implement; replace both [`NotImplementedException`](https://learn.microsoft.com/dotnet/api/system.notimplementedexception) statements with your format's read and write logic before registering it. + +```csharp +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Kampute.HttpClient.Content.Abstracts; + +public sealed class VendorFormatter : HttpContentFormatter +{ + private const string VendorMediaType = "application/vnd.example.resource+json"; + + public VendorFormatter() + : base([VendorMediaType], [VendorMediaType]) + { + } + + protected override Task ReadContentAsync( + HttpContent content, + Type modelType, + CancellationToken cancellationToken) + { + // Read the vendor-specific payload here. + throw new NotImplementedException(); + } + + protected override HttpContent CreateContent(object payload, string mediaType) + { + // Write the vendor-specific payload here. + throw new NotImplementedException(); + } +} +``` + +Once you have implemented the formatter, register it with the client. The following POST uses your application's `Resource` model and `resource` payload. Responses with its media type are then read into the requested .NET type, the media type is added to the `Accept` header, and [`SendObjectAsync`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) writes request payloads with it. + +```csharp +using System.Net.Http; +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.ContentFormatters.Add(new VendorFormatter()); + +var created = await client.SendObjectAsync( + HttpMethod.Post, + "https://api.example.com/resources", + resource, + "application/vnd.example.resource+json"); +``` + +[`SendObjectAsync`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) throws [`InvalidOperationException`](https://learn.microsoft.com/dotnet/api/system.invalidoperationexception) before sending anything if no registered formatter can write the payload in the requested media type. A payload that is already an [`HttpContent`](https://learn.microsoft.com/dotnet/api/system.net.http.httpcontent) is sent as it is. + +## Combine Formats + +You can combine formats when an API can return more than one content type. Here, `Resource` is the response model from [Getting started](getting-started.md). + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.NewtonsoftJson; +using Kampute.HttpClient.Xml; + +using var client = new HttpRestClient(); + +client.UseNewtonsoftJson(); +client.UseXml(); + +var result = await client.GetAsync("https://api.example.com/resource"); +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..f46dbe5 --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,102 @@ +--- +title: Error Handling +summary: Retry connection failures, handle structured error bodies, and recover from selected HTTP status codes. +--- + +# Error Handling + +A request can fail in two ways: without a response, such as when the connection fails or times out, or with an error response. The client retries connection failures as its retry policy decides, and offers error responses to its error handlers. When an error response is not retried, the client raises an [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). + +## Retry Connection Failures + +A request that fails without a response, because the connection is refused or reset, the host cannot be reached, or [`HttpClient.Timeout`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient.timeout) elapses, is retried only if you set [`RetryPolicy`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_RetryPolicy). The default, [`HttpRetryPolicy.None`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_None), does not retry. + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.Resilience; + +using var client = new HttpRestClient(); + +client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)) + .WithMaxRetries(5) + .ToHttpRetryPolicy(); +``` + +The retry strategy comes from the [`Kampute.Resilience`](https://kampute.github.io/resilience/) package, which the core client installs as a dependency. `RetryStrategies` creates constant, linear, Fibonacci, and exponential delays, and modifiers such as `WithMaxRetries()`, `WithMaxElapsedTime()`, `WithMaxDelay()`, and `WithJitter()` limit the retries and shape their delays. A strategy without a limiting modifier retries without limit. See [Retry Strategies](https://kampute.github.io/resilience/overview.html#retry-strategies) in the Kampute.Resilience user guide for every strategy and modifier, and use [`HttpRetryPolicy.Dynamic()`](~/api/Kampute.HttpClient.HttpRetryPolicy.html#Kampute_HttpClient_HttpRetryPolicy_Dynamic_System_Func{Kampute_HttpClient_HttpRequestErrorContext_Kampute_Resilience_IRetryStrategy}_) to choose a strategy from the failure. + +## Structured Error Bodies + +Set [`ResponseErrorType`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ResponseErrorType) to your API's error model. Register a [content formatter](content-formats.md) that can read that model from the response media type. + +The deserialized model is available through [`HttpResponseException.ResponseObject`](~/api/Kampute.HttpClient.HttpResponseException.html#Kampute_HttpClient_HttpResponseException_ResponseObject). If the model implements [`IHttpErrorResponse`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html), its [`ToException()`](~/api/Kampute.HttpClient.Interfaces.IHttpErrorResponse.html#Kampute_HttpClient_Interfaces_IHttpErrorResponse_ToException_System_Net_HttpStatusCode_) method constructs the exception. See the reference for the full error-response contract. + +## Refresh Authorization + +[`HttpError401Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html) can obtain new authorization details and retry a rejected request. The following fragment assumes your application provides `RefreshAccessTokenAsync(CancellationToken)`, which contacts your authentication service and returns a bearer token. + +```csharp +using System.Net.Http.Headers; +using Kampute.HttpClient; +using Kampute.HttpClient.ErrorHandlers; + +using var unauthorizedErrorHandler = new HttpError401Handler(async (ctx, cancellationToken) => +{ + var token = await RefreshAccessTokenAsync(cancellationToken); + return new AuthenticationHeaderValue(AuthSchemes.Bearer, token); +}); + +using var client = new HttpRestClient(); +client.ErrorHandlers.Add(unauthorizedErrorHandler); +``` + +Register a response formatter as well if you make typed requests with this client. Keep the handler alive while the client uses it. + +## Other Recovery Handlers + +The core package includes handlers for these cases: + +| Handler | Purpose | +| --- | --- | +| [`HttpError401Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html) | Refresh authorization after 401 Unauthorized. | +| [`HttpError429Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError429Handler.html) | Schedule retries after 429 Too Many Requests. | +| [`HttpError503Handler`](~/api/Kampute.HttpClient.ErrorHandlers.HttpError503Handler.html) | Schedule retries after 503 Service Unavailable. | +| [`TransientHttpErrorHandler`](~/api/Kampute.HttpClient.ErrorHandlers.TransientHttpErrorHandler.html) | Retry selected transient HTTP error responses. | + +Add handlers to [`client.ErrorHandlers`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_ErrorHandlers). When several handlers accept a status code, they are asked in the order they were added, until one retries. + +## Retry Error Responses + +The 429, 503, and transient error handlers decide how to retry when they handle the first error response of a call: + +- If the response suggests a retry time, the request is retried once, at that time. The 503 and transient handlers read the `Retry-After` header; the 429 handler also reads rate limit reset headers such as `x-ratelimit-reset`. If the retry receives another error response that the same handler handles, the error reaches the caller. +- If the response suggests no retry time, the 503 and transient handlers retry as the client's [`RetryPolicy`](#retry-connection-failures) decides, and the 429 handler does not retry. + +A server cannot keep a call waiting: retry times suggested by later responses do not change these delays, and a suggested time more than [`MaxRetryDelay`](~/api/Kampute.HttpClient.ErrorHandlers.Abstracts.RetryableHttpErrorHandler.html#Kampute_HttpClient_ErrorHandlers_Abstracts_RetryableHttpErrorHandler_MaxRetryDelay) away, five minutes by default, ends the retries and the error reaches the caller. + +To choose the retries yourself, set [`OnRetryPolicy`](~/api/Kampute.HttpClient.ErrorHandlers.Abstracts.RetryableHttpErrorHandler.html#Kampute_HttpClient_ErrorHandlers_Abstracts_RetryableHttpErrorHandler_OnRetryPolicy). It is called once per call, with the first error response and the retry time it suggests, and the policy it returns applies to that handler for the rest of the call; return `null` to keep the default. This handler retries a 503 response without `Retry-After` up to three times with growing delays, and accepts suggested retry times up to one minute away: + +```csharp +using System; +using Kampute.HttpClient; +using Kampute.HttpClient.ErrorHandlers; +using Kampute.Resilience; + +using var client = new HttpRestClient(); + +client.ErrorHandlers.Add(new HttpError503Handler +{ + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnRetryPolicy = (ctx, retryTime) => retryTime is null + ? RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(3).ToHttpRetryPolicy() + : null +}); +``` + +Each handler counts only its own retries, separately from the connection retry policy and from other handlers. + +## Content Failures + +Typed response reads fail with [`HttpContentException`](~/api/Kampute.HttpClient.HttpContentException.html) when the body is empty, its media type cannot be read by a registered formatter, or parsing fails. Check the response's `Content-Type`, register the matching formatter, and ensure your model matches the payload. + +A missing writer for [`SendObjectAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) raises [`InvalidOperationException`](https://learn.microsoft.com/dotnet/api/system.invalidoperationexception) when the method is called, before sending the request. Register a formatter that can write the payload type in the requested media type. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..2479839 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,58 @@ +--- +title: Getting Started +summary: Choose a package, register a content formatter, and read a typed response. +--- + +# Getting Started + +Use these examples in a .NET application with asynchronous calling code. The URLs are placeholders: replace them with endpoints you can access, and use models that match their response bodies. + +## Choose a Package + +The base package contains [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry policies, error handlers, compression content wrappers, the content formatter registry, and XML support. It depends on [`Kampute.Resilience`](https://kampute.github.io/resilience/), which provides the retry strategies. JSON packages are separate so applications only reference the JSON library they use. + +| Package | Use it for | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, error handling, and XML APIs. | +| [`Kampute.HttpClient.Json`](~/api/Kampute.HttpClient.Json.html) | JSON APIs using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json). | +| [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm) features or compatibility. | + +## Install + +For a JSON API using [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json), install the extension package. It brings in the core client and `Kampute.Resilience` as dependencies. + +```shell +dotnet add package Kampute.HttpClient.Json +``` + +For XML or raw response bodies, install [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) instead. For JSON using [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), install [`Kampute.HttpClient.NewtonsoftJson`](~/api/Kampute.HttpClient.NewtonsoftJson.html). See [Content formats](content-formats.md) for registration and serializer settings. + +## Read a Typed Response + +The core client starts with no content formatter. Register one before reading a response as a .NET object. This example expects `application/json` content such as `{"Id":42,"Name":"Example"}`. + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +var resource = await client.GetAsync("https://api.example.com/resource"); + +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} +``` + +[`UseJson()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_UseJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the formatter for reading responses and writing payloads. The client uses its readable media types to populate `Accept` when the request does not already set that header. + +## Next Steps + +- [Send requests and payloads](sending-requests.md). +- [Configure the client and its lifetime](client-configuration.md). +- [Customize individual requests](request-customization.md). +- [Configure content formats](content-formats.md). +- [Retry failed requests and handle errors](error-handling.md). diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..a662e77 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,34 @@ +--- +title: User Guide +summary: Understand how clients, request scopes, content formatters, and recovery policies work together. +--- + +# User Guide + +An integration typically keeps a configured [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) behind an API wrapper. The wrapper exposes application operations, while the client prepares HTTP messages, processes responses, and applies the recovery rules you select. + +## Configuration and Lifetime + +The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) owns the HTTP transport. [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) adds the base address, default request headers, content formatters, retry policy, and error handlers used by the integration. Configure these before making requests. + +When the application supplies an [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), decide who owns its lifetime: by default, disposing the wrapper disposes the supplied client. Pass [`disposeClient: false`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient__ctor_System_Net_Http_HttpClient_System_Boolean_) when the application manages that lifetime. The [client configuration guide](client-configuration.md) covers both supplied and shared clients. + +A request scope supplies temporary headers or properties for its lifetime. Use it for operation-specific context, and dispose it to restore the previous settings. The [request customization guide](request-customization.md) explains explicit scopes, the fluent scope API, and request/response events. + +## The Request Lifecycle + +A request passes through these stages: + +1. The client creates the HTTP message using its default and scoped configuration. +2. [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) gives subscribers an opportunity to modify the outgoing message. +3. The underlying [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) sends it. A configured connection retry policy can schedule another attempt after a transient connection failure or a timeout. +4. [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) exposes a received response before further processing. +5. A successful response is read in the form requested by the caller. An error response is offered to registered error handlers; if none recovers by retrying, the call fails with [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). + +The [request helpers](sending-requests.md) let you choose typed objects, raw bodies, or lower-level HTTP control. Typed responses need a registered formatter that can read the response's media type into the requested model; [content formatters](content-formats.md) also write object payloads in a selected format. + +## Failure and Recovery + +Connection failures, HTTP error responses, and content failures need different treatment. A retry policy controls transient connection failures. Error handlers decide whether an HTTP error response can be retried, while formatter or model problems must be corrected to read the response successfully. + +Set retry limits deliberately. The connection retry policy and each retrying error handler count only their own retries, so their limits do not add up to one limit for a call: a call that fails in several ways can be retried more times than any one limit allows. The [error handling guide](error-handling.md) explains how to retry connection failures, how handlers retry error responses, and how to read structured errors. diff --git a/docs/request-customization.md b/docs/request-customization.md new file mode 100644 index 0000000..0b5f33a --- /dev/null +++ b/docs/request-customization.md @@ -0,0 +1,80 @@ +--- +title: Request Customization +summary: Apply temporary request settings and intercept requests and responses. +--- + +# Request Customization + +Configure a client as shown in [Getting started](getting-started.md), then apply settings at the level that needs them. + +## Default Headers + +Set [`client.DefaultRequestHeaders`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_DefaultRequestHeaders) for headers shared by the client's requests. If you change that collection while requests are in flight, lock the collection as described in the [`HttpRestClient` reference](~/api/Kampute.HttpClient.HttpRestClient.html). + +## Scoped Headers + +Request scopes let you apply headers or properties to a group of operations without changing the client defaults. This is useful when a few endpoints need a different `Accept` header, tenant identifier, correlation value, or authentication state. + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +var csv = await client + .WithScope() + .SetHeader("Accept", MediaTypeNames.Text.Csv) + .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/report")); +``` + +You can also use explicit scopes when the same temporary configuration should apply to multiple requests. Here, `Customer` and `Order` are your application's JSON response models. + +```csharp +using System.Collections.Generic; +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +using (client.BeginHeaderScope(new Dictionary +{ + ["X-Tenant"] = "northwind" +})) +{ + var customer = await client.GetAsync("https://api.example.com/customers/42"); + var orders = await client.GetAsync("https://api.example.com/customers/42/orders"); +} +``` + +When the scope is disposed, the headers and properties it set or removed return to their previous values. + +## Scoped Properties + +Use [`BeginPropertyScope()`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeginPropertyScope_System_Collections_Generic_IEnumerable{System_Collections_Generic_KeyValuePair{System_String_System_Object}}_) for temporary values attached to request messages, or [`WithScope()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_WithScope_Kampute_HttpClient_HttpRestClient_) followed by [`SetProperty()`](~/api/Kampute.HttpClient.HttpRequestScope.html#Kampute_HttpClient_HttpRequestScope_SetProperty_System_String_System_Object_) in the fluent API. Properties carry application context for message handlers and request hooks; headers carry values sent to the server. + +## Request and Response Events + +Subscribe to [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) and [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) when you need logging, diagnostics, request enrichment, or response inspection around every operation. + +```csharp +using System; +using Kampute.HttpClient; + +using var client = new HttpRestClient(); + +client.BeforeSendingRequest += (_, args) => +{ + args.Request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N")); +}; + +client.AfterReceivingResponse += (_, args) => +{ + Console.WriteLine($"{(int)args.Response.StatusCode} {args.Response.ReasonPhrase}"); +}; +``` + +Event handlers run around the actual HTTP operation, so keep them small and predictable. + +[`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) runs before further response processing. For streaming requests, inspect status and headers without reading the body: reading it consumes the stream intended for the caller. + +See [`HttpRequestScope`](~/api/Kampute.HttpClient.HttpRequestScope.html) and [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) for the complete scope and event contracts. diff --git a/docs/sending-requests.md b/docs/sending-requests.md new file mode 100644 index 0000000..321a3b7 --- /dev/null +++ b/docs/sending-requests.md @@ -0,0 +1,54 @@ +--- +title: Sending Requests +summary: Send HTTP requests, write payloads, and read typed or raw responses. +--- + +# Sending Requests + +Start with a client configured as shown in [Getting started](getting-started.md). Replace the example endpoints with your API. + +## Choose a Request Helper + +The [core request extensions](~/api/Kampute.HttpClient.HttpRestClientExtensions.html) cover common request shapes: + +- [`GetAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`PostAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PostAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PutAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PutAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PatchAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PatchAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), and [`DeleteAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_DeleteAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) deserialize response bodies. +- [`GetAsStringAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStringAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`GetAsByteArrayAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsByteArrayAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), and [`GetAsStreamAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStreamAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) read raw response bodies without a content formatter. +- [`HeadAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_HeadAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) and [`OptionsAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_OptionsAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) return response headers. +- [`SendAsync()`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_SendAsync_System_Net_Http_HttpMethod_System_String_System_Net_Http_HttpContent_System_Net_Http_HttpCompletionOption_System_Threading_CancellationToken_) provides control over the HTTP method and payload. + +Typed response methods need a registered formatter that can read the response's media type. See [Content formats](content-formats.md) when adding another format. + +## Send a JSON Payload + +The JSON extension packages supply [`PostAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PutAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PutAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), and [`PatchAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PatchAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_). The following POST creates a resource on your API and expects a JSON response matching the `Resource` model from [Getting started](getting-started.md). + +```csharp +using Kampute.HttpClient; +using Kampute.HttpClient.Json; + +using var client = new HttpRestClient(); +client.UseJson(); + +var created = await client.PostAsJsonAsync( + "https://api.example.com/resources", + new { name = "New resource" }); +``` + +For XML payloads, use [`PostAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PostAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PutAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PutAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), or [`PatchAsXmlAsync()`](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PatchAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_) from [`Kampute.HttpClient.Xml`](~/api/Kampute.HttpClient.Xml.html). For application-specific media types, use [`SendObjectAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_SendObjectAsync_Kampute_HttpClient_HttpRestClient_System_Net_Http_HttpMethod_System_String_System_Object_System_String_System_Threading_CancellationToken_) with a [custom content formatter](content-formats.md#custom-formatters). + +## Read a Raw Response + +```csharp +using Kampute.HttpClient; + +using var client = new HttpRestClient(); +var text = await client.GetAsStringAsync("https://api.example.com/resource"); +``` + +Dispose the stream returned by [`GetAsStreamAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStreamAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) when you finish reading it. Avoid reading the response body in an [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) handler for streaming requests, since this consumes the stream before the caller can read it. + +## Cancellation and Failures + +Request helpers accept a [`CancellationToken`](https://learn.microsoft.com/dotnet/api/system.threading.cancellationtoken); pass the caller's token through your API wrapper. See [Error handling](error-handling.md) for retrying transient connection failures and handling HTTP errors. + +See the [core extensions](~/api/Kampute.HttpClient.HttpRestClientExtensions.html), [JSON extensions](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html), and [XML extensions](~/api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html) for signatures and overloads. diff --git a/docs/welcome.md b/docs/welcome.md index c552c53..ebca5e5 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -1,324 +1,41 @@ --- title: Home -summary: Build REST API clients on top of HttpClient with scoped request configuration, content deserialization, retry strategies, structured error handling, and request/response interception. +summary: A .NET client layer for REST integrations, with typed content, scoped requests, and configurable recovery. --- # Welcome to Kampute.HttpClient -`Kampute.HttpClient` is a lightweight .NET library for building REST API clients on top of the native [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). It keeps the familiar .NET HTTP stack while adding the pieces most REST integrations need around it: reusable clients, request scopes, typed response deserialization, retry strategies, structured error handling, and request/response hooks. +REST integrations need more than an HTTP call: requests carry authentication and context, responses need to become application models, and failures need a recovery policy. [`Kampute.HttpClient`](~/api/Kampute.HttpClient.html) brings these concerns together around the native .NET [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). -Use it when you want a small client layer instead of a generated API SDK, or when you need direct control over [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) while still avoiding repeated boilerplate in every request. +The library is useful when you want to write an API client around your application's own models and operations. You choose the endpoints, payloads, and recovery rules; [`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) provides the request helpers and configuration behind them. -## Core Capabilities +## A Familiar HTTP Foundation -[`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) wraps [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) and focuses on common REST workflows: +[`HttpRestClient`](~/api/Kampute.HttpClient.HttpRestClient.html) wraps [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient), so your integration can use the .NET HTTP stack's message handlers, proxies, and timeouts. You can supply a client managed by your application or use the library's shared client, which reuses connections across wrappers. -- Send common HTTP methods through concise async helpers. -- Deserialize successful responses into typed .NET objects. -- Read raw response bodies as strings, streams, or byte arrays when needed. -- Register JSON, XML, or custom response deserializers. -- Apply headers and request properties globally or inside temporary scopes. -- Configure retry behavior for transient connection failures. -- Handle HTTP error responses with reusable handlers. -- Inspect outgoing requests and incoming responses through lifecycle events. +This lets an API wrapper keep its own base address, headers, content formatters, and error handlers while sharing the underlying connection infrastructure. Application code can expose operations such as loading an account or updating a resource without repeating HTTP setup in each method. -The library does not hide [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient). You can provide your own instance, configure handlers and timeouts yourself, or let [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) use a shared client instance. +## Application Models and Content Formats -## Quick Start +Use helpers such as [`GetAsync()`](~/api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) and [`PostAsJsonAsync()`](~/api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_) to work with typed responses and request payloads. Registered content formatters read a response according to its `Content-Type` and the model you request. When you need the body directly, helpers also return strings, byte arrays, or streams. -Install the base package and one serializer package for the content type you want to consume. For most APIs, start with the `System.Text.Json` package. +XML support is included in the core package. JSON extensions let you choose [`System.Text.Json`](https://learn.microsoft.com/dotnet/api/system.text.json) or [`Newtonsoft.Json`](https://www.newtonsoft.com/json/help/html/N_Newtonsoft_Json.htm), and custom formatters can support application-specific media types. A client can register more than one format when an API returns different kinds of content. -```shell -dotnet add package Kampute.HttpClient.Json -``` +Formatters are explicit: a new client starts with none registered. Configure the formats your integration needs before requesting typed responses. -Create an [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), configure accepted response formats, and send requests asynchronously. +## Request Configuration Where It Belongs -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; +Set default headers for values shared by an API client's requests. Use a request scope when an operation needs a temporary tenant identifier, authorization header, or different accepted media type. Disposing the scope restores the prior configuration, so each operation does not need to undo its overrides manually. -using var client = new HttpRestClient(); +Request properties carry context for message handlers and hooks. The [`BeforeSendingRequest`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_BeforeSendingRequest) and [`AfterReceivingResponse`](~/api/Kampute.HttpClient.HttpRestClient.html#Kampute_HttpClient_HttpRestClient_AfterReceivingResponse) events let you enrich outgoing messages or inspect status and headers around the HTTP operation. -client.AcceptJson(); +## Recovery That Matches Your API -var data = await client.GetAsync("https://api.example.com/resource"); -``` +Choose a retry policy for transient connection failures, with delays and limits supplied by [`Kampute.Resilience`](https://kampute.github.io/resilience/). Retries are opt-in: the default connection policy does not retry. -[`AcceptJson()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_AcceptJson_Kampute_HttpClient_HttpRestClient_System_Text_Json_JsonSerializerOptions_) registers the JSON deserializer and lets the client advertise JSON through the `Accept` header when the request does not already provide one. +HTTP error responses have a separate recovery path. Register error handlers to refresh authorization after a 401 response or schedule retries for selected status codes. Structured error bodies can be deserialized into your API's error model; an unrecovered error response raises [`HttpResponseException`](~/api/Kampute.HttpClient.HttpResponseException.html). -## Choosing Packages +## Get Started -The base package contains [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html), request helpers, scopes, retry strategies, error handlers, compression content wrappers, and the deserializer registry. Serializer packages are separate so applications only reference the serializers they use. - -| Package | Use it for | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| [`Kampute.HttpClient`](api/Kampute.HttpClient.html) | Core HTTP client, request helpers, scopes, retry behavior, and error handling. | -| [`Kampute.HttpClient.Json`](api/Kampute.HttpClient.Json.html) | JSON APIs using `System.Text.Json`. | -| [`Kampute.HttpClient.NewtonsoftJson`](api/Kampute.HttpClient.NewtonsoftJson.html) | JSON APIs that require `Newtonsoft.Json` features or compatibility. | -| [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html) | XML APIs using `XmlSerializer`. | -| [`Kampute.HttpClient.DataContract`](api/Kampute.HttpClient.DataContract.html) | XML APIs using `DataContractSerializer`. | - -You can combine serializer packages when an API can return more than one content type. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.DataContract; -using Kampute.HttpClient.NewtonsoftJson; - -using var client = new HttpRestClient(); - -client.AcceptJson(); -client.AcceptXml(); - -var result = await client.GetAsync("https://api.example.com/resource"); -``` - -## Working With HttpClient - -By default, [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) acquires a shared [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instance. This avoids creating a new connection pool for every short-lived client wrapper. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); -``` - -If your application already manages [`HttpClient`](https://learn.microsoft.com/dotnet/api/system.net.http.httpclient) instances, pass one in directly. This is useful when you configure handlers, proxies, default timeouts, or dependency-injection lifetimes elsewhere. - -```csharp -using Kampute.HttpClient; - -var httpClient = new HttpClient -{ - Timeout = TimeSpan.FromSeconds(30) -}; - -using var client = new HttpRestClient(httpClient); -``` - -Set [`BaseAddress`](api/Kampute.HttpClient.HttpRestClient.html) when most requests target the same API. The client normalizes missing trailing slashes so relative paths resolve predictably. - -```csharp -using var client = new HttpRestClient -{ - BaseAddress = new Uri("https://api.example.com/v1") -}; - -var account = await client.GetAsync("accounts/current"); -``` - -## Sending Requests - -The core package includes helpers for common request shapes: - -- [`GetAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`PostAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PostAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PutAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PutAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), [`PatchAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_PatchAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Net_Http_HttpContent_System_Threading_CancellationToken_), and [`DeleteAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_DeleteAsync__1_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for typed responses. -- [`GetAsStringAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStringAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), [`GetAsByteArrayAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsByteArrayAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_), and [`GetAsStreamAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_GetAsStreamAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for raw response bodies. -- [`HeadAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_HeadAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) and [`OptionsAsync()`](api/Kampute.HttpClient.HttpRestClientExtensions.html#Kampute_HttpClient_HttpRestClientExtensions_OptionsAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Threading_CancellationToken_) for response headers. -- [`SendAsync()`](api/Kampute.HttpClient.HttpRestClient.html) for lower-level control over the HTTP method and payload. - -Use content-specific packages for convenient request payload helpers such as [`PostAsJsonAsync()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PostAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), [`PatchAsJsonAsync()`](api/Kampute.HttpClient.Json.HttpRestClientJsonExtensions.html#Kampute_HttpClient_Json_HttpRestClientJsonExtensions_PatchAsJsonAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_), and [`PostAsXmlAsync()`](api/Kampute.HttpClient.Xml.HttpRestClientXmlExtensions.html#Kampute_HttpClient_Xml_HttpRestClientXmlExtensions_PostAsXmlAsync_Kampute_HttpClient_HttpRestClient_System_String_System_Object_System_Threading_CancellationToken_). - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; - -using var client = new HttpRestClient(); - -client.AcceptJson(); - -var created = await client.PostAsJsonAsync( - "https://api.example.com/resources", - new { name = "New resource" }); -``` - -## Scoped Requests - -Request scopes let you apply headers or properties to a group of operations without changing the client defaults. This is useful when a few endpoints need a different `Accept` header, tenant identifier, correlation value, or authentication state. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -var csv = await client - .WithScope() - .SetHeader("Accept", MediaTypeNames.Text.Csv) - .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/report")); -``` - -You can also use explicit scopes when the same temporary configuration should apply to multiple requests. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -using (client.BeginHeaderScope(new Dictionary -{ - ["X-Tenant"] = "northwind" -})) -{ - var customer = await client.GetAsync("https://api.example.com/customers/42"); - var orders = await client.GetAsync("https://api.example.com/customers/42/orders"); -} -``` - -When the scope is disposed, the temporary headers and properties are removed. - -## Serializer Packages - -The base package does not include a default content deserializer. Each serializer package registers a deserializer with [`ResponseDeserializers`](api/Kampute.HttpClient.HttpRestClient.html) and exposes payload helpers for its content type. - -- [`Kampute.HttpClient.Json`](api/Kampute.HttpClient.Json.html): JSON support through `System.Text.Json`. -- [`Kampute.HttpClient.NewtonsoftJson`](api/Kampute.HttpClient.NewtonsoftJson.html): JSON support through `Newtonsoft.Json`. -- [`Kampute.HttpClient.Xml`](api/Kampute.HttpClient.Xml.html): XML support through `XmlSerializer`. -- [`Kampute.HttpClient.DataContract`](api/Kampute.HttpClient.DataContract.html): XML support through `DataContractSerializer`. - -You can also implement custom deserializers for application-specific content types. - -```csharp -using Kampute.HttpClient.Content.Abstracts; - -public sealed class VendorContentDeserializer - : HttpContentDeserializer -{ - public VendorContentDeserializer() - : base("application/vnd.example.resource+json") - { - } - - public override Task DeserializeAsync( - HttpContent content, - Type modelType, - CancellationToken cancellationToken = default) - { - // Deserialize the vendor-specific payload here. - throw new NotImplementedException(); - } -} -``` - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -client.ResponseDeserializers.Add(new VendorContentDeserializer()); -``` - -## Retry Behavior - -Retry strategies help clients recover from transient connection failures without duplicating retry loops around every request. Set [`BackoffStrategy`](api/Kampute.HttpClient.HttpRestClient.html) to choose how long the client waits between attempts. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -client.BackoffStrategy = BackoffStrategies.Fibonacci( - maxAttempts: 5, - initialDelay: TimeSpan.FromSeconds(1)); -``` - -Built-in strategies include: - -- [`BackoffStrategies.None`](api/Kampute.HttpClient.BackoffStrategies.html) for no retry delay. -- [`BackoffStrategies.Once()`](api/Kampute.HttpClient.BackoffStrategies.html) for a single retry after a delay. -- [`BackoffStrategies.Uniform()`](api/Kampute.HttpClient.BackoffStrategies.html) for a fixed delay. -- [`BackoffStrategies.Linear()`](api/Kampute.HttpClient.BackoffStrategies.html) for linearly increasing delays. -- [`BackoffStrategies.Exponential()`](api/Kampute.HttpClient.BackoffStrategies.html) for exponential backoff. -- [`BackoffStrategies.Fibonacci()`](api/Kampute.HttpClient.BackoffStrategies.html) for gradually increasing delays. - -Retry strategies can be combined with limits and jitter where appropriate for the API you are calling. - -## HTTP Error Handling - -When a response status code indicates failure, the client raises an [`HttpResponseException`](api/Kampute.HttpClient.HttpResponseException.html) unless an error handler recovers from the response. Use [`ResponseErrorType`](api/Kampute.HttpClient.HttpRestClient.html) when the server returns structured error bodies, and register handlers when a status code needs custom recovery behavior. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.ErrorHandlers; - -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => -{ - var auth = await client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); - - return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); -}); - -using var client = new HttpRestClient(); - -client.ErrorHandlers.Add(unauthorizedErrorHandler); -``` - -The core package includes handlers for common retry and authentication scenarios, including [`HttpError401Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError401Handler.html), [`HttpError429Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError429Handler.html), [`HttpError503Handler`](api/Kampute.HttpClient.ErrorHandlers.HttpError503Handler.html), and [`TransientHttpErrorHandler`](api/Kampute.HttpClient.ErrorHandlers.TransientHttpErrorHandler.html). - -## Request And Response Events - -Subscribe to [`BeforeSendingRequest`](api/Kampute.HttpClient.HttpRestClient.html) and [`AfterReceivingResponse`](api/Kampute.HttpClient.HttpRestClient.html) when you need logging, diagnostics, request enrichment, or response inspection around every operation. - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -client.BeforeSendingRequest += (_, args) => -{ - args.Request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N")); -}; - -client.AfterReceivingResponse += (_, args) => -{ - Console.WriteLine($"{(int)args.Response.StatusCode} {args.Response.ReasonPhrase}"); -}; -``` - -Event handlers run around the actual HTTP operation, so keep them small and predictable. - -## Common Integration Shape - -A typical API wrapper keeps one configured [`HttpRestClient`](api/Kampute.HttpClient.HttpRestClient.html) and exposes domain-specific methods around it. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; - -public sealed class AccountApiClient : IDisposable -{ - private readonly HttpRestClient _client; - - public AccountApiClient(Uri baseAddress, string bearerToken) - { - _client = new HttpRestClient - { - BaseAddress = baseAddress - }; - - _client.AcceptJson(); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); - } - - public Task GetCurrentAccountAsync(CancellationToken cancellationToken = default) - { - return _client.GetAsync("accounts/current", cancellationToken); - } - - public Task RenameAccountAsync(string name, CancellationToken cancellationToken = default) - { - return _client.PatchAsJsonAsync("accounts/current", new { name }, cancellationToken); - } - - public void Dispose() - { - _client.Dispose(); - } -} -``` - -This keeps the rest of the application focused on business operations instead of repeated HTTP setup. +For a JSON API, install the JSON extension for the serializer your application uses. For XML or raw response bodies, start with the core package. The [getting-started guide](getting-started.md) walks through package selection, formatter registration, and a first typed request. diff --git a/kampose.json b/kampose.json index 5acc95f..724a520 100644 --- a/kampose.json +++ b/kampose.json @@ -8,11 +8,31 @@ "stopOnIssues": true }, "assemblies": [ - "src/**/bin/Release/netstandard2.1/*.dll" + "src/**/bin/Release/net10.0/*.dll" + ], + "references": [ + { + "namespaces": [ + "Kampute.Resilience", + "Kampute.Resilience.*" + ], + "strategy": "docFx", + "url": "https://kampute.github.io/resilience/" + } ], "topics": [ "docs/**/*.md" ], + "topicHierarchy": "index", + "topicOrder": [ + "docs/overview.md", + "docs/getting-started.md", + "docs/sending-requests.md", + "docs/client-configuration.md", + "docs/request-customization.md", + "docs/content-formats.md", + "docs/error-handling.md" + ], "assets": [ { "source": [ @@ -21,15 +41,6 @@ ] } ], - "references": [ - { - "namespaces": [ - "Newtonsoft.Json.*" - ], - "strategy": "onlineSearch", - "url": "https://www.phind.com/search/" - } - ], "theme": "classic", "themeSettings": { "projectName": "Kampute.HttpClient", @@ -37,10 +48,21 @@ "projectLogoLightUri": "logo-dark.png", "projectLogoDarkUri": "logo-light.png", "faviconUri": "logo-dark.png", + "menuItems": [ + { + "title": "Home", + "url": "index.html" + }, + "overview", + { + "title": "GitHub", + "url": "https://github.com/kampute/http-client" + } + ], + "groupTypesByNamespace": true, "pageFooter": [ "- Copyright © {{now 'yyyy'}} Kampute", - "- [MIT License](~/LICENSE)", - "- [Source code](https://github.com/kampute/http-client)" + "- [MIT License](~/LICENSE)" ], "pageFooterRight": [ "Built with [Kampose](https://kampute.github.io/kampose/)" diff --git a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs deleted file mode 100644 index 1f696de..0000000 --- a/src/Kampute.HttpClient.DataContract/HttpRestClientXmlExtensions.cs +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using System; - using System.Collections.Concurrent; - using System.Net.Http; - using System.Runtime.Serialization; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides extension methods for to support XML-based HTTP operations. - /// - /// - /// This static class extends with methods tailored for handling HTTP requests and responses - /// involving XML data. It facilitates the sending and receiving of XML content by abstracting the complexities of serialization - /// and deserialization of XML to and from .NET objects. - /// - public static class HttpRestClientXmlExtensions - { - private static readonly ConcurrentDictionary serializerSettings = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetXmlSerializerSettings((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified settings when serializing payloads as XML. - /// - /// The instance to configure. - /// The to use for serializing payload as XML. If , default settings will be used. - public static void SetXmlSerializerSettings(this HttpRestClient client, DataContractSerializerSettings? settings) - { - client.Disposing -= ClientDisposing; - if (settings is not null) - { - serializerSettings[client] = settings; - client.Disposing += ClientDisposing; - } - else - { - serializerSettings.TryRemove(client, out _); - } - } - - /// - /// Retrieves the settings used by the when serializing payloads as XML. - /// - /// The instance to query. - /// The if set; otherwise, . - public static DataContractSerializerSettings? GetXmlSerializerSettings(this HttpRestClient client) - { - serializerSettings.TryGetValue(client, out var settings); - return settings; - } - - /// - /// Configures the to accept XML responses by adding or updating a in its response deserializers collection. - /// - /// The instance to configure. - /// The to use for deserializing XML responses. if , default settings will be used. - /// The used for XML content deserialization. - /// - /// If the client already has a , this method updates its options with the provided . - /// Otherwise, it adds a new with the specified options to the client's response deserializers. - /// - public static XmlContentDeserializer AcceptXml(this HttpRestClient client, DataContractSerializerSettings? settings = null) - { - var deserializer = client.ResponseDeserializers.Find(); - if (deserializer is null) - { - deserializer = new XmlContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); - } - deserializer.Settings = settings; - return deserializer; - } - - /// - /// Sends an asynchronous request with XML-formatted payload to the specified URI. - /// - /// The type of the object expected in the response. - /// The instance to be used for sending the request. - /// The HTTP method to use for the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) - { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var xmlContent = new XmlContent(payload) { Settings = client.GetXmlSerializerSettings() }; - return client.SendAsync(method, uri, xmlContent, cancellationToken); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The HTTP method to use for the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation. - /// Thrown if , or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) - { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var xmlContent = new XmlContent(payload) { Settings = client.GetXmlSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, xmlContent, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI. - /// - /// The type of the object expected in the response. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PUT request with XML-formatted payload to the specified URI and returns the response body deserialized as the specified type. - /// - /// The type of the response object. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PUT request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PATCH request with XML-formatted payload to the specified URI and returns the response body deserialized as the specified type. - /// - /// The type of the response object. - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); - } - - /// - /// Sends an asynchronous PATCH request with XML-formatted payload to the specified URI without processing the response body. - /// - /// The instance to be used for sending the request. - /// The URI to which the request is sent. - /// The object to serialize as the XML-formatted HTTP request payload. - /// A token for canceling the request (optional). - /// A task that represents the asynchronous operation. - /// Thrown if or is . - /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) - { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); - } - } -} diff --git a/src/Kampute.HttpClient.DataContract/ICON.png b/src/Kampute.HttpClient.DataContract/ICON.png deleted file mode 100644 index 7293eba..0000000 Binary files a/src/Kampute.HttpClient.DataContract/ICON.png and /dev/null differ diff --git a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj deleted file mode 100644 index 68ca808..0000000 --- a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - netstandard2.0;netstandard2.1 - Kampute.HttpClient.DataContract - This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using DataContractSerializer for serialization and deserialization of XML responses and payloads. - Kambiz Khojasteh - 2.5.1 - Kampute - Copyright (c) 2025 Kampute - latest - enable - true - snupkg - true - false - Kampute.HttpClient.DataContract - http http-client restful rest-client rest-api web-api xml - ICON.png - README.md - LICENSE - For detailed release notes, please visit https://github.com/kampute/http-client/releases - https://kampute.github.io/http-client/ - https://github.com/kampute/http-client.git - git - IDE0290 - - - - true - ../../SigningKey.snk - - - - - - - - - - - - - diff --git a/src/Kampute.HttpClient.DataContract/LICENSE b/src/Kampute.HttpClient.DataContract/LICENSE deleted file mode 100644 index b7a7c64..0000000 --- a/src/Kampute.HttpClient.DataContract/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) Kampute - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs b/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs deleted file mode 100644 index 3c4dfd0..0000000 --- a/src/Kampute.HttpClient.DataContract/NamespaceDoc.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - /// - /// This namespace provides support for DataContract serialization in HTTP requests and responses, - /// specifically for handling application/xml content types using the DataContractSerializer. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.HttpClient.DataContract/README.md b/src/Kampute.HttpClient.DataContract/README.md deleted file mode 100644 index 74f4c65..0000000 --- a/src/Kampute.HttpClient.DataContract/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Kampute.HttpClient.DataContract - -`Kampute.HttpClient.DataContract` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) -library, designed to enhance its functionality by providing support for handling `application/xml` content types. This package leverages -the `DataContractSerializer` for efficient serialization and deserialization of XML data, simplifying the process of sending and receiving -XML payloads in RESTful API communications. - -## Installation - -Install `Kampute.HttpClient.DataContract` via NuGet: - -```shell -dotnet add package Kampute.HttpClient.DataContract -``` - -## Usage - -To enable XML processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.DataContract` namespace and -use the provided extension methods. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.DataContract; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept XML responses. -client.AcceptXml(); - -// Sending an XML payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsXmlAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.DataContract` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.DataContract.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new features, -please open an issue first to discuss what you would like to change. - -## License - -`Kampute.HttpClient.DataContract` is licensed under the terms of the [MIT](LICENSE) license. diff --git a/src/Kampute.HttpClient.DataContract/XmlContent.cs b/src/Kampute.HttpClient.DataContract/XmlContent.cs deleted file mode 100644 index 0c76a93..0000000 --- a/src/Kampute.HttpClient.DataContract/XmlContent.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using System; - using System.IO; - using System.Net; - using System.Net.Http; - using System.Net.Http.Headers; - using System.Runtime.Serialization; - using System.Text; - using System.Threading.Tasks; - using System.Xml; - - /// - /// Represents HTTP content based on XML serialized from an object. - /// - public sealed class XmlContent : HttpContent - { - private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); - - private readonly object _content; - private readonly Encoding _encoding; - - /// - /// Initializes a new instance of the class using a specified content object with UTF-8 encoding. - /// - /// The object to be serialized into XML format. - /// Thrown if is . - public XmlContent(object payload) - : this(payload, utf8WithoutMarker) - { - } - - /// - /// Initializes a new instance of the class using a specified content object and encoding. - /// - /// The object to be serialized into XML format. - /// The character encoding to use for the serialized XML content. - /// Thrown if or is . - public XmlContent(object content, Encoding encoding) - { - _content = content ?? throw new ArgumentNullException(nameof(content)); - _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); - - Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) - { - CharSet = encoding.WebName - }; - } - - /// - /// Gets the character encoding of the serialized XML content. - /// - /// - /// The character encoding of the serialized XML content. - /// - public Encoding Encoding => _encoding; - - /// - /// Gets or sets the XML serialization settings. - /// - /// - /// The XML serialization settings, if any. - /// - public DataContractSerializerSettings? Settings { get; set; } - - /// - /// Serializes the content to a stream asynchronously. - /// - /// The target stream. - /// The transport context. - /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) - { - using var streamWriter = new StreamWriter(stream, _encoding, 4096, true); - using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings - { - Encoding = _encoding, - OmitXmlDeclaration = false, - CheckCharacters = true, - Indent = false, - }); - var serializer = new DataContractSerializer(_content.GetType(), Settings); - serializer.WriteObject(xmlWriter, _content); - return Task.CompletedTask; - } - - /// - /// Attempts to compute the length of the content. - /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } - } -} diff --git a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs b/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs deleted file mode 100644 index c501122..0000000 --- a/src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.DataContract package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.DataContract -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Net.Http; - using System.Reflection; - using System.Runtime.Serialization; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Xml; - - /// - /// Provides functionality for deserializing XML content from HTTP responses into objects. - /// - public sealed class XmlContentDeserializer : HttpContentDeserializer - { - /// - /// Initializes a new instance of the class. - /// - public XmlContentDeserializer() - : base(MediaTypeNames.Application.Xml) - { - } - - /// - /// Gets or sets the XML deserialization settings. - /// - /// - /// The XML deserialization settings, if any. - /// - public DataContractSerializerSettings? Settings { get; set; } - - /// - /// Retrieves a collection of supported media types for a specific model type. - /// - /// The type of the model for which to retrieve supported media types. - /// - /// The read-only collection of media types that this deserializer supports if the model type is not and - /// is marked with a ; otherwise, an empty collection. - /// - public override IEnumerable GetSupportedMediaTypes(Type modelType) - { - return modelType?.GetCustomAttribute() is not null ? SupportedMediaTypes : []; - } - - /// - /// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The target model type for deserialization. - /// - /// if the deserializer supports the media type and the model type is not and is marked with - /// a ; otherwise, . - /// - public override bool CanDeserialize(string mediaType, Type modelType) - { - return modelType?.GetCustomAttribute() is not null && SupportedMediaTypes.Contains(mediaType); - } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) - { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var xmlReader = XmlReader.Create(streamReader); - return new DataContractSerializer(modelType, Settings).ReadObject(xmlReader); - } - } -} diff --git a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs index 288f4d3..ce7f8e4 100644 --- a/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.Json/HttpRestClientJsonExtensions.cs @@ -6,76 +6,45 @@ namespace Kampute.HttpClient.Json { using System; - using System.Collections.Concurrent; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; /// - /// Provides extension methods for to support JSON-based HTTP operations. + /// Provides extension methods for that register a System.Text.Json formatter and send JSON payloads. /// /// - /// This static class enhances by offering methods specifically designed for handling HTTP - /// requests and responses that involve JSON data. It simplifies the process of sending and receiving JSON content, by - /// abstracting the serialization and deserialization of JSON to and from .NET objects. + /// registers a , which lets the client read JSON responses and advertise JSON in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default options if none + /// is registered. /// public static class HttpRestClientJsonExtensions { - private static readonly ConcurrentDictionary serializerOptions = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerOptions((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified options when serializing payloads as JSON. - /// - /// The instance to configure. - /// The to use for serializing payload as JSON. if , default options will be used. - public static void SetJsonSerializerOptions(this HttpRestClient client, JsonSerializerOptions? options) - { - client.Disposing -= ClientDisposing; - if (options is not null) - { - serializerOptions[client] = options; - client.Disposing += ClientDisposing; - } - else - { - serializerOptions.TryRemove(client, out _); - } - } - - /// - /// Retrieves the options used by the when serializing payloads as JSON. - /// - /// The instance to query. - /// The if set; otherwise, . - public static JsonSerializerOptions? GetJsonSerializerOptions(this HttpRestClient client) - { - serializerOptions.TryGetValue(client, out var options); - return options; - } - /// - /// Configures the to accept JSON responses by adding or updating a in its response deserializers collection. + /// Registers a with the client, or updates the registered one. /// /// The instance to configure. - /// The to use for deserializing JSON responses. if , default options will be used. - /// The used for JSON content deserialization. + /// The to use for reading responses and writing payloads. If , default options are used. + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method updates its options with the provided . - /// Otherwise, it adds a new with the specified options to the client's response deserializers. + /// If the client already has a , this method sets its options to . Otherwise, it adds a new + /// with to . /// - public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerOptions? options = null) + public static JsonFormatter UseJson(this HttpRestClient client, JsonSerializerOptions? options = null) { - var deserializer = client.ResponseDeserializers.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new JsonContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + formatter = new JsonFormatter(); + client.ContentFormatters.Add(formatter); } - deserializer.Options = options; - return deserializer; + formatter.Options = options; + return formatter; } /// @@ -88,29 +57,18 @@ public static JsonContentDeserializer AcceptJson(this HttpRestClient client, Jso /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Options = client.GetJsonSerializerOptions() }; - return client.SendAsync(method, uri, jsonContent, cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with JSON-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with JSON-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -118,25 +76,13 @@ public static Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Options = client.GetJsonSerializerOptions() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -148,14 +94,14 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -166,14 +112,13 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -185,14 +130,14 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -203,14 +148,13 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -222,14 +166,14 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -240,14 +184,26 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default options. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static JsonFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.Json/JsonContent.cs b/src/Kampute.HttpClient.Json/JsonContent.cs index 2f5e08c..2f89b3a 100644 --- a/src/Kampute.HttpClient.Json/JsonContent.cs +++ b/src/Kampute.HttpClient.Json/JsonContent.cs @@ -15,7 +15,7 @@ namespace Kampute.HttpClient.Json using System.Threading.Tasks; /// - /// Represents HTTP content based on JSON serialized from an object. + /// Represents application/json content that serializes an object with System.Text.Json when it is sent. /// public sealed class JsonContent : HttpContent { @@ -24,7 +24,7 @@ public sealed class JsonContent : HttpContent /// /// Initializes a new instance of the class. /// - /// The object to be serialized into JSON format. + /// The object to serialize. /// Thrown if is . public JsonContent(object content) { @@ -37,20 +37,20 @@ public JsonContent(object content) } /// - /// Gets or sets the JSON serialization options. + /// Gets or sets the serializer options. /// /// - /// The JSON serialization options, if any. + /// The options used to serialize the object, or for the defaults of the serializer. /// public JsonSerializerOptions? Options { get; set; } /// - /// Serializes the content to a stream asynchronously. + /// Writes the object to a stream as JSON. /// - /// The target stream. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return JsonSerializer.SerializeAsync(stream, _content, Options); } diff --git a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs b/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs deleted file mode 100644 index 56c1c45..0000000 --- a/src/Kampute.HttpClient.Json/JsonContentDeserializer.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Json package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Json -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.Net.Http; - using System.Text; - using System.Text.Json; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing JSON content from HTTP responses into objects. - /// - public sealed class JsonContentDeserializer : HttpContentDeserializer - { - /// - /// Initializes a new instance of the class. - /// - public JsonContentDeserializer() - : base(MediaTypeNames.Application.Json) - { - } - - /// - /// Gets or sets the JSON deserialization options. - /// - /// - /// The JSON deserialization options, if any. - /// - public JsonSerializerOptions? Options { get; set; } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) - { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - if (encoding == Encoding.UTF8) - { - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - return await JsonSerializer.DeserializeAsync(stream, modelType, Options, cancellationToken).ConfigureAwait(false); - } - - var jsonString = await content.ReadAsStringAsync().ConfigureAwait(false); - return JsonSerializer.Deserialize(jsonString, modelType, Options); - } - } -} diff --git a/src/Kampute.HttpClient.Json/JsonFormatter.cs b/src/Kampute.HttpClient.Json/JsonFormatter.cs new file mode 100644 index 0000000..9d73ecc --- /dev/null +++ b/src/Kampute.HttpClient.Json/JsonFormatter.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient.Json package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Json +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/json content with System.Text.Json. + /// + /// + /// Register the formatter with . Its apply both to the responses it + /// reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests + /// of the client. + /// + public sealed class JsonFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public JsonFormatter() + : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + { + } + + /// + /// Gets or sets the JSON serializer options. + /// + /// + /// The options used to read responses and write payloads, or for the defaults of . + /// + public JsonSerializerOptions? Options { get; set; } + + /// + /// Asynchronously reads an object of the specified type from JSON content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + + if (encoding == Encoding.UTF8) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, modelType, Options, cancellationToken).ConfigureAwait(false); + } + + var jsonString = await content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(jsonString, modelType, Options); + } + + /// + /// Creates JSON content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// A with the of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new JsonContent(payload) { Options = Options }; + } + } +} diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index 2471e1c..5c2693a 100644 --- a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj +++ b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj @@ -1,11 +1,11 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 Kampute.HttpClient.Json This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using System.Text.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 2.5.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest @@ -35,7 +35,7 @@ - + diff --git a/src/Kampute.HttpClient.Json/README.md b/src/Kampute.HttpClient.Json/README.md index 2c6d3be..94af86d 100644 --- a/src/Kampute.HttpClient.Json/README.md +++ b/src/Kampute.HttpClient.Json/README.md @@ -1,13 +1,12 @@ # Kampute.HttpClient.Json -`Kampute.HttpClient.Json` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) library, -designed to enhance its functionality by providing support for handling `application/json` content types. This package leverages the -`System.Text.Json` for efficient serialization and deserialization of JSON data, simplifying the process of sending and receiving -JSON payloads in RESTful API communications. +JSON support for [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient), using `System.Text.Json` to read responses and write request payloads. + +[Content formats guide](https://kampute.github.io/http-client/overview/content-formats.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html) ## Installation -Install `Kampute.HttpClient.Json` via NuGet: +The package includes the core client as a dependency. ```shell dotnet add package Kampute.HttpClient.Json @@ -15,34 +14,26 @@ dotnet add package Kampute.HttpClient.Json ## Usage -To enable JSON processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.Json` namespace and -use the provided extension methods. +Register the JSON formatter before requesting a typed response. Replace the example URL with your API; this model assumes a response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.Json; -// Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); +client.UseJson(); -// Configure the client to accept JSON responses. -client.AcceptJson(); +var resource = await client.GetAsync("https://api.example.com/resource"); -// Sending a JSON payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsJsonAsync("https://api.example.com/resource", payload); +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} ``` -## Documentation - -For details on how to utilize the `Kampute.HttpClient.Json` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.Json.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. +Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payloads. See [Sending requests](https://kampute.github.io/http-client/overview/sending-requests.html) for examples and [Content formats](https://kampute.github.io/http-client/overview/content-formats.html) for serializer configuration. ## License -`Kampute.HttpClient.Json` is licensed under the terms of the [MIT](LICENSE) license. +Kampute.HttpClient.Json is released under the [MIT License](LICENSE). diff --git a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs index c6894d5..da6bc73 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/HttpRestClientJsonExtensions.cs @@ -7,75 +7,44 @@ namespace Kampute.HttpClient.NewtonsoftJson { using Newtonsoft.Json; using System; - using System.Collections.Concurrent; using System.Net.Http; using System.Threading; using System.Threading.Tasks; /// - /// Provides extension methods for to support JSON-based HTTP operations. + /// Provides extension methods for that register a Newtonsoft.Json formatter and send JSON payloads. /// /// - /// This static class enhances by offering methods specifically designed for handling HTTP - /// requests and responses that involve JSON data. It simplifies the process of sending and receiving JSON content, by - /// abstracting the serialization and deserialization of JSON to and from .NET objects. + /// registers a , which lets the client read JSON responses and advertise JSON in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default settings if none + /// is registered. /// public static class HttpRestClientJsonExtensions { - private static readonly ConcurrentDictionary serializerSettings = new(); - - private static void ClientDisposing(object sender, EventArgs e) => SetJsonSerializerSettings((HttpRestClient)sender, null); - - /// - /// Configures the to use the specified settings when serializing payloads as JSON. - /// - /// The instance to configure. - /// The to use for serializing payload as JSON. if , default settings will be used. - public static void SetJsonSerializerSettings(this HttpRestClient client, JsonSerializerSettings? settings) - { - client.Disposing -= ClientDisposing; - if (settings is not null) - { - serializerSettings[client] = settings; - client.Disposing += ClientDisposing; - } - else - { - serializerSettings.TryRemove(client, out _); - } - } - - /// - /// Retrieves the settings used by the when serializing payloads as JSON. - /// - /// The instance to query. - /// The if set; otherwise, . - public static JsonSerializerSettings? GetJsonSerializerSettings(this HttpRestClient client) - { - serializerSettings.TryGetValue(client, out var settings); - return settings; - } - /// - /// Configures the to accept JSON responses by adding or updating a in its response deserializers collection. + /// Registers a with the client, or updates the registered one. /// /// The instance to configure. - /// The to use for deserializing JSON responses. if , default settings will be used. - /// The used for JSON content deserialization. + /// The to use for reading responses and writing payloads. If , default settings are used. + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method updates its settings with the provided . - /// Otherwise, it adds a new with the specified settings to the client's response deserializers. + /// If the client already has a , this method sets its settings to . Otherwise, it adds a new + /// with to . /// - public static JsonContentDeserializer AcceptJson(this HttpRestClient client, JsonSerializerSettings? settings = null) + public static NewtonsoftJsonFormatter UseNewtonsoftJson(this HttpRestClient client, JsonSerializerSettings? settings = null) { - var deserializer = client.ResponseDeserializers.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new JsonContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + formatter = new NewtonsoftJsonFormatter(); + client.ContentFormatters.Add(formatter); } - deserializer.Settings = settings; - return deserializer; + formatter.Settings = settings; + return formatter; } /// @@ -88,29 +57,18 @@ public static JsonContentDeserializer AcceptJson(this HttpRestClient client, Jso /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Settings = client.GetJsonSerializerSettings() }; - return client.SendAsync(method, uri, jsonContent, cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with JSON-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with JSON-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -118,25 +76,13 @@ public static Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsJsonAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsJsonAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - var jsonContent = new JsonContent(payload) { Settings = client.GetJsonSerializerSettings() }; - using var _ = await client.SendAsync(method, uri, jsonContent, cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -148,14 +94,14 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -166,14 +112,13 @@ public static async Task SendAsJsonAsync /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -185,14 +130,14 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -203,14 +148,13 @@ public static Task PostAsJsonAsync(this HttpRestClient client, string uri, objec /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -222,14 +166,14 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -240,14 +184,26 @@ public static Task PutAsJsonAsync(this HttpRestClient client, string uri, object /// The object to serialize as the JSON-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsJsonAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsJsonAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default settings. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static NewtonsoftJsonFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs b/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs deleted file mode 100644 index 8135531..0000000 --- a/src/Kampute.HttpClient.NewtonsoftJson/JsonContentDeserializer.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.NewtonsoftJson package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.NewtonsoftJson -{ - using Kampute.HttpClient.Content.Abstracts; - using Newtonsoft.Json; - using System; - using System.IO; - using System.Net.Http; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing JSON content from HTTP responses into objects. - /// - public sealed class JsonContentDeserializer : HttpContentDeserializer - { - /// - /// Initializes a new instance of the class. - /// - public JsonContentDeserializer() - : base(MediaTypeNames.Application.Json) - { - } - - /// - /// Gets or sets the JSON deserialization settings. - /// - /// - /// The JSON deserialization settings, if any. - /// - public JsonSerializerSettings? Settings { get; set; } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) - { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var jsonReader = new JsonTextReader(streamReader); - var serializer = JsonSerializer.CreateDefault(Settings); - return serializer.Deserialize(jsonReader, modelType); - } - } -} diff --git a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj index 6ad6724..1f26196 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj +++ b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj @@ -1,11 +1,11 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 Kampute.HttpClient.NewtonsoftJson This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using Newtonsoft.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 2.5.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest diff --git a/src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs similarity index 76% rename from src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs rename to src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs index c714dc0..075d502 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/JsonContent.cs +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonContent.cs @@ -15,20 +15,20 @@ namespace Kampute.HttpClient.NewtonsoftJson using System.Threading.Tasks; /// - /// Represents HTTP content based on JSON serialized from an object. + /// Represents application/json content that serializes an object with Newtonsoft.Json when it is sent. /// - public sealed class JsonContent : HttpContent + public sealed class NewtonsoftJsonContent : HttpContent { private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); private readonly object _content; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The object to be serialized into JSON format. + /// The object to serialize. /// Thrown if is . - public JsonContent(object content) + public NewtonsoftJsonContent(object content) { _content = content ?? throw new ArgumentNullException(nameof(content)); @@ -39,20 +39,20 @@ public JsonContent(object content) } /// - /// Gets or sets the JSON serialization settings. + /// Gets or sets the serializer settings. /// /// - /// The JSON serialization settings, if any. + /// The settings used to serialize the object, or for the defaults of the serializer. /// public JsonSerializerSettings? Settings { get; set; } /// - /// Serializes the content to a stream asynchronously. + /// Writes the object to a stream as JSON. /// - /// The target stream. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { using var streamWriter = new StreamWriter(stream, utf8WithoutMarker, 4096, true); using var jsonWriter = new JsonTextWriter(streamWriter); diff --git a/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs new file mode 100644 index 0000000..53d1cd9 --- /dev/null +++ b/src/Kampute.HttpClient.NewtonsoftJson/NewtonsoftJsonFormatter.cs @@ -0,0 +1,75 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient.NewtonsoftJson package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.NewtonsoftJson +{ + using Kampute.HttpClient.Content.Abstracts; + using Newtonsoft.Json; + using System; + using System.IO; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/json content with Newtonsoft.Json. + /// + /// + /// Register the formatter with . Its apply both to the responses + /// it reads and to the payloads it writes. Change them before the client sends requests, because a registered formatter is shared by all requests + /// of the client. + /// + public sealed class NewtonsoftJsonFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public NewtonsoftJsonFormatter() + : base([MediaTypeNames.Application.Json], [MediaTypeNames.Application.Json]) + { + } + + /// + /// Gets or sets the JSON serializer settings. + /// + /// + /// The settings used to read responses and write payloads, or for the defaults of . + /// + public JsonSerializerSettings? Settings { get; set; } + + /// + /// Asynchronously reads an object of the specified type from JSON content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var streamReader = new StreamReader(stream, encoding); + using var jsonReader = new JsonTextReader(streamReader); + var serializer = JsonSerializer.CreateDefault(Settings); + return serializer.Deserialize(jsonReader, modelType); + } + + /// + /// Creates JSON content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// A with the of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new NewtonsoftJsonContent(payload) { Settings = Settings }; + } + } +} diff --git a/src/Kampute.HttpClient.NewtonsoftJson/README.md b/src/Kampute.HttpClient.NewtonsoftJson/README.md index d1c1fca..b44f4a7 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/README.md +++ b/src/Kampute.HttpClient.NewtonsoftJson/README.md @@ -1,13 +1,12 @@ # Kampute.HttpClient.NewtonsoftJson -`Kampute.HttpClient.NewtonsoftJson` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) -library, designed to enhance its functionality by providing support for handling `application/json` content types. This package leverages -the `Newtonsoft.Json` for efficient serialization and deserialization of JSON data, simplifying the process of sending and receiving JSON -payloads in RESTful API communications. +JSON support for [Kampute.HttpClient](https://www.nuget.org/packages/Kampute.HttpClient), using `Newtonsoft.Json` to read responses and write request payloads. + +[Content formats guide](https://kampute.github.io/http-client/overview/content-formats.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html) ## Installation -Install `Kampute.HttpClient.NewtonsoftJson` via NuGet: +The package includes the core client as a dependency. ```shell dotnet add package Kampute.HttpClient.NewtonsoftJson @@ -15,34 +14,26 @@ dotnet add package Kampute.HttpClient.NewtonsoftJson ## Usage -To enable JSON processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.NewtonsoftJson` namespace -and use the provided extension methods. +Register the JSON formatter before requesting a typed response. Replace the example URL with your API; this model assumes a response such as `{"Id":42,"Name":"Example"}`. ```csharp using Kampute.HttpClient; using Kampute.HttpClient.NewtonsoftJson; -// Create a new instance of the HttpRestClient. using var client = new HttpRestClient(); +client.UseNewtonsoftJson(); -// Configure the client to accept JSON responses. -client.AcceptJson(); +var resource = await client.GetAsync("https://api.example.com/resource"); -// Sending a JSON payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsJsonAsync("https://api.example.com/resource", payload); +public sealed class Resource +{ + public int Id { get; set; } + public string? Name { get; set; } +} ``` -## Documentation - -For details on how to utilize the `Kampute.HttpClient.NewtonsoftJson` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.NewtonsoftJson.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. +Use `PostAsJsonAsync`, `PutAsJsonAsync`, or `PatchAsJsonAsync` to send JSON payloads. See [Sending requests](https://kampute.github.io/http-client/overview/sending-requests.html) for examples and [Content formats](https://kampute.github.io/http-client/overview/content-formats.html) for serializer configuration. ## License -`Kampute.HttpClient.NewtonsoftJson` is licensed under the terms of the [MIT](LICENSE) license. +Kampute.HttpClient.NewtonsoftJson is released under the [MIT License](LICENSE). diff --git a/src/Kampute.HttpClient.Xml/ICON.png b/src/Kampute.HttpClient.Xml/ICON.png deleted file mode 100644 index 7293eba..0000000 Binary files a/src/Kampute.HttpClient.Xml/ICON.png and /dev/null differ diff --git a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj deleted file mode 100644 index 2060251..0000000 --- a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - netstandard2.0;netstandard2.1 - Kampute.HttpClient.Xml - This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using XmlSerializer for serialization and deserialization of XML responses and payloads. - Kambiz Khojasteh - 2.5.1 - Kampute - Copyright (c) 2025 Kampute - latest - enable - true - snupkg - true - false - Kampute.HttpClient.Xml - http http-client restful rest-client rest-api web-api xml - ICON.png - README.md - LICENSE - For detailed release notes, please visit https://github.com/kampute/http-client/releases - https://kampute.github.io/http-client/ - https://github.com/kampute/http-client.git - git - IDE0290 - - - - true - ../../SigningKey.snk - - - - - - - - - - - - - diff --git a/src/Kampute.HttpClient.Xml/LICENSE b/src/Kampute.HttpClient.Xml/LICENSE deleted file mode 100644 index b7a7c64..0000000 --- a/src/Kampute.HttpClient.Xml/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) Kampute - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/src/Kampute.HttpClient.Xml/README.md b/src/Kampute.HttpClient.Xml/README.md deleted file mode 100644 index 1cc8130..0000000 --- a/src/Kampute.HttpClient.Xml/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Kampute.HttpClient.Xml - -`Kampute.HttpClient.Xml` is an extension for the [`Kampute.HttpClient`](https://www.nuget.org/packages/Kampute.HttpClient) library, -designed to enhance its functionality by providing support for handling `application/xml` content types. This package leverages -the `XmlSerializer` for efficient serialization and deserialization of XML data, simplifying the process of sending and receiving -XML payloads in RESTful API communications. - -## Installation - -Install `Kampute.HttpClient.Xml` via NuGet: - -```shell -dotnet add package Kampute.HttpClient.Xml -``` - -## Usage - -To enable XML processing capabilities in your `HttpRestClient` instance, simply import the `Kampute.HttpClient.Xml` namespace and -use the provided extension methods. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Xml; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept XML responses. -client.AcceptXml(); - -// Sending an XML payload to an API endpoint. -var payload = new MyPayload(); -var result = await client.PostAsXmlAsync("https://api.example.com/resource", payload); -``` - -## Documentation - -For details on how to utilize the `Kampute.HttpClient.Xml` extension, including class references, method signatures, and property -descriptions, please refer to its [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.Xml.html). - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new -features, please open an issue first to discuss what you would like to change. - -## License - -`Kampute.HttpClient.Xml` is licensed under the terms of the [MIT](LICENSE) license. diff --git a/src/Kampute.HttpClient.Xml/XmlContent.cs b/src/Kampute.HttpClient.Xml/XmlContent.cs deleted file mode 100644 index 41bcf09..0000000 --- a/src/Kampute.HttpClient.Xml/XmlContent.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Xml -{ - using System; - using System.IO; - using System.Net; - using System.Net.Http; - using System.Net.Http.Headers; - using System.Text; - using System.Threading.Tasks; - using System.Xml; - using System.Xml.Serialization; - - /// - /// Represents HTTP content based on XML serialized from an object. - /// - public sealed class XmlContent : HttpContent - { - private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); - - private readonly object _content; - private readonly Encoding _encoding; - - /// - /// Initializes a new instance of the class using a specified content object with UTF-8 encoding. - /// - /// The object to be serialized into XML format. - /// Thrown if is . - public XmlContent(object payload) - : this(payload, utf8WithoutMarker) - { - } - - /// - /// Initializes a new instance of the class using a specified content object and encoding. - /// - /// The object to be serialized into XML format. - /// The character encoding to use for the serialized XML content. - /// Thrown if or is . - public XmlContent(object content, Encoding encoding) - { - _content = content ?? throw new ArgumentNullException(nameof(content)); - _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); - - Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) - { - CharSet = encoding.WebName - }; - } - - /// - /// Gets the character encoding of the serialized XML content. - /// - /// - /// The character encoding of the serialized XML content. - /// - public Encoding Encoding => _encoding; - - /// - /// Serializes the content to a stream asynchronously. - /// - /// The target stream. - /// The transport context. - /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) - { - using var streamWriter = new StreamWriter(stream, _encoding, 4096, true); - using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings - { - Encoding = _encoding, - OmitXmlDeclaration = false, - CheckCharacters = true, - Indent = false, - }); - var serializer = new XmlSerializer(_content.GetType()); - serializer.Serialize(xmlWriter, _content); - return Task.CompletedTask; - } - - /// - /// Attempts to compute the length of the content. - /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . - protected override bool TryComputeLength(out long length) - { - length = -1; - return false; - } - } -} diff --git a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs b/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs deleted file mode 100644 index 7ac0339..0000000 --- a/src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Xml -{ - using Kampute.HttpClient.Content.Abstracts; - using System; - using System.IO; - using System.Net.Http; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Xml; - using System.Xml.Serialization; - - /// - /// Provides functionality for deserializing XML content from HTTP responses into objects. - /// - public sealed class XmlContentDeserializer : HttpContentDeserializer - { - /// - /// Initializes a new instance of the class. - /// - public XmlContentDeserializer() - : base(MediaTypeNames.Application.Xml) - { - } - - /// - /// Asynchronously reads an object from the provided . - /// - /// The to read from. - /// The type of the object to read. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, containing the deserialized object. - /// Thrown if or is . - public override async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) - { - if (content is null) - throw new ArgumentNullException(nameof(content)); - if (modelType is null) - throw new ArgumentNullException(nameof(modelType)); - - var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; - - using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); - using var streamReader = new StreamReader(stream, encoding); - using var xmlReader = XmlReader.Create(streamReader); - return new XmlSerializer(modelType).Deserialize(xmlReader); - } - } -} diff --git a/src/Kampute.HttpClient/AuthSchemes.cs b/src/Kampute.HttpClient/AuthSchemes.cs index f37718a..51950a7 100644 --- a/src/Kampute.HttpClient/AuthSchemes.cs +++ b/src/Kampute.HttpClient/AuthSchemes.cs @@ -6,50 +6,47 @@ namespace Kampute.HttpClient { /// - /// Provides constants for common HTTP authentication schemes. + /// Provides the names of common HTTP authentication schemes, for use in the Authorization header. /// + /// + /// + /// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, accessToken); + /// + /// public static class AuthSchemes { /// - /// The Bearer authentication scheme as defined in RFC 6750. - /// This scheme is typically used with OAuth 2.0. The bearer token is a cryptic string, usually generated by the server in response to a login request. + /// The Bearer authentication scheme, defined in RFC 6750, which sends an access token such as one issued by an OAuth 2.0 server. /// public const string Bearer = "Bearer"; /// - /// The Basic authentication scheme as defined in RFC 7617. - /// This scheme transmits credentials as user ID/password pairs, encoded using Base64. + /// The Basic authentication scheme, defined in RFC 7617, which sends a user ID and password encoded in Base64. /// public const string Basic = "Basic"; /// - /// The Digest authentication scheme as defined in RFC 7616. - /// This scheme is an enhancement of the Basic scheme, providing a more secure approach to user authentication. + /// The Digest authentication scheme, defined in RFC 7616, which sends a hash of the credentials instead of the password. /// public const string Digest = "Digest"; /// - /// The HOBA (HTTP Origin-Bound Authentication) scheme as defined in RFC 7486. - /// This scheme is a token-based authentication method that binds a token to the origin of the HTTP request. + /// The HTTP Origin-Bound Authentication (HOBA) scheme, defined in RFC 7486, which authenticates with a key pair bound to the origin. /// public const string HOBA = "HOBA"; /// - /// The Mutual authentication scheme. - /// This scheme is a method where both the client and server authenticate each other. - /// Note that this is not defined in an RFC and the usage may vary. + /// The Mutual authentication scheme, defined in RFC 8120, in which the client and the server authenticate each other. /// public const string Mutual = "Mutual"; /// - /// The AWS4-HMAC-SHA256 authentication scheme. - /// This scheme is specific to Amazon Web Services and is used for signing API requests. + /// The AWS Signature Version 4 scheme, which Amazon Web Services uses to sign API requests. /// public const string AWS4HMACSHA256 = "AWS4-HMAC-SHA256"; /// - /// The API Key authentication scheme. - /// In this scheme, a secret API key is included in the HTTP request. + /// A scheme name commonly used to send an API key in the Authorization header. It is not standardized, so check the name your API expects. /// public const string ApiKey = "ApiKey"; } diff --git a/src/Kampute.HttpClient/BackoffStrategies.cs b/src/Kampute.HttpClient/BackoffStrategies.cs deleted file mode 100644 index e305cae..0000000 --- a/src/Kampute.HttpClient/BackoffStrategies.cs +++ /dev/null @@ -1,321 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Kampute.HttpClient.RetryManagement.Strategies; - using System; - - /// - /// Provides a collection of factory methods for creating instances of different retry strategies. - /// - /// - /// - /// This class offers various retry strategies to manage transient failures in distributed systems for flexible, use case-specific configuration. - /// - /// - /// Strategies are ordered by increasing delay approach: - /// - /// - /// None - /// No retries. Best for critical operations where failures should be immediately addressed without retries. - /// - /// - /// Once - /// A single retry after a delay. Suitable for operations where one additional attempt may resolve a transient issue. - /// - /// - /// Uniform - /// Multiple retries with constant delays. Ideal for cases needing multiple attempts with predictable delays. - /// - /// - /// Linear - /// Multiple retries with delays increasing linearly. Optimal for reducing system load with gradually increasing wait times. - /// - /// - /// Fibonacci - /// Multiple retries with delays following the Fibonacci sequence. A balanced choice between aggressive and cautious retry pacing. - /// - /// - /// Exponential - /// Multiple retries with delays growing exponentially. For aggressively minimizing impact on systems by rapidly increasing wait times. - /// - /// - /// - /// - /// The Dynamic strategy stands apart, as its delay can vary based on the context of the failure. It tailors retry attempts to specific conditions, such - /// as error type or system load, offering the flexibility to adapt retry logic for optimal outcomes. This approach is most useful in complex systems where a - /// static retry strategy may not adequately address the nuances of different failure scenarios. - /// - /// - public static class BackoffStrategies - { - /// - /// Gets a strategy where no retry attempts are made. - /// - /// - /// An that defines a retry strategy of no retry attempts. - /// - /// - /// This strategy schedules no retry attempts, making it ideal for operations where failure handling is immediate or managed through other means. - /// - public static IHttpBackoffProvider None { get; } = NoneStrategy.Instance.ToBackoffStrategy(); - - /// - /// Creates a strategy that performs a single retry attempt after the specified delay. - /// - /// The delay before the single retry attempt. - /// An that defines a retry strategy of a single attempt after a specified delay. - /// - /// This strategy performs a single retry attempt after the specified delay. It is suitable for operations where one additional attempt may resolve - /// a transient issue. - /// - public static IHttpBackoffProvider Once(TimeSpan delay) - { - return new UniformStrategy(delay).WithMaxAttempts(1).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs a single retry attempt after the specified date and time. - /// - /// The date and time after which the single retry attempt will be made. - /// An that defines a retry strategy of a single attempt after a specified date and time. - /// - /// This strategy schedules a single retry attempt for a specified future point in time, ensuring operations are retried when certain - /// conditions are likely met. If the specified time has already passed, it immediately schedules the retry attempt. - /// - public static IHttpBackoffProvider Once(DateTimeOffset after) - { - return new UniformStrategy(after - DateTimeOffset.UtcNow).WithMaxAttempts(1).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with a constant delay between each attempt, up to a specified maximum number of retry - /// attempts. - /// - /// The maximum number of retry attempts. - /// The constant delay between each retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with a constant delay between each attempt. It is ideal for cases needing multiple attempts with - /// predictable delays. - /// - public static IHttpBackoffProvider Uniform(uint maxAttempts, TimeSpan delay) - { - return new UniformStrategy(delay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The constant delay between each retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with a constant delay between each attempt, up to a specified timeout. It is ideal for cases needing - /// multiple attempts with predictable delays, but with a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Uniform(TimeSpan timeout, TimeSpan delay) - { - return new UniformStrategy(delay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified maximum number of - /// retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The amount by which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy of multiple attempts with a constant delay between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt. It is optimal for reducing system load with - /// gradually increasing wait times. - /// - public static IHttpBackoffProvider Linear(uint maxAttempts, TimeSpan initialDelay, TimeSpan delayStep) - { - return new LinearStrategy(initialDelay, delayStep).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The amount by which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. It is optimal for - /// reducing system load with gradually increasing wait times, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Linear(TimeSpan timeout, TimeSpan initialDelay, TimeSpan delayStep) - { - return new LinearStrategy(initialDelay, delayStep).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified maximum number of - /// retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt. It is optimal for reducing system load with - /// gradually increasing wait times. - /// - public static IHttpBackoffProvider Linear(uint maxAttempts, TimeSpan initialDelay) - { - return new LinearStrategy(initialDelay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays increasing linearly between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing linearly between each attempt, up to a specified timeout. It is optimal for - /// reducing system load with gradually increasing wait times, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Linear(TimeSpan timeout, TimeSpan initialDelay) - { - return new LinearStrategy(initialDelay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified maximum number - /// of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The rate at which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing exponentially between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays increasing exponentially between each attempt. It is suitable for aggressively minimizing - /// the impact on systems by rapidly increasing wait times between retry attempts. - /// - /// Thrown if is less than 1. - public static IHttpBackoffProvider Exponential(uint maxAttempts, TimeSpan initialDelay, double rate = 2.0) - { - return new ExponentialStrategy(initialDelay, rate).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The rate at which the delay increases for each subsequent retry attempt. - /// An that defines a retry strategy with delays increasing exponentially between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays increasing exponentially between each attempt, up to a specified timeout. It is suitable - /// for aggressively minimizing the impact on systems by rapidly increasing wait times between retry attempts, while enforcing a maximum time limit for - /// retrying. - /// - /// Thrown if is less than 1. - public static IHttpBackoffProvider Exponential(TimeSpan timeout, TimeSpan initialDelay, double rate = 2.0) - { - return new ExponentialStrategy(initialDelay, rate).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified maximum - /// number of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt. It provides a balanced choice between - /// aggressive and cautious retry pacing, suitable for a wide range of scenarios. - /// - public static IHttpBackoffProvider Fibonacci(uint maxAttempts, TimeSpan initialDelay, TimeSpan delayStep) - { - return new FibonacciStrategy(initialDelay, delayStep).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. It provides - /// a balanced choice between aggressive and cautious retry pacing, suitable for a wide range of scenarios, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Fibonacci(TimeSpan timeout, TimeSpan initialDelay, TimeSpan delayStep) - { - return new FibonacciStrategy(initialDelay, delayStep).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified maximum - /// number of retry attempts. - /// - /// The maximum number of retry attempts. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified maximum number of retry attempts. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt. It provides a balanced choice between - /// aggressive and cautious retry pacing, suitable for a wide range of scenarios. - /// - public static IHttpBackoffProvider Fibonacci(uint maxAttempts, TimeSpan initialDelay) - { - return new FibonacciStrategy(initialDelay).WithMaxAttempts(maxAttempts).ToBackoffStrategy(); - } - - /// - /// Creates a strategy that performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// The maximum time to spend retrying. - /// The delay before the first retry attempt. - /// An that defines a retry strategy with delays following the Fibonacci sequence between each attempt, up to a specified timeout. - /// - /// This strategy performs multiple retry attempts with delays following the Fibonacci sequence between each attempt, up to a specified timeout. It provides - /// a balanced choice between aggressive and cautious retry pacing, suitable for a wide range of scenarios, while enforcing a maximum time limit for retrying. - /// - public static IHttpBackoffProvider Fibonacci(TimeSpan timeout, TimeSpan initialDelay) - { - return new FibonacciStrategy(initialDelay).WithTimeout(timeout).ToBackoffStrategy(); - } - - /// - /// Creates an instance of with a dynamic strategy factory based on the context of a failed HTTP request. - /// - /// A factory function that creates instances based on the failed HTTP request context. - /// An instance of . - /// Thrown if is . - /// - /// This strategy offers the highest flexibility by dynamically scheduling retries based on the specific context of a failure. It adapts to the nature of - /// encountered errors, making it ideal for complex systems with varied types of transient failures that cannot be effectively handled by a static retry strategy. - /// - public static IHttpBackoffProvider Dynamic(Func strategyFactory) - { - return new DynamicBackoffStrategy(strategyFactory); - } - - /// - /// Creates an instance of with a dynamic scheduler factory based on the context of a failed HTTP request. - /// - /// A factory function that creates instances based on the failed HTTP request context. - /// An instance of . - /// Thrown if is . - /// - /// This strategy offers the highest flexibility by dynamically scheduling retries based on the specific context of a failure. It adapts to the nature of - /// encountered errors, making it ideal for complex systems with varied types of transient failures that cannot be effectively handled by a static retry strategy. - /// - public static IHttpBackoffProvider Dynamic(Func schedulerFactory) - { - return new DynamicBackoffStrategy(schedulerFactory); - } - } -} diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs index 0adfa7b..5929165 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDecorator.cs @@ -4,38 +4,55 @@ using System.Net.Http; /// - /// Serves as a base class for decorating instances. + /// Provides a base class for content that wraps another . /// /// - /// This class provides common functionality such as copying headers from the original content and managing the lifecycle of the wrapped content. + /// The decorator starts with a copy of the headers of the original content, and disposes the original content when it is disposed, unless it + /// was created to leave it open. /// public abstract class HttpContentDecorator : HttpContent { + private readonly bool _leaveOpen; + /// /// Initializes a new instance of the class. /// - /// The HTTP content to decorate. This content will be disposed when this decorator instance is disposed. + /// The content to wrap. It is disposed when this instance is disposed. /// Thrown when is . protected HttpContentDecorator(HttpContent content) + : this(content, leaveOpen: false) + { + } + + /// + /// Initializes a new instance of the class, specifying whether the wrapped content is disposed with this instance. + /// + /// The content to wrap. + /// + /// to leave undisposed when this decorator instance is disposed; to dispose it. + /// + /// Thrown when is . + protected HttpContentDecorator(HttpContent content, bool leaveOpen) { + _leaveOpen = leaveOpen; OriginalContent = content ?? throw new ArgumentNullException(nameof(content)); foreach (var header in content.Headers) Headers.TryAddWithoutValidation(header.Key, header.Value); } /// - /// Gets the original HTTP content that this instance decorates. + /// Gets the content that this instance wraps. /// - /// The original instance. + /// The wrapped . protected internal HttpContent OriginalContent { get; } /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Releases the resources of this instance, and disposes the wrapped content unless this instance was created to leave it open. /// - /// to release both managed and unmanaged resources; to release only unmanaged resources. + /// when called from ; when called from a finalizer. protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_leaveOpen) OriginalContent.Dispose(); base.Dispose(disposing); diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs deleted file mode 100644 index 80962d1..0000000 --- a/src/Kampute.HttpClient/Content/Abstracts/HttpContentDeserializer.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace Kampute.HttpClient.Content.Abstracts -{ - using Kampute.HttpClient.Interfaces; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Provides functionality for deserializing content from HTTP responses into objects. - /// - public abstract class HttpContentDeserializer : IHttpContentDeserializer - { - /// - /// Initializes a new instance of the class with specified supported media types. - /// - /// An array of media types that this deserializer supports. - /// Thrown if is . - protected HttpContentDeserializer(params string[] supportedMediaTypes) - { - SupportedMediaTypes = supportedMediaTypes ?? throw new ArgumentNullException(nameof(supportedMediaTypes)); - } - - /// - /// Gets the collection of media types that this deserializer supports. - /// - /// The read-only collection of media types that this deserializer can handle. - public IReadOnlyCollection SupportedMediaTypes { get; } - - /// - /// Retrieves a collection of supported media types for a specific model type. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings representing the media types supported for the specified model type. - public virtual IEnumerable GetSupportedMediaTypes(Type modelType) - { - return modelType is not null ? SupportedMediaTypes : []; - } - - /// - /// Determines whether this deserializer can handle data of a specific media type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The target model type for deserialization. - /// if the deserializer supports the media type and the model type is not ; otherwise, . - public virtual bool CanDeserialize(string mediaType, Type modelType) - { - return SupportedMediaTypes.Contains(mediaType); - } - - /// - /// Asynchronously reads and deserializes an object from the provided . - /// - /// The to read from. - /// The type of the object to be deserialized. - /// A token for canceling the read operation (optional). - /// A task representing the asynchronous read operation, which upon completion contains the deserialized object, or if deserialization fails. - /// Thrown if either or is . - public abstract Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); - } -} diff --git a/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs new file mode 100644 index 0000000..f20d6d6 --- /dev/null +++ b/src/Kampute.HttpClient/Content/Abstracts/HttpContentFormatter.cs @@ -0,0 +1,204 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content.Abstracts +{ + using Kampute.HttpClient.Interfaces; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Provides a base class for content formatters that read and write a fixed set of media types. + /// + /// + /// + /// A derived class passes the media types it reads and the media types it writes to the constructor. A receive-only formatter passes no writable + /// media types and overrides ; a send-only formatter passes no readable media types and overrides ; + /// a two-way formatter does both. To limit the types a formatter handles, override or . + /// + /// + /// Media types are compared ignoring case. and validate their arguments before they call the + /// overridable members. + /// + /// + public abstract class HttpContentFormatter : IHttpContentFormatter + { + /// + /// Initializes a new instance of the class with the media types it reads and writes. + /// + /// The media types this formatter reads, in order of preference; empty for a send-only formatter. + /// The media types this formatter writes, in order of preference; empty for a receive-only formatter. + /// Thrown if or is . + protected HttpContentFormatter(IEnumerable readableMediaTypes, IEnumerable writableMediaTypes) + { + if (readableMediaTypes is null) + throw new ArgumentNullException(nameof(readableMediaTypes)); + if (writableMediaTypes is null) + throw new ArgumentNullException(nameof(writableMediaTypes)); + + ReadableMediaTypes = readableMediaTypes.ToArray(); + WritableMediaTypes = writableMediaTypes.ToArray(); + } + + /// + /// Gets the media types this formatter reads. + /// + /// + /// The media types this formatter reads, in order of preference. + /// + public IReadOnlyCollection ReadableMediaTypes { get; } + + /// + /// Gets the media types this formatter writes. + /// + /// + /// The media types this formatter writes, in order of preference. + /// + public IReadOnlyCollection WritableMediaTypes { get; } + + /// + /// Returns the media types that this formatter can read into the specified model type. + /// + /// The type of the object to read. + /// + /// if accepts ; otherwise, an empty collection. + /// + public virtual IEnumerable GetReadableMediaTypes(Type modelType) + { + return modelType is not null && ReadableMediaTypes.Count != 0 && CanReadType(modelType) ? ReadableMediaTypes : []; + } + + /// + /// Determines whether this formatter can read content of the specified media type into the specified model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// + /// if is one of the and accepts + /// ; otherwise, . + /// + public virtual bool CanRead(string mediaType, Type modelType) + { + return mediaType is not null && modelType is not null + && ReadableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) + && CanReadType(modelType); + } + + /// + /// Asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation (optional). + /// A task that resolves to the object read from . + /// Thrown if or is . + public Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + if (modelType is null) + throw new ArgumentNullException(nameof(modelType)); + + return ReadContentAsync(content, modelType, cancellationToken); + } + + /// + /// Returns the media types in which this formatter can write a payload of the specified type. + /// + /// The type of the payload to write. + /// + /// if accepts ; otherwise, an empty collection. + /// + public virtual IEnumerable GetWritableMediaTypes(Type payloadType) + { + return payloadType is not null && WritableMediaTypes.Count != 0 && CanWriteType(payloadType) ? WritableMediaTypes : []; + } + + /// + /// Determines whether this formatter can write a payload of the specified type in the specified media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// + /// if is one of the and accepts + /// ; otherwise, . + /// + public virtual bool CanWrite(string mediaType, Type payloadType) + { + return mediaType is not null && payloadType is not null + && WritableMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) + && CanWriteType(payloadType); + } + + /// + /// Creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown if or is . + /// Thrown if returns for and the type of . + public HttpContent Write(object payload, string mediaType) + { + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (!CanWrite(mediaType, payload.GetType())) + throw new NotSupportedException($"{GetType().Name} cannot write an object of type '{payload.GetType()}' as '{mediaType}'."); + + return CreateContent(payload, mediaType); + } + + /// + /// Determines whether this formatter can read objects of the specified type. + /// + /// The type of the object to read. + /// if this formatter can read objects of ; otherwise, . The default is . + protected virtual bool CanReadType(Type modelType) => true; + + /// + /// Determines whether this formatter can write payloads of the specified type. + /// + /// The type of the payload to write. + /// if this formatter can write payloads of ; otherwise, . The default is . + protected virtual bool CanWriteType(Type payloadType) => true; + + /// + /// When overridden in a derived class, asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// Thrown by the base implementation, for a formatter that does not read content. + /// + /// calls this method after it has validated its arguments. + /// + protected virtual Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + throw new NotSupportedException($"{GetType().Name} does not read content."); + } + + /// + /// When overridden in a derived class, creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown by the base implementation, for a formatter that does not write content. + /// + /// calls this method after it has validated its arguments and checked them with . + /// + protected virtual HttpContent CreateContent(object payload, string mediaType) + { + throw new NotSupportedException($"{GetType().Name} does not write content."); + } + } +} diff --git a/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs b/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs index 3e78d72..6cb63a0 100644 --- a/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Content/Abstracts/NamespaceDoc.cs @@ -6,8 +6,7 @@ namespace Kampute.HttpClient.Content.Abstracts { /// - /// This namespace contains abstract classes for HTTP content - /// deserialization and decoration. + /// This namespace contains base classes for content formatters, which read and write HTTP content, and for content decorators. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs index 8318813..08e6926 100644 --- a/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/Abstracts/CompressedContent.cs @@ -8,15 +8,20 @@ using System.Threading.Tasks; /// - /// Provides a base class for creating HTTP content based on compression. + /// Provides a base class for content that compresses another as it is sent. /// + /// + /// The content has the headers of the content it compresses, except Content-Length and Content-MD5, which describe the + /// uncompressed body. It adds its encoding to the Content-Encoding header, and its length is not known until it is sent, so it is sent + /// with chunked transfer encoding. + /// public abstract class CompressedContent : HttpContentDecorator { /// /// Initializes a new instance of the class. /// - /// The HTTP content to compress. - /// The encoding type used for compression (e.g., gzip, deflate). + /// The content to compress. It is disposed when this instance is disposed. + /// The value added to the Content-Encoding header, such as gzip or deflate. /// Thrown when is . /// Thrown when is or empty. protected CompressedContent(HttpContent content, string contentEncoding) @@ -25,33 +30,37 @@ protected CompressedContent(HttpContent content, string contentEncoding) if (string.IsNullOrEmpty(contentEncoding)) throw new ArgumentException("Content encoding cannot be null or empty.", nameof(contentEncoding)); + // The copied length and hash describe the uncompressed body. They are removed rather than set to null, because setting + // ContentLength to null also stops it from reporting the length of a buffered body, which HttpClient on .NET Framework needs. + Headers.Remove("Content-Length"); + Headers.Remove("Content-MD5"); Headers.ContentEncoding.Add(contentEncoding); } /// - /// When overridden in a derived class, returns a stream that wraps the provided base stream with a compression layer. + /// When overridden in a derived class, returns a stream that compresses the data written to it into the specified stream. /// - /// The original stream to wrap with a compression stream. - /// A that compresses the content as it is written to the . + /// The stream that receives the compressed data. + /// A that compresses the data written to it into . protected abstract Stream CompressStream(Stream stream); /// - /// Serializes the HTTP content to a stream as an asynchronous operation. + /// Writes the compressed content to a stream. /// - /// The target stream to which the content will be written. - /// Information about the transport (e.g., channel binding token). - /// The task object representing the asynchronous operation. - protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + /// The stream to write to. + /// The transport context. + /// A task that represents the asynchronous operation. + protected sealed override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) { using var compressionStream = CompressStream(stream); - await OriginalContent.CopyToAsync(compressionStream); + await OriginalContent.CopyToAsync(compressionStream).ConfigureAwait(false); } /// - /// Tries to compute the length of the compressed content. + /// Indicates that the length of the compressed content is not known before it is sent. /// - /// The length of the content, if it can be computed. - /// as the compressed content length is not predictable before compression. + /// Always -1. + /// Always . protected sealed override bool TryComputeLength(out long length) { length = -1; diff --git a/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs index 5ef82cd..3d70b3d 100644 --- a/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/DeflateCompressedContent.cs @@ -7,7 +7,7 @@ using System.Net.Http; /// - /// Provides an HTTP content encapsulation that compresses the underlying content using the Deflate compression algorithm. + /// Represents content that compresses another with Deflate as it is sent. /// public sealed class DeflateCompressedContent : CompressedContent { @@ -16,8 +16,8 @@ public sealed class DeflateCompressedContent : CompressedContent /// /// Initializes a new instance of the class. /// - /// The content to compress using the Deflate compression algorithm. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. + /// The content to compress. It is disposed when this instance is disposed. + /// Whether to favor speed or size. The default is . /// Thrown when is . public DeflateCompressedContent(HttpContent content, CompressionLevel compressionLevel = CompressionLevel.Fastest) : base(content, "deflate") @@ -26,10 +26,10 @@ public DeflateCompressedContent(HttpContent content, CompressionLevel compressio } /// - /// Wraps the provided base stream with a Deflate compression stream. + /// Returns a stream that compresses the data written to it with Deflate into the specified stream. /// - /// The original stream to wrap with a Deflate compression stream. - /// A that applies Deflate compression to the data written to the . + /// The stream that receives the compressed data. + /// A that writes to . protected override Stream CompressStream(Stream stream) { return new DeflateStream(stream, _compressionLevel, leaveOpen: true); diff --git a/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs b/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs index c8df50c..8d81c0e 100644 --- a/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs +++ b/src/Kampute.HttpClient/Content/Compression/GZipCompressedContent.cs @@ -7,7 +7,7 @@ using System.Net.Http; /// - /// Provides an HTTP content encapsulation that compresses the underlying content using the GZIP compression algorithm. + /// Represents content that compresses another with GZip as it is sent. /// public sealed class GzipCompressedContent : CompressedContent { @@ -16,8 +16,8 @@ public sealed class GzipCompressedContent : CompressedContent /// /// Initializes a new instance of the class. /// - /// The content to compress using the GZIP compression algorithm. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. + /// The content to compress. It is disposed when this instance is disposed. + /// Whether to favor speed or size. /// Thrown when is . public GzipCompressedContent(HttpContent content, CompressionLevel compressionLevel) : base(content, "gzip") @@ -26,10 +26,10 @@ public GzipCompressedContent(HttpContent content, CompressionLevel compressionLe } /// - /// Wraps the provided base stream with a GZIP compression stream. + /// Returns a stream that compresses the data written to it with GZip into the specified stream. /// - /// The original stream to wrap with a GZIP compression stream. - /// A that applies GZIP compression to the data written to the . + /// The stream that receives the compressed data. + /// A that writes to . protected override Stream CompressStream(Stream stream) { return new GZipStream(stream, _compressionLevel, leaveOpen: true); diff --git a/src/Kampute.HttpClient/Content/EmptyContent.cs b/src/Kampute.HttpClient/Content/EmptyContent.cs index 05a14b8..95b1820 100644 --- a/src/Kampute.HttpClient/Content/EmptyContent.cs +++ b/src/Kampute.HttpClient/Content/EmptyContent.cs @@ -6,30 +6,29 @@ using System.Threading.Tasks; /// - /// Represents an HTTP content with no data. + /// Represents content with no body, whose length is zero. /// /// - /// This class is used when an HTTP request or response needs to indicate a content body, but there should be no actual content sent or received. - /// It effectively sets the content length to 0 and does not write anything to the output stream. + /// Use it to send a request that needs content, such as one with Content-* headers, but no body. /// public sealed class EmptyContent : HttpContent { /// - /// Serializes the content to a stream asynchronously. + /// Writes nothing to the stream. /// - /// The target stream to which the content should be written. + /// The stream to write to. /// The transport context. /// A task that represents the asynchronous operation. - protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) { return Task.CompletedTask; } /// - /// Attempts to compute the length of the content. + /// Returns the length of the content, which is zero. /// - /// When this method returns, contains the length of the content in bytes. - /// if the length could be computed; otherwise, . + /// Always 0. + /// Always . protected override bool TryComputeLength(out long length) { length = 0; diff --git a/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs b/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs new file mode 100644 index 0000000..a598c30 --- /dev/null +++ b/src/Kampute.HttpClient/Content/FormUrlEncodedFormatter.cs @@ -0,0 +1,52 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Collections.Generic; + using System.Net.Http; + + /// + /// Writes collections of key-value pairs as URL-encoded form content. + /// + /// + /// This formatter is send-only: it writes payloads that implement of with + /// string keys and values as application/x-www-form-urlencoded content, and does not read responses. The form helpers of + /// use it. + /// + public sealed class FormUrlEncodedFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public FormUrlEncodedFormatter() + : base([], [MediaTypeNames.Application.FormUrlEncoded]) + { + } + + /// + /// Determines whether the payload type is a collection of string key-value pairs. + /// + /// The type of the payload to write. + /// if implements of with string keys and values; otherwise, . + protected override bool CanWriteType(Type payloadType) + { + return typeof(IEnumerable>).IsAssignableFrom(payloadType); + } + + /// + /// Creates URL-encoded form content from the payload. + /// + /// The collection of key-value pairs to write. + /// The media type of the content to create. + /// A that carries . + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new FormUrlEncodedContent((IEnumerable>)payload); + } + } +} diff --git a/src/Kampute.HttpClient/Content/NamespaceDoc.cs b/src/Kampute.HttpClient/Content/NamespaceDoc.cs index 5808a4f..02d0491 100644 --- a/src/Kampute.HttpClient/Content/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Content/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient.Content { /// - /// This namespace contains classes for handling HTTP content. + /// This namespace contains content formatters and HttpContent types that requests can send. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Content/NonOwningContent.cs b/src/Kampute.HttpClient/Content/NonOwningContent.cs new file mode 100644 index 0000000..83d45a8 --- /dev/null +++ b/src/Kampute.HttpClient/Content/NonOwningContent.cs @@ -0,0 +1,68 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Content +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// Represents an HTTP content that sends another content unchanged, without taking ownership of it. + /// + /// + /// + /// This class passes the headers and body of the original content through unchanged. Disposing an instance of this class does not dispose + /// the original content, so the original content stays usable and its owner remains responsible for disposing it. + /// + /// + /// Use this class when a component that disposes the content it receives must not end the life of the original content. For example, + /// on .NET Framework disposes the content of every request it sends, which prevents the same + /// content from being sent again. Sending a instead keeps the original content available for another attempt. + /// + /// + /// The original content can be sent again only if it is reusable. For example, a over a stream that cannot be + /// read twice remains non-reusable when wrapped. + /// + /// + public sealed class NonOwningContent : HttpContentDecorator + { + /// + /// Initializes a new instance of the class. + /// + /// The HTTP content to send. It is not disposed when this instance is disposed. + /// Thrown when is . + public NonOwningContent(HttpContent content) + : base(content, leaveOpen: true) + { + } + + /// + /// Writes the original content to a stream. + /// + /// The stream to write to. + /// The transport context. + /// A task that represents the asynchronous operation. + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return OriginalContent.CopyToAsync(stream, context); + } + + /// + /// Tries to compute the length of the content. + /// + /// When this method returns, contains the length of the original content in bytes, if it is known. + /// if the length of the original content is known; otherwise, . + protected override bool TryComputeLength(out long length) + { + var contentLength = OriginalContent.Headers.ContentLength; + length = contentLength.GetValueOrDefault(-1); + return contentLength.HasValue; + } + } +} diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs index 1ef59a7..14873a9 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/NamespaceDoc.cs @@ -6,8 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { /// - /// This namespace contains abstract classes for handling transient HTTP error responses by implementing - /// backoff and retry strategies. + /// This namespace contains the base class of the error handlers that retry a request after an error response with a transient status code. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs index eef599d..2a4f2a2 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/Abstracts/RetryableHttpErrorHandler.cs @@ -6,57 +6,110 @@ namespace Kampute.HttpClient.ErrorHandlers.Abstracts { using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; using System; using System.Net; using System.Threading; using System.Threading.Tasks; /// - /// Provides the base functionality for handling HTTP responses with transient error status codes by attempting to back off and - /// retry the request according to a specified or default backoff strategy. + /// Provides the base class for error handlers that retry a request after a delay when it receives an error response with a transient + /// status code. /// /// - /// This handler class is designed to be extended for specific transient error status codes. It offers a mechanism to respond to - /// transient HTTP errors by retrying the request after a delay. The delay duration and retry logic can be customized through the - /// delegate. + /// + /// A derived class chooses the status codes it handles by overriding , and can change where the suggested retry time + /// is read from and which policy applies when provides none. + /// + /// + /// The handler decides how to retry when it handles the first error response of a call, and applies that decision to every later error response + /// it handles during the same call: + /// + /// If returns a policy, that policy decides whether and when the request is retried. + /// Otherwise, the policy returned by decides. + /// + /// + /// + /// By default, if the first error response suggests a retry time, for example in a Retry-After header, the request is retried once, at + /// that time. If the retry receives another error response that this handler handles, the error reaches the caller, whatever retry time the new + /// response suggests. If the first error response suggests no retry time, the policy of for that case applies to + /// the rest of the call, and retry times suggested by later responses do not change its delays. Either way, a server cannot keep a call waiting + /// by moving its suggested retry time further away. + /// + /// + /// Every suggested retry time is checked against , which is five minutes by default. If a response suggests a time + /// further away, the request is not retried, even when an earlier response of the same call was. + /// + /// + /// The handler counts only the retries it makes. Retries after connection failures, which decides, + /// and retries made by other handlers are counted separately, so a call that fails in several ways can be retried more times in total than + /// any one of their limits allows. + /// /// /// - /// + /// public abstract class RetryableHttpErrorHandler : IHttpErrorHandler { + private TimeSpan? _maxRetryDelay = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the longest wait before a retry that this handler accepts when the server suggests a retry time. + /// + /// + /// The longest accepted wait, or to accept any suggested retry time. The default is five minutes. + /// + /// + /// + /// When a response suggests a retry time, for example in a Retry-After header, and that time is further away than this value, + /// the handler does not retry the request, and the for the response reaches the caller. The check + /// applies to every response the handler handles, including later responses of a call that it has already retried. When the first + /// error response of a call fails the check, is not called. + /// + /// + /// This limit applies only to retry times suggested by the server. It does not limit the delays of a retry policy, such as the + /// used when the response suggests no retry time. + /// + /// + /// Thrown if the value is negative. + public TimeSpan? MaxRetryDelay + { + get => _maxRetryDelay; + set + { + if (value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(value), value, "The maximum retry delay cannot be negative."); + + _maxRetryDelay = value; + } + } + /// - /// A delegate that allows customization of the backoff strategy when responses with transient error status codes are received. + /// Gets or sets a function that chooses the retry policy of this handler for a call. /// /// - /// A function that takes an and an optional representing - /// the suggested retry time, and returns an to be used for the retry operation. + /// A function that receives the context of the error response and the retry time the response suggests, and returns the + /// to use, or to use the policy of . /// /// /// - /// If this delegate is set and returns an , the returned strategy is used for the retry operation. - /// If it is not set, or returns , a default behavior is applied. + /// The function is called once per call, for the first error response this handler handles. The policy it returns decides the retries + /// of that response and of the later error responses this handler handles during the same call. /// /// - /// The delegate receives the following parameters: + /// The function receives the following parameters: /// /// /// context - /// - /// Provides context about the HTTP response that indicates a transient error. It is encapsulated within an - /// instance, allowing for an informed decision on the retry strategy. - /// + /// The of the error response. /// /// /// retryTime - /// - /// Advises on the next retry attempt timing as a value if the response suggests one. If the response - /// does not include a suggested retry time, the value will be . - /// + /// The retry time the response suggests, or if it suggests none. /// /// /// /// - public Func? OnBackoffStrategy { get; set; } + public Func? OnRetryPolicy { get; set; } /// /// Determines whether this handler can process the specified HTTP status code. @@ -66,10 +119,13 @@ public abstract class RetryableHttpErrorHandler : IHttpErrorHandler public abstract bool CanHandle(HttpStatusCode statusCode); /// - /// Extracts the suggested retry time from the HTTP response's header, if present. + /// Reads the retry time that an error response suggests. /// /// The context containing information about the HTTP response. - /// The suggested to retry the request, or if the header is not present. + /// + /// The time the response suggests for the retry, or if it suggests none. The base implementation reads the + /// Retry-After header. + /// /// Thrown if is . protected virtual DateTimeOffset? GetSuggestedRetryTime(HttpResponseErrorContext ctx) { @@ -81,45 +137,73 @@ public abstract class RetryableHttpErrorHandler : IHttpErrorHandler } /// - /// Provides the default backoff strategy when no custom strategy is specified. + /// Returns the retry policy of this handler for a call when provides none. /// - /// The context containing information about the HTTP response. - /// The suggested retry time, if any. - /// An representing the default backoff strategy. + /// The context of the first error response this handler handles during the call. + /// The retry time the response suggests, or if it suggests none. + /// + /// The base implementation returns a policy that retries once, at , if the response suggests a retry time, + /// and the of the client otherwise. + /// /// Thrown if is . - protected virtual IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) + protected virtual IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); - return retryTime.HasValue ? BackoffStrategies.Once(retryTime.Value) : ctx.Client.BackoffStrategy; + return retryTime.HasValue ? RetryStrategies.Once(retryTime.Value).ToHttpRetryPolicy() : ctx.Client.RetryPolicy; } /// - /// Creates a scheduler for retrying the failed request based on the error context. + /// Creates the retry session that decides the retries of this handler for a call. /// - /// The context containing information about the HTTP response that indicates a failure. - /// An that schedules the retry attempts. + /// The context of the first error response this handler handles during the call. + /// An that decides on the retry attempts, or if the request must not be retried. /// Thrown if is . /// - /// The method uses when available. If the delegate is not provided or returns - /// , and the response includes a suggested retry time, a single retry at that time is used. - /// Otherwise the client's default backoff strategy is used. + /// + /// The handler calls this method only for the first error response it handles during a call. The later error responses it handles during + /// the same call reuse the session this method returns. + /// + /// + /// If the response suggests a retry time further away than , the method returns , + /// so the request is not retried. Otherwise, it creates the session from the policy that returns, or from + /// the policy of . + /// /// - protected virtual IRetryScheduler? CreateScheduler(HttpResponseErrorContext ctx) + protected virtual IRetrySession? CreateSession(HttpResponseErrorContext ctx) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); var retryTime = GetSuggestedRetryTime(ctx); - var strategy = OnBackoffStrategy?.Invoke(ctx, retryTime) ?? GetDefaultStrategy(ctx, retryTime); - return strategy.CreateScheduler(ctx); + if (ExceedsMaxRetryDelay(retryTime)) + return null; + + var strategy = OnRetryPolicy?.Invoke(ctx, retryTime) ?? GetDefaultPolicy(ctx, retryTime); + return strategy.CreateSession(ctx); } /// Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - return ctx.ScheduleRetryAsync(CreateScheduler, cancellationToken); + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + if (!ctx.Request.CanClone() || ExceedsMaxRetryDelay(GetSuggestedRetryTime(ctx))) + return Task.FromResult(HttpErrorHandlerResult.NoRetry); + + return ctx.ScheduleRetryAsync(this, CreateSession, cancellationToken); + } + + /// + /// Checks the suggested retry time against the configured limit, including when a retry session already exists. + /// + /// The retry time suggested by the current response, if any. + /// if the suggested time is further away than the configured limit. + private bool ExceedsMaxRetryDelay(DateTimeOffset? retryTime) + { + return retryTime.HasValue && _maxRetryDelay.HasValue && retryTime.Value - DateTimeOffset.UtcNow > _maxRetryDelay.Value; } } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs index 9328361..161d334 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/DynamicHttpErrorHandler.cs @@ -12,43 +12,36 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Threading.Tasks; /// - /// Provides a dynamic mechanism to handle HTTP error status codes and determine retry logic for HTTP requests. + /// Handles error responses with a function, without defining a handler class. /// /// - /// - /// This class implements the interface, allowing for custom and dynamic error handling strategies to be defined at runtime. - /// It encapsulates a delegate that is invoked to determine the retry logic for failed HTTP requests, making it highly flexible and adaptable to various error - /// handling scenarios. - /// - /// - /// Since this class always returns for , it represents a "catch-all" handler that can be used as a fall-back - /// when no other specific error handlers are suitable. - /// + /// The handler accepts every status code, so it is asked about every error response that the handlers added before it do not retry. The function + /// decides, for example by checking , whether to return a request to retry. /// + /// + /// This handler retries a request up to three times, one second apart, while the server answers '409 Conflict'. The policy object is the + /// source of the retries, so they are counted separately from the retries of other handlers. + /// + /// var conflictRetries = RetryStrategies.Constant(TimeSpan.FromSeconds(1)).WithMaxRetries(3).ToHttpRetryPolicy(); + /// + /// client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, cancellationToken) => + /// ctx.Response.StatusCode == HttpStatusCode.Conflict + /// ? ctx.ScheduleRetryAsync(conflictRetries, errorContext => conflictRetries.CreateSession(errorContext), cancellationToken) + /// : Task.FromResult(HttpErrorHandlerResult.NoRetry))); + /// + /// public class DynamicHttpErrorHandler : IHttpErrorHandler { - private readonly Func> _asyncHandler; + private readonly Func> _asyncHandler; /// /// Initializes a new instance of the class. /// - /// The asynchronous delegate to handle HTTP error status codes and decide on retry logic. + /// + /// The function that decides. It receives the context of the error response and a cancellation token, and returns the decision. + /// /// Thrown if is . - /// - /// The delegate receives the following parameters: - /// - /// - /// context - /// Provides context about the HTTP request resulting in a failure response. It is encapsulated within an - /// instance, allowing for an informed decision on the retry strategy. - /// - /// - /// cancellationToken - /// A for canceling the operation. - /// - /// - /// - public DynamicHttpErrorHandler(Func> asyncHandler) + public DynamicHttpErrorHandler(Func> asyncHandler) { _asyncHandler = asyncHandler ?? throw new ArgumentNullException(nameof(asyncHandler)); } @@ -57,11 +50,11 @@ public DynamicHttpErrorHandler(Func /// The HTTP status code to evaluate. - /// Always , indicating that this handler can handle any status code. + /// Always . public bool CanHandle(HttpStatusCode statusCode) => true; /// - /// Invokes the configured asynchronous delegate to determine whether a failed request should be retried. + /// Calls the function of this handler to decide whether to retry a request after an error response. /// /// The context containing information about the HTTP response that indicates a failure. /// A token for canceling the operation. diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 5bd7ce6..99abdf9 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -16,31 +16,37 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Threading.Tasks; /// - /// Handles '401 Unauthorized' HTTP responses by attempting to re-authenticate and retry the request. + /// Handles '401 Unauthorized' responses by obtaining new authorization and retrying the request with it. /// /// /// - /// The class is specifically designed to enhance instances of by providing a mechanism - /// to handle HTTP '401 Unauthorized' responses. When a request made by a instance receives a '401 Unauthorized' status code, - /// this indicates that the request was rejected due to insufficient or missing authentication credentials. The responds - /// to such scenarios by initiating a re-authentication process using a delegate provided at instantiation, to obtain new authentication credentials. + /// When a request receives a '401 Unauthorized' response, the handler calls the authentication function passed to its constructor, sets the + /// authorization it returns as the Authorization header of , and retries the request + /// with it. The handler retries a call once: if the retry is also rejected, the response reaches the caller as an . + /// It does not retry a request whose content cannot be sent again, such as a over a non-seekable + /// stream, and does not call the function for it. /// /// - /// The delegate provided to the constructor is tasked with obtaining new authentication credentials, which might involve interacting with an authentication - /// server or prompting the user for credentials. Successful acquisition of new credentials leads to their application to the - /// instance, allowing the previously failed request to be retried with the updated authentication details. + /// When several requests are rejected at the same time, the function runs once and all of them retry with its result. A request that was rejected + /// with older authorization than the latest one the handler obtained is retried with the latest one, without calling the function. /// /// - /// When an authentication process is underway for a client, subsequent authentication requests from the client will not initiate new processes. Instead, they - /// will await and utilize the outcome of the ongoing authentication. This approach guarantees that the authentication delegate is executed a single time for - /// concurrent requests, ensuring both efficiency and thread safety. - /// - /// - /// A single instance of this error handler can be shared with multiple instances, enabling centralized management of authentication - /// challenges across various client instances that interact with different endpoints, if the instances share the same authentication - /// details. This enables a more efficient use of credentials and reduces the need for frequent re-authentications. + /// One instance can serve several clients that use the same credentials. Keep it alive while the clients use it, and dispose it afterwards. /// /// + /// + /// This handler obtains a new access token when a request is rejected. RefreshAccessTokenAsync stands for the application's own code that + /// requests a token from its authentication service. + /// + /// using var unauthorizedHandler = new HttpError401Handler(async (ctx, cancellationToken) => + /// { + /// var token = await RefreshAccessTokenAsync(cancellationToken); + /// return new AuthenticationHeaderValue(AuthSchemes.Bearer, token); + /// }); + /// + /// client.ErrorHandlers.Add(unauthorizedHandler); + /// + /// /// public class HttpError401Handler : IHttpErrorHandler, IDisposable { @@ -51,25 +57,10 @@ public class HttpError401Handler : IHttpErrorHandler, IDisposable /// Initializes a new instance of the class. /// /// - /// The asynchronous delegate to be invoked to acquire new authorization details. The delegate receives the following parameters: - /// - /// - /// context - /// - /// Provides context about the HTTP response indicating a '401 Unauthorized' error. It is encapsulated within - /// an instance, allowing for an informed decision on authentication. - /// - /// - /// - /// cancellationToken - /// - /// A for canceling the operation. - /// - /// - /// - /// The delegate should return a task resolving to an instance of containing the authorization details - /// necessary for subsequent requests if authentication can be successfully completed. If the authentication process fails, the delegate should - /// return . + /// The function that obtains new authorization. It receives the context of the '401 Unauthorized' response and a cancellation token, and + /// returns the to send, or if authentication fails, in which case the request is + /// not retried. Requests that the function sends with the same client are not handled by this handler, so a rejected token request cannot + /// wait on itself. /// /// Thrown if is . public HttpError401Handler(Func> asyncAuthenticator) @@ -82,24 +73,40 @@ public HttpError401Handler(Func /// The HTTP status code to evaluate. - /// if the handler can process the status code; otherwise, . - /// - /// This implementation specifically handles the HTTP '401 Unauthorized' status code. - /// + /// if is '401 Unauthorized'; otherwise, . public bool CanHandle(HttpStatusCode statusCode) => statusCode == HttpStatusCode.Unauthorized; /// - /// Asynchronously authenticates an HTTP request that resulted in a '401 Unauthorized' response. + /// Returns the authorization to retry a rejected request with. /// - /// The error context for the HTTP response. + /// The context of the '401 Unauthorized' response. /// A token for canceling the operation. - /// A task that resolves to an if the client successfully acquires new authorization details; otherwise, . - /// Throws if is . - protected virtual async Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + /// A task that resolves to the authorization to send, or to if authentication failed. + /// Thrown if is . + /// + /// If the request was sent with authorization other than the latest one the handler obtained, this method returns the latest one without calling + /// the authentication function. Otherwise, it calls the function, or waits for a call already in progress. + /// + protected virtual Task AuthenticateAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); + var currentAuthorization = _lastAuthorization.Value; + if (currentAuthorization is not null && !currentAuthorization.Equals(ctx.Request.Headers.Authorization)) + return Task.FromResult(currentAuthorization); + + return RefreshAuthorizationAsync(ctx, cancellationToken); + } + + /// + /// Invokes the authentication delegate, unless a concurrent call already did, and returns the most recently acquired authorization details. + /// + /// The error context for the HTTP response. + /// A token for canceling the operation. + /// A task that resolves to the most recently acquired authorization details, or if authentication failed. + private async Task RefreshAuthorizationAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + { await _lastAuthorization.TryUpdateAsync(async () => { using (ctx.Client.BeginPropertyScope(AuthorizationScope.Properties)) @@ -114,48 +121,49 @@ await _lastAuthorization.TryUpdateAsync(async () => /// async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - if (ctx.Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) + if (ctx.Request.GetPropertyBag().TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) + return HttpErrorHandlerResult.NoRetry; + + if (!ctx.Request.CanClone()) return HttpErrorHandlerResult.NoRetry; var authorization = await AuthenticateAsync(ctx, cancellationToken).ConfigureAwait(false); if (authorization is null) return HttpErrorHandlerResult.NoRetry; - ctx.Client.DefaultRequestHeaders.Authorization = authorization; + var defaultHeaders = ctx.Client.DefaultRequestHeaders; + lock (defaultHeaders) + { + if (!authorization.Equals(defaultHeaders.Authorization)) + defaultHeaders.Authorization = authorization; + } var authorizedRequest = ctx.Request.Clone(); authorizedRequest.Headers.Authorization = authorization; - authorizedRequest.Properties[HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling] = true; + authorizedRequest.GetPropertyBag()[HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling] = true; return HttpErrorHandlerResult.Retry(authorizedRequest); } /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Releases the resources that the handler uses to coordinate concurrent authentication. /// - public void Dispose() => _lastAuthorization.Dispose(); + public void Dispose() + { + _lastAuthorization.Dispose(); + GC.SuppressFinalize(this); + } /// - /// Provides the request properties to be set during authorization process. + /// Provides the request properties of the requests that the authentication function sends. /// /// - /// This class defines request properties that are used during the authorization process. - /// - /// - /// - /// - /// A flag indicating whether the request should skip the authorization process. - /// - /// This property is set to for all requests initiated by the authorization process - /// to prevent the handler from reentering itself and causing potential deadlocks. - /// - /// - /// - /// + /// is set on those requests, so that a '401 Unauthorized' response to + /// one of them does not start another authentication that would wait for the current one. /// private static class AuthorizationScope { /// - /// Gets the scoped properties of requests initiated by the authorization process. + /// Gets the request properties of the requests that the authentication function sends. /// public static IEnumerable> Properties => [ diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs index 8f5269c..9c8c58e 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError429Handler.cs @@ -7,28 +7,30 @@ namespace Kampute.HttpClient.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers.Abstracts; using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; using System; using System.Net; /// - /// Handles '429 Too Many Requests' HTTP responses by attempting to back off and retry the request according to a specified or - /// default backoff strategy. + /// Handles '429 Too Many Requests' responses by retrying the request when the server allows it. /// /// - /// This handler provides a mechanism to respond to HTTP 429 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate - /// is not provided, or does not specify a strategy, the handler will look for a rate limit reset header in the response. If the - /// header is present, its value is used to determine the backoff duration. If the header is not present, no retries will be attempted. + /// If the first '429 Too Many Requests' response of a call suggests a retry time, in a Retry-After header or otherwise in a rate limit + /// reset header such as x-ratelimit-reset, the request is retried once, at that time; a repeated 429 response then reaches the caller. + /// Without a suggested time, the request is not retried. can choose another policy. If a + /// suggested time is further away than , which is five minutes by default, the request is not + /// retried. See for how the retries of a call are decided, and + /// for the headers that are read. /// /// public class HttpError429Handler : RetryableHttpErrorHandler { /// /// - /// This implementation specifically handles the HTTP '429 Too Many Requests' status code. + /// This handler handles the '429 Too Many Requests' status code only. /// public sealed override bool CanHandle(HttpStatusCode statusCode) => -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 statusCode == HttpStatusCode.TooManyRequests; #else statusCode == (HttpStatusCode)429; @@ -45,12 +47,12 @@ public sealed override bool CanHandle(HttpStatusCode statusCode) => } /// - protected override IHttpBackoffProvider GetDefaultStrategy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) + protected override IHttpRetryPolicy GetDefaultPolicy(HttpResponseErrorContext ctx, DateTimeOffset? retryTime) { if (ctx is null) throw new ArgumentNullException(nameof(ctx)); - return retryTime.HasValue ? BackoffStrategies.Once(retryTime.Value) : BackoffStrategies.None; + return retryTime.HasValue ? RetryStrategies.Once(retryTime.Value).ToHttpRetryPolicy() : HttpRetryPolicy.None; } } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs index 640bf11..1c60bb0 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError503Handler.cs @@ -9,16 +9,15 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Net; /// - /// Handles '503 Service Unavailable' HTTP responses by attempting to back off and retry the request according to a specified or - /// default backoff strategy. + /// Handles '503 Service Unavailable' responses by retrying the request after a delay. /// /// /// - /// This handler provides a mechanism to respond to HTTP 503 errors by retrying the request after a delay. The delay duration and - /// retry logic can be customized through the delegate. If the delegate - /// is not provided, or does not specify a strategy, the handler will look for a Retry-After header in the response. If the - /// Retry-After header is present, its value is used to determine the backoff duration. If the header is not present, the - /// default backoff strategy of the is used. + /// If the first '503 Service Unavailable' response of a call has a Retry-After header, the request is retried once, at the time the + /// header suggests; a repeated 503 response then reaches the caller. Without the header, the request is retried as the + /// of the client decides. can choose another policy. + /// If a suggested time is further away than , which is five minutes by default, the request is + /// not retried. See for how the retries of a call are decided. /// /// /// Consider using if you want to handle multiple transient HTTP errors (including 503) with @@ -26,13 +25,13 @@ namespace Kampute.HttpClient.ErrorHandlers /// /// /// - /// + /// /// public class HttpError503Handler : RetryableHttpErrorHandler { /// /// - /// This implementation specifically handles the HTTP '503 Service Unavailable' status code. + /// This handler handles the '503 Service Unavailable' status code only. /// public sealed override bool CanHandle(HttpStatusCode statusCode) => statusCode == HttpStatusCode.ServiceUnavailable; } diff --git a/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs b/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs index d4047c9..9e4e2c8 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient.ErrorHandlers { /// - /// This namespace contains classes for handling specific HTTP error responses. + /// This namespace contains the error handlers that recover from specific error responses, such as '401 Unauthorized' and '503 Service Unavailable'. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs index 76f056f..85f361c 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/TransientHttpErrorHandler.cs @@ -11,11 +11,17 @@ namespace Kampute.HttpClient.ErrorHandlers using System.Net; /// - /// Handles HTTP responses with a transient error status code by attempting to back off and retry the request according to a specified - /// or default backoff strategy. + /// Handles responses with a transient error status code by retrying the request after a delay. /// + /// + /// If the first error response of a call that this handler handles has a Retry-After header, the request is retried once, at the time + /// the header suggests; a repeated error response then reaches the caller. Without the header, the request is retried as the + /// of the client decides. can choose another policy. + /// If a suggested time is further away than , which is five minutes by default, the request is + /// not retried. See for how the retries of a call are decided. + /// /// - /// + /// public class TransientHttpErrorHandler : RetryableHttpErrorHandler { private readonly HashSet _handledStatusCodes; diff --git a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs b/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs deleted file mode 100644 index d38f0dd..0000000 --- a/src/Kampute.HttpClient/HttpContentDeserializerCollection.cs +++ /dev/null @@ -1,372 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.Utilities; - using System; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - using System.Runtime.CompilerServices; - using System.Threading; - - /// - /// Represents a specialized collection of instances. - /// - /// - /// This collection provides capabilities for managing instances, including adding, removing, - /// and selecting deserializers based on media types and model types. It leverages internal caches to optimize performance for frequently - /// accessed deserializers, significantly enhancing efficiency in scenarios where media types and model types are repeatedly queried. - /// - public sealed class HttpContentDeserializerCollection : ICollection, IReadOnlyCollection - { - private static readonly string[] AllMediaTypes = ["*/*"]; - - private readonly List _collection; - private readonly Lazy _acceptCache; - private readonly FlyweightCache<(string, Type), IHttpContentDeserializer?> _deserializerCache; - - /// - /// Initializes a new instance of the class. - /// - public HttpContentDeserializerCollection() - { - _collection = []; - _deserializerCache = new(key => FindDeserializer(key.Item1, key.Item2)); - _acceptCache = new(() => new(this), LazyThreadSafetyMode.PublicationOnly); - } - - /// - /// Gets the number of instances contained in the collection. - /// - /// - /// The number of instances contained in the collection. - /// - public int Count => _collection.Count; - - /// - /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. - /// - /// - /// Indicates whether the collection is read-only. This implementation always returns . - /// - bool ICollection.IsReadOnly => false; - - /// - /// Retrieves the first instances in the collection that support deserializing a specific media type and model type. - /// - /// The media type to deserialize. - /// The type of the model to deserialize. - /// An instance of that can deserialize the specified media type and model type, or if none is found. - public IHttpContentDeserializer? GetDeserializerFor(string mediaType, Type modelType) - { - return _deserializerCache.Get((mediaType, modelType)); - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings that represent the media types supported for deserializing the specified model type. - /// - /// - /// This method determines the supported media types for deserializing content based on the given model type. - /// - /// - /// If the model type is , this method returns a collection containing only "*/*", signifying that all media types are acceptable. - /// - /// - /// For non-null model types, the method aggregates media types supported by the registered deserializers for that specific model type. - /// - /// - public IEnumerable GetAcceptableMediaTypes(Type? modelType) - { - if (modelType is null) - return AllMediaTypes; - - return _collection.Count switch - { - 0 => [], - 1 => _collection[0].GetSupportedMediaTypes(modelType), - _ => _acceptCache.Value.GetSupportedMediaTypes(modelType) - }; - } - - /// - /// Retrieves all supported media types for a specified model type and error type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// The type of the error for which to retrieve supported media types. - /// An enumerable of strings representing the supported media types for the specified types. - /// - /// - /// This method determines the supported media types for deserializing content based on the given and . - /// - /// - /// If both and are provided, it aggregates and returns the media types that support deserializing either type. - /// - /// - /// If only is provided and is , the result includes media types exclusively supporting the . - /// - /// - /// Conversely, if is and is provided, the result includes media types supporting the , - /// augmented by "*/*" to indicate that all media types are acceptable for the . - /// - /// - /// If both parameters are , the method defaults to returning "*/*" only, implying general acceptability of any media type. - /// - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public IEnumerable GetAcceptableMediaTypes(Type? modelType, Type? errorType) - { - if (errorType is null) - return GetAcceptableMediaTypes(modelType); - - if (modelType is null) - return GetAcceptableMediaTypes(errorType).Concat(AllMediaTypes); - - return _acceptCache.Value.GetSupportedMediaTypes(modelType, errorType); - } - - /// - /// Adds an to the collection if an instance of the same type doesn't already exist. - /// - /// The to add. - /// Thrown if is . - /// Thrown if an instance of the same type already exists in the collection. - public void Add(IHttpContentDeserializer deserializer) - { - if (deserializer is null) - throw new ArgumentNullException(nameof(deserializer)); - - var deserializerType = deserializer.GetType(); - if (_collection.Any(item => item.GetType() == deserializerType)) - throw new ArgumentException($"An instance of type {deserializerType.Name} already exists in the collection.", nameof(deserializer)); - - _collection.Add(deserializer); - InvalidateCaches(); - } - - /// - /// Removes the first occurrence of a specific from the collection. - /// - /// The to remove from the collection. - /// if was successfully removed from the collection; otherwise, . - public bool Remove(IHttpContentDeserializer deserializer) - { - if (_collection.Remove(deserializer)) - { - InvalidateCaches(); - return true; - } - return false; - } - - /// - /// Determines whether the collection contains a specific . - /// - /// The to locate in the collection. - /// if is found in the collection; otherwise, . - public bool Contains(IHttpContentDeserializer deserializer) - { - return _collection.Contains(deserializer); - } - - /// - /// Finds an by its type. - /// - /// The type of the deserializer to find. - /// The instance of of the specified type, or if not found. - public T Find() where T : IHttpContentDeserializer - { - return (T)_collection.FirstOrDefault(deserializer => deserializer.GetType() == typeof(T)); - } - - /// - /// Removes all items from the collection. - /// - public void Clear() - { - _collection.Clear(); - InvalidateCaches(); - } - - /// - /// Copies the elements of the collection to an array, starting at a particular array index. - /// - /// The one-dimensional array that is the destination of the elements copied from the collection. The array must have zero-based indexing. - /// The zero-based index in array at which copying begins. - void ICollection.CopyTo(IHttpContentDeserializer[] array, int arrayIndex) - { - _collection.CopyTo(array, arrayIndex); - } - - /// - /// Returns an enumerator that iterates through the collection. - /// - /// A for . - public IEnumerator GetEnumerator() - { - return _collection.GetEnumerator(); - } - - /// - /// Returns an enumerator that iterates through a collection. - /// - /// An object that can be used to iterate through the collection. - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - /// - /// Locates the first instances in the collection that support deserializing a specific media type and model type. - /// - /// The media type to deserialize. - /// The type of the model to deserialize. - /// An instance of that can deserialize the specified media type and model type, or if none is found. - private IHttpContentDeserializer? FindDeserializer(string mediaType, Type modelType) - { - foreach (var deserializer in _collection) - if (deserializer.CanDeserialize(mediaType, modelType)) - return deserializer; - - return null; - } - - /// - /// Resets the caches. - /// - private void InvalidateCaches() - { - _deserializerCache.Clear(); - if (_acceptCache.IsValueCreated) - _acceptCache.Value.Clear(); - } - - #region Helper Types - - /// - /// Provides cache of supported media types for .NET object types. - /// - private sealed class AcceptableMediaTypeCache - { - private readonly IReadOnlyCollection _deserializers; - private readonly FlyweightCache> _singles; - private readonly FlyweightCache<(Type, Type), IReadOnlyCollection> _duals; - - public AcceptableMediaTypeCache(IReadOnlyCollection deserializers) - { - _deserializers = deserializers; - _singles = new(CollectSupportedMediaTypes); - _duals = new(CollectSupportedMediaTypes); - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// A read-only collection of strings that represent the media types supported for deserializing the specified model type. - public IReadOnlyCollection GetSupportedMediaTypes(Type modelType) - { - return _singles.Get(modelType); - } - - /// - /// Retrieves all supported media types for a specified model type and error type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media types. - /// The type of the error for which to retrieve supported media types. - /// A read-only collection of strings representing the supported media types for the specified types. - public IReadOnlyCollection GetSupportedMediaTypes(Type modelType, Type errorType) - { - return _duals.Get((modelType, errorType)); - } - - /// - /// Clears the cache. - /// - public void Clear() - { - _singles.Clear(); - _duals.Clear(); - } - - /// - /// Retrieves all supported media types for a specified model type from the collection of deserializers. - /// - /// The type of the model for which to retrieve supported media type header values. - /// A read-only collection of strings that represent the media types supported for deserializing the specified model type. - private IReadOnlyCollection CollectSupportedMediaTypes(Type modelType) - { - var uniqueMediaTypes = new HashSet(); - var orderedMediaTypes = new List(); - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(modelType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - orderedMediaTypes.TrimExcess(); - return orderedMediaTypes; - } - - /// - /// Retrieves all supported media types for a specified pair of model and error types from the collection of deserializers. - /// - /// - /// A tuple containing two types used to collect and aggregate media types that can deserialize objects of these types from HTTP content: - /// - /// - /// Item1 - /// The type of the model for which to retrieve supported media types. - /// - /// - /// Item2 - /// The type of the error for which to retrieve supported media types. - /// - /// - /// - /// - /// A read-only collection of strings that represent the media types supported for deserializing the specified types. - /// - private IReadOnlyCollection CollectSupportedMediaTypes((Type, Type) types) - { - var (modelType, errorType) = types; - var uniqueMediaTypes = new HashSet(); - var orderedMediaTypes = new List(); - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(modelType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - foreach (var deserializer in _deserializers) - { - foreach (var mediaType in deserializer.GetSupportedMediaTypes(errorType)) - { - if (uniqueMediaTypes.Add(mediaType)) - orderedMediaTypes.Add(mediaType); - } - } - - orderedMediaTypes.TrimExcess(); - return orderedMediaTypes; - } - } - - #endregion - } -} diff --git a/src/Kampute.HttpClient/HttpContentException.cs b/src/Kampute.HttpClient/HttpContentException.cs index 72295d9..72116d4 100644 --- a/src/Kampute.HttpClient/HttpContentException.cs +++ b/src/Kampute.HttpClient/HttpContentException.cs @@ -10,8 +10,12 @@ namespace Kampute.HttpClient using System.Text; /// - /// The exception that is thrown when an invalid or unsupported content is encountered in an HTTP response. + /// The exception that is thrown when the content of a response cannot be read into the requested type. /// + /// + /// The content cannot be read when the response has no body, when no registered content formatter reads its media type into the requested type, + /// or when the formatter fails; in the last case, the exception of the formatter is the inner exception. + /// public class HttpContentException : Exception { /// @@ -44,18 +48,18 @@ public HttpContentException(string message, Exception? innerException) } /// - /// Gets or sets the HTTP content associated with the exception. + /// Gets or sets the content that could not be read. /// /// - /// The HTTP content associated with the exception, if any. + /// The content that could not be read, if any. /// public HttpContent? Content { get; set; } /// - /// Gets or sets the expected type for deserialization when the exception occurred. + /// Gets or sets the type into which the content was to be read. /// /// - /// The type expected to be deserialized from the HTTP content, if any. + /// The requested type, if any. /// public Type? ObjectType { get; set; } diff --git a/src/Kampute.HttpClient/HttpContentExtensions.cs b/src/Kampute.HttpClient/HttpContentExtensions.cs index 4c53ebd..4e00e04 100644 --- a/src/Kampute.HttpClient/HttpContentExtensions.cs +++ b/src/Kampute.HttpClient/HttpContentExtensions.cs @@ -13,35 +13,30 @@ namespace Kampute.HttpClient using System.Text; /// - /// Provides extension methods for to enhance functionality related to HTTP content processing. + /// Provides extension methods for . /// public static class HttpContentExtensions { /// - /// Attempts to find the character encoding from the headers. + /// Returns the character encoding named by the charset parameter of the Content-Type header. /// - /// The instance to extract the character encoding from. - /// The specified in the content's headers if the charset is recognized; otherwise, . - /// Thrown if the charset specified in the content's headers is not recognized. - /// - /// This method inspects the 'CharSet' value in the content type header of the . If the charset is specified - /// and recognized, it returns the corresponding . If the charset is not specified, the method returns , - /// indicating that the encoding could not be determined. An is thrown if the charset is specified but - /// not supported by the system. - /// + /// The content whose encoding to return. + /// The that the header names, or if the header names none. + /// Thrown if the header names a character set that the runtime does not support. public static Encoding? FindCharacterEncoding(this HttpContent httpContent) { return httpContent.Headers.ContentType?.CharSet is string charSet ? Encoding.GetEncoding(charSet) : null; } /// - /// Determines whether the instance can be reused. + /// Determines whether the content can be sent more than once, as a retry requires. /// - /// The instance to check for re-usability. - /// if the content is reusable; otherwise, . + /// The content to check. + /// if the content can be sent again; otherwise, . /// - /// Reusability of is determined by its ability to provide its content multiple times without alteration. - /// For example, content backed by a non-seekable stream is not reusable as the stream can be consumed only once. + /// A can be sent again only if its length is known, which is the case for a seekable stream; a non-seekable + /// stream can be read only once. Content that wraps another content, such as compressed content, can be sent again if the wrapped content can. + /// Other content is assumed to be reusable. /// public static bool IsReusable(this HttpContent httpContent) { @@ -55,22 +50,22 @@ public static bool IsReusable(this HttpContent httpContent) } /// - /// Compresses the using the GZIP compression algorithm. + /// Wraps the content in content that compresses it with GZip as it is sent. /// - /// The HTTP content to compress. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. - /// A new instance of that wraps the original HTTP content with GZIP compression. + /// The content to compress. It is disposed when the returned content is disposed. + /// Whether to favor speed or size. The default is . + /// A that wraps . public static GzipCompressedContent AsGzip(this HttpContent httpContent, CompressionLevel compressionLevel = CompressionLevel.Optimal) { return new GzipCompressedContent(httpContent, compressionLevel); } /// - /// Compresses the using the Deflate compression algorithm. + /// Wraps the content in content that compresses it with Deflate as it is sent. /// - /// The HTTP content to compress. - /// The level of compression that indicates whether to emphasize speed or compression efficiency. - /// A new instance of that wraps the original HTTP content with Deflate compression. + /// The content to compress. It is disposed when the returned content is disposed. + /// Whether to favor speed or size. The default is . + /// A that wraps . public static DeflateCompressedContent AsDeflate(this HttpContent httpContent, CompressionLevel compressionLevel = CompressionLevel.Optimal) { return new DeflateCompressedContent(httpContent, compressionLevel); diff --git a/src/Kampute.HttpClient/HttpContentFormatterCollection.cs b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs new file mode 100644 index 0000000..16505fb --- /dev/null +++ b/src/Kampute.HttpClient/HttpContentFormatterCollection.cs @@ -0,0 +1,400 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.Utilities; + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.CompilerServices; + using System.Threading; + + /// + /// Represents the content formatters of an , in the order they were added. + /// + /// + /// + /// This collection holds the content formatters of an . It selects the formatter that reads a response with + /// , the formatter that writes a request payload with , and the media types of the + /// Accept header with . When several formatters match, the one added first is used. + /// + /// + /// Media types that differ only in case are treated as the same media type. A formatter that is found is cached for later lookups of the + /// same media type and type; a failed lookup is not cached. + /// + /// + public sealed class HttpContentFormatterCollection : ICollection, IReadOnlyCollection + { + private static readonly string[] AllMediaTypes = ["*/*"]; + + private readonly List _collection; + private readonly Lazy _acceptCache; + private readonly ConcurrentDictionary<(string, Type), IHttpContentFormatter> _readerCache; + private readonly ConcurrentDictionary<(string, Type), IHttpContentFormatter> _writerCache; + + /// + /// Initializes a new instance of the class. + /// + public HttpContentFormatterCollection() + { + _collection = []; + _readerCache = new(MediaTypeAndObjectTypeComparer.Instance); + _writerCache = new(MediaTypeAndObjectTypeComparer.Instance); + _acceptCache = new(() => new(this), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Gets the number of formatters in the collection. + /// + /// + /// The number of formatters in the collection. + /// + public int Count => _collection.Count; + + /// + /// Gets a value indicating whether the collection is read-only. + /// + /// + /// Always . + /// + bool ICollection.IsReadOnly => false; + + /// + /// Retrieves the first formatter in the collection that can read content of a specific media type into a specific model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// The first whose returns , or if there is none. + /// Thrown if or is . + public IHttpContentFormatter? GetReaderFor(string mediaType, Type modelType) + { + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (modelType is null) + throw new ArgumentNullException(nameof(modelType)); + + return FindCached(_readerCache, mediaType, modelType, static (formatter, mediaType, type) => formatter.CanRead(mediaType, type)); + } + + /// + /// Retrieves the first formatter in the collection that can write a payload of a specific type in a specific media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// The first whose returns , or if there is none. + /// Thrown if or is . + public IHttpContentFormatter? GetWriterFor(string mediaType, Type payloadType) + { + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + if (payloadType is null) + throw new ArgumentNullException(nameof(payloadType)); + + return FindCached(_writerCache, mediaType, payloadType, static (formatter, mediaType, type) => formatter.CanWrite(mediaType, type)); + } + + /// + /// Retrieves the media types that the formatters in the collection can read into a specified model type. + /// + /// The type of the object to read, or if any content is acceptable. + /// The media types that can be read into , in the order of the formatters, without duplicates. + /// + /// + /// If is , this method returns only "*/*", signifying that all media types are acceptable. + /// + /// + /// Send-only formatters contribute no media types. + /// + /// + public IEnumerable GetAcceptableMediaTypes(Type? modelType) + { + if (modelType is null) + return AllMediaTypes; + + return _collection.Count switch + { + 0 => [], + 1 => _collection[0].GetReadableMediaTypes(modelType), + _ => _acceptCache.Value.GetReadableMediaTypes(modelType) + }; + } + + /// + /// Retrieves the media types that the formatters in the collection can read into a specified model type or error type. + /// + /// The type of the object to read, or if any content is acceptable. + /// The type of the error object to read, or if errors are not read. + /// The media types that can be read into either type, model type first, without duplicates. + /// + /// + /// If is , the result is the same as for . + /// + /// + /// If is and is provided, the result includes the media types + /// that can be read into , followed by "*/*" to indicate that any content is acceptable for the model. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IEnumerable GetAcceptableMediaTypes(Type? modelType, Type? errorType) + { + if (errorType is null) + return GetAcceptableMediaTypes(modelType); + + if (modelType is null) + return GetAcceptableMediaTypes(errorType).Concat(AllMediaTypes); + + return _acceptCache.Value.GetReadableMediaTypes(modelType, errorType); + } + + /// + /// Adds a formatter to the collection, which can hold one formatter of each type. + /// + /// The to add. + /// Thrown if is . + /// Thrown if an instance of the same type already exists in the collection. + public void Add(IHttpContentFormatter formatter) + { + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + var formatterType = formatter.GetType(); + if (_collection.Any(item => item.GetType() == formatterType)) + throw new ArgumentException($"An instance of type {formatterType.Name} already exists in the collection.", nameof(formatter)); + + _collection.Add(formatter); + InvalidateCaches(); + } + + /// + /// Removes a formatter from the collection. + /// + /// The to remove from the collection. + /// if was successfully removed from the collection; otherwise, . + public bool Remove(IHttpContentFormatter formatter) + { + if (_collection.Remove(formatter)) + { + InvalidateCaches(); + return true; + } + return false; + } + + /// + /// Determines whether the collection contains a specific . + /// + /// The to locate in the collection. + /// if is found in the collection; otherwise, . + public bool Contains(IHttpContentFormatter formatter) + { + return _collection.Contains(formatter); + } + + /// + /// Finds the formatter of the specified type. + /// + /// The type of the formatter to find. + /// The formatter whose type is exactly , or if the collection has none. + /// + /// A formatter of a type derived from does not match. + /// + public T? Find() where T : IHttpContentFormatter + { + return (T?)_collection.FirstOrDefault(formatter => formatter.GetType() == typeof(T)); + } + + /// + /// Finds the formatter of the specified type, or creates one with default options if the collection has none. + /// + /// The type of the formatter to find. + /// The formatter whose type is exactly , or a new instance of that is not added to the collection. + /// + /// A new instance is created on each call that finds no formatter, rather than shared, so that changing its options cannot affect other clients. + /// + public T FindOrDefault() where T : IHttpContentFormatter, new() + { + return Find() ?? new T(); + } + + /// + /// Removes all items from the collection. + /// + public void Clear() + { + _collection.Clear(); + InvalidateCaches(); + } + + /// + /// Copies the elements of the collection to an array, starting at a particular array index. + /// + /// The one-dimensional array that is the destination of the elements copied from the collection. The array must have zero-based indexing. + /// The zero-based index in array at which copying begins. + void ICollection.CopyTo(IHttpContentFormatter[] array, int arrayIndex) + { + _collection.CopyTo(array, arrayIndex); + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// A for . + public IEnumerator GetEnumerator() + { + return _collection.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Returns the cached formatter for a media type and type, or finds the first matching formatter and caches it. + /// + /// The cache of the lookup. + /// The media type to match. + /// The type of the object to read or write. + /// The function that tells whether a formatter matches. + /// The first matching formatter, or if there is none. + private IHttpContentFormatter? FindCached + ( + ConcurrentDictionary<(string, Type), IHttpContentFormatter> cache, + string mediaType, + Type type, + Func matches + ) + { + var key = (mediaType, type); + if (cache.TryGetValue(key, out var formatter)) + return formatter; + + foreach (var candidate in _collection) + { + if (matches(candidate, mediaType, type)) + { + cache.TryAdd(key, candidate); + return candidate; + } + } + + return null; + } + + /// + /// Resets the caches. + /// + private void InvalidateCaches() + { + _readerCache.Clear(); + _writerCache.Clear(); + if (_acceptCache.IsValueCreated) + _acceptCache.Value.Clear(); + } + + #region Helper Types + + /// + /// Compares pairs of media type and object type, ignoring the case of the media type. + /// + private sealed class MediaTypeAndObjectTypeComparer : IEqualityComparer<(string, Type)> + { + public static readonly MediaTypeAndObjectTypeComparer Instance = new(); + + public bool Equals((string, Type) x, (string, Type) y) + { + return StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1) && x.Item2 == y.Item2; + } + + public int GetHashCode((string, Type) obj) + { + return unchecked(StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1) * 31 + obj.Item2.GetHashCode()); + } + } + + /// + /// Provides cache of readable media types for .NET object types. + /// + private sealed class AcceptableMediaTypeCache + { + private readonly IReadOnlyCollection _formatters; + private readonly FlyweightCache> _singles; + private readonly FlyweightCache<(Type, Type), IReadOnlyCollection> _duals; + + public AcceptableMediaTypeCache(IReadOnlyCollection formatters) + { + _formatters = formatters; + _singles = new(modelType => CollectReadableMediaTypes(modelType)); + _duals = new(types => CollectReadableMediaTypes(types.Item1, types.Item2)); + } + + /// + /// Retrieves the media types that the formatters can read into a specified model type. + /// + /// The type of the object to read. + /// A read-only collection of the media types, without duplicates. + public IReadOnlyCollection GetReadableMediaTypes(Type modelType) + { + return _singles.Get(modelType); + } + + /// + /// Retrieves the media types that the formatters can read into a specified model type or error type. + /// + /// The type of the object to read. + /// The type of the error object to read. + /// A read-only collection of the media types, model type first, without duplicates. + public IReadOnlyCollection GetReadableMediaTypes(Type modelType, Type errorType) + { + return _duals.Get((modelType, errorType)); + } + + /// + /// Clears the cache. + /// + public void Clear() + { + _singles.Clear(); + _duals.Clear(); + } + + /// + /// Collects the media types that the formatters can read into the specified types, in order and without duplicates. + /// + /// The types of the objects to read. + /// A read-only collection of the media types. + private IReadOnlyCollection CollectReadableMediaTypes(params Type[] types) + { + var uniqueMediaTypes = new HashSet(StringComparer.OrdinalIgnoreCase); + var orderedMediaTypes = new List(); + + foreach (var type in types) + { + foreach (var formatter in _formatters) + { + foreach (var mediaType in formatter.GetReadableMediaTypes(type)) + { + if (uniqueMediaTypes.Add(mediaType)) + orderedMediaTypes.Add(mediaType); + } + } + } + + orderedMediaTypes.TrimExcess(); + return orderedMediaTypes; + } + } + + #endregion + } +} diff --git a/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs b/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs index 77f5ebb..0d23f10 100644 --- a/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs +++ b/src/Kampute.HttpClient/HttpErrorHandlerCollection.cs @@ -13,37 +13,37 @@ namespace Kampute.HttpClient using System.Net; /// - /// Represents a specialized collection of instances. + /// Represents the error handlers of an , in the order they were added. /// /// - /// This collection enables the management of instances for handling HTTP errors, - /// with capabilities such as adding, removing, and querying error handlers based on HTTP status codes. + /// The client asks the handlers that returns for the status code of an error response, in order, until one of them + /// retries. A handler can be added only once. /// public sealed class HttpErrorHandlerCollection : ICollection { private readonly List _collection = []; /// - /// Gets the number of instances contained in the collection. + /// Gets the number of handlers in the collection. /// /// - /// The number of instances contained in the collection. + /// The number of handlers in the collection. /// public int Count => _collection.Count; /// - /// Gets a value indicating whether the collection is read-only. Always returns for this implementation. + /// Gets a value indicating whether the collection is read-only. /// /// - /// Indicates whether the collection is read-only. This property always returns . + /// Always . /// bool ICollection.IsReadOnly => false; /// - /// Retrieves all instances in the collection that support handling a specific HTTP status code. + /// Returns the handlers that can handle the specified status code, in the order they were added. /// - /// The HTTP status code to query. - /// An enumerable of that can handle the specified status code. + /// The status code of the error response. + /// The handlers whose accepts . public IEnumerable GetHandlersFor(HttpStatusCode statusCode) { return _collection.Where(errorHandler => errorHandler.CanHandle(statusCode)); @@ -54,7 +54,7 @@ public IEnumerable GetHandlersFor(HttpStatusCode statusCode) /// /// The to add. /// Thrown if is . - /// Thrown if is already present in the collection, as duplicates are not allowed. + /// Thrown if is already in the collection. public void Add(IHttpErrorHandler errorHandler) { if (errorHandler is null) @@ -66,7 +66,7 @@ public void Add(IHttpErrorHandler errorHandler) } /// - /// Removes the first occurrence of a specific from the collection. + /// Removes a handler from the collection. /// /// The to remove from the collection. /// if was successfully removed from the collection; otherwise, . diff --git a/src/Kampute.HttpClient/HttpErrorHandlerResult.cs b/src/Kampute.HttpClient/HttpErrorHandlerResult.cs index b3acceb..db67f52 100644 --- a/src/Kampute.HttpClient/HttpErrorHandlerResult.cs +++ b/src/Kampute.HttpClient/HttpErrorHandlerResult.cs @@ -9,14 +9,11 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Represents the outcome of an HTTP error handling attempt by a specific handler, indicating whether it determines a failed request - /// should be retried. + /// Represents the decision of an error handler: retry with a given request, or do not retry. /// /// - /// This struct communicates the decision of an HTTP error handler regarding the handling of a failed request. It specifies whether the - /// handler determines the request should be retried, potentially with modifications, or if it considers the error not recoverable by - /// its logic, indicating that a retry should not be attempted. This determination is contextual to the handler's implementation and does - /// not preclude other handlers from potentially retrying the request. + /// A decision only means that this handler does not retry; the client then asks the next handler that can handle the + /// status code. /// public readonly struct HttpErrorHandlerResult { @@ -31,24 +28,22 @@ private HttpErrorHandlerResult(HttpRequestMessage requestToRetry) } /// - /// Gets the to use for retrying the failed request, if the handler determines a retry is warranted; - /// otherwise, . + /// The request to send as the retry, or if the handler does not retry. /// - /// - /// The to use for retrying the failed request, if the handler determines a retry is warranted; otherwise, . - /// public readonly HttpRequestMessage? RequestToRetry; /// - /// Creates a result indicating that the request should be retried with the provided . + /// Creates a decision to retry with the specified request. /// - /// The request to use for the retry. - /// An indicating the request should be retried with the provided . + /// + /// The request to send, such as a clone of the failed request made with , or a new request. + /// + /// An whose is . /// Thrown if is . public static HttpErrorHandlerResult Retry(HttpRequestMessage requestToRetry) => new(requestToRetry); /// - /// Represents a result indicating that the request should not be retried according to the handler's determination. + /// The decision not to retry. /// public static readonly HttpErrorHandlerResult NoRetry = new(); } diff --git a/src/Kampute.HttpClient/HttpRequestErrorContext.cs b/src/Kampute.HttpClient/HttpRequestErrorContext.cs index 538ddb3..56d9368 100644 --- a/src/Kampute.HttpClient/HttpRequestErrorContext.cs +++ b/src/Kampute.HttpClient/HttpRequestErrorContext.cs @@ -6,75 +6,117 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; /// - /// Represents the context of an HTTP request error, encapsulating details about the request, the error encountered, and the client that sent the request. + /// Describes a failed request to the code that decides whether to retry it: the client, the request, and the error. /// public class HttpRequestErrorContext { /// - /// Initializes an instance of the class. + /// Initializes a new instance of the class. /// - /// The instance used to send the request. - /// The that resulted in a failure. - /// The containing details of the error encountered during the HTTP request. - /// Thrown if , or or is . - public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request, HttpRequestException error) + /// The client that sent the request. + /// The request that failed. + /// The exception that describes the failure. + /// The retry state of the call that sent the request, shared by all its attempts. + /// Thrown if , , or is . + public HttpRequestErrorContext(HttpRestClient client, HttpRequestMessage request, HttpRequestException error, HttpRetryState retryState) { Client = client ?? throw new ArgumentNullException(nameof(client)); Request = request ?? throw new ArgumentNullException(nameof(request)); Error = error ?? throw new ArgumentNullException(nameof(error)); + RetryState = retryState ?? throw new ArgumentNullException(nameof(retryState)); } /// - /// Gets the instance used to send the request. + /// Gets the client that sent the request. /// /// - /// The instance used to send the request. + /// The that sent the request. /// public HttpRestClient Client { get; } /// - /// Gets the that resulted in a failure. + /// Gets the request that failed. /// /// - /// The that resulted in a failure. + /// The that failed. /// public HttpRequestMessage Request { get; } /// - /// Gets the containing details of the error encountered during the HTTP request. + /// Gets the exception that describes the failure. /// /// - /// The containing details of the error encountered during the HTTP request. + /// The of the failure. /// public HttpRequestException Error { get; } /// - /// Schedules a retry for the failed HTTP request using a provided scheduler factory. + /// Gets the retry state of the call that sent the request. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// + /// The shared by all attempts of the call. keeps the retry session of each source in it. + /// + public HttpRetryState RetryState { get; } + + /// + /// Waits as the retry session of the source decides, and returns a clone of the request to retry, or a decision not to retry. + /// + /// + /// The component that handles this kind of failure and counts its retries, such as the for connection + /// failures or an for error responses. + /// + /// + /// A function that creates the retry session of the source from this context, or returns if the source does not retry. + /// /// A token that can be used to cancel the operation. - /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if the is . - public async Task ScheduleRetryAsync(Func schedulerFactory, CancellationToken cancellationToken = default) + /// + /// A task that resolves, after the wait, to a decision to retry with a clone of , or to . + /// + /// Thrown if or is . + /// + /// + /// Each source has its own retry session for a call. The first time a source schedules a retry during a call, + /// is called and the session it returns is kept in . Later failures from the same source during the same call reuse + /// that session, so its retry limit and elapsed time cover all of them, whether the request to retry is a clone of the failed request or a + /// request built by an error handler. Failures from another source use another session, so a call that fails in several ways can be retried + /// more times in total than any one session allows. + /// + /// + /// If the request content cannot be sent again, the request is not retried and is not called. + /// + /// + public Task ScheduleRetryAsync(object source, Func sessionFactory, CancellationToken cancellationToken = default) { - if (schedulerFactory is null) - throw new ArgumentNullException(nameof(schedulerFactory)); + if (source is null) + throw new ArgumentNullException(nameof(source)); + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); if (!Request.CanClone()) - return HttpErrorHandlerResult.NoRetry; - - if (!Request.Properties.ContainsKey(HttpRequestMessagePropertyKeys.RetryScheduler)) - Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] = schedulerFactory(this); + return Task.FromResult(HttpErrorHandlerResult.NoRetry); - var scheduler = Request.Properties[HttpRequestMessagePropertyKeys.RetryScheduler] as IRetryScheduler; + var session = RetryState.GetOrCreateSession(source, () => sessionFactory(this)); + return session is not null + ? RetryWhenScheduledAsync(session, cancellationToken) + : Task.FromResult(HttpErrorHandlerResult.NoRetry); + } - return scheduler is not null && await scheduler.WaitAsync(cancellationToken).ConfigureAwait(false) + /// + /// Waits as the session decides, and returns a clone of the request to retry if the session allows another attempt. + /// + /// The retry session of the source that handles the failure. + /// A token that can be used to cancel the operation. + /// A task that resolves to an indicating whether a retry should be attempted. + private async Task RetryWhenScheduledAsync(IRetrySession session, CancellationToken cancellationToken) + { + return await session.WaitToRetryAsync(cancellationToken).ConfigureAwait(false) ? HttpErrorHandlerResult.Retry(Request.Clone()) : HttpErrorHandlerResult.NoRetry; } diff --git a/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs b/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs index 7c3d021..55ced02 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageCloneManager.cs @@ -7,16 +7,16 @@ namespace Kampute.HttpClient { using System; using System.Net.Http; - using System.Runtime.CompilerServices; /// - /// Manages clones of for retry operations. + /// Manages the requests that replace the original during retries. /// /// - /// This class oversees the life-cycle of cloned instances. It ensures that clones are properly managed - /// and disposed of, preventing resource leaks and maintaining the integrity of the original . + /// Each request that replaces another is disposed when it is itself replaced or when the manager is disposed. The original request is never + /// disposed. The content of a replaced request is disposed only when no other request still uses it: content that is the original request's + /// content, or that the replacing request reuses, is left undisposed. /// - public struct HttpRequestMessageCloneManager : IDisposable + internal struct HttpRequestMessageCloneManager : IDisposable { private readonly HttpRequestMessage _originalRequest; private HttpRequestMessage _currentRequest; @@ -41,40 +41,46 @@ public HttpRequestMessageCloneManager(HttpRequestMessage request) public readonly HttpRequestMessage RequestToSend => _currentRequest; /// - /// Attempts to apply a retry decision to the current request. If the decision includes a request to retry, updates the current request and - /// disposes of the previous request if it is not the original. + /// Attempts to apply a retry decision to the current request. If the decision includes a request to retry, makes it the current request and + /// disposes of the request it replaces, unless that is the original request. /// /// The retry decision. /// if the decision was applied and a retry should occur; otherwise, . public bool TryApplyDecision(in HttpErrorHandlerResult decision) { - if (decision.RequestToRetry is null) + var nextRequest = decision.RequestToRetry; + if (nextRequest is null) return false; - DisposeNonOriginalRequest(); - _currentRequest = decision.RequestToRetry; + if (!ReferenceEquals(nextRequest, _currentRequest)) + { + DisposeCurrentRequest(contentInUse: nextRequest.Content); + _currentRequest = nextRequest; + } + return true; } /// - /// Disposes of any cloned requests, releasing the managed resources. + /// Disposes of the current request, unless it is the original request. /// public readonly void Dispose() { - DisposeNonOriginalRequest(); + DisposeCurrentRequest(contentInUse: null); } /// - /// Disposes of the current request if it is not the original request. + /// Disposes of the current request if it is not the original request, leaving its content undisposed if another request still uses it. /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private readonly void DisposeNonOriginalRequest() + /// The content of the request that replaces the current request, if any. + private readonly void DisposeCurrentRequest(HttpContent? contentInUse) { if (ReferenceEquals(_currentRequest, _originalRequest)) return; - if (_currentRequest.IsCloned()) - _currentRequest.Content = null; // Content is reused, not cloned. + var content = _currentRequest.Content; + if (content is not null && (ReferenceEquals(content, _originalRequest.Content) || ReferenceEquals(content, contentInUse))) + _currentRequest.Content = null; _currentRequest.Dispose(); } diff --git a/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs b/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs index b565bf0..87939f4 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageEventArgs.cs @@ -9,20 +9,14 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides event data for events that involve manipulation or inspection of HTTP request messages. + /// Provides the request of the event. /// - /// - /// This class is typically used in scenarios where an HTTP request message needs to be inspected or modified - /// before it is sent. It encapsulates an instance of , allowing subscribers - /// of the event to access and manipulate the request as necessary. Common use cases include adding headers, - /// changing the request URI, or modifying the request body. - /// public class HttpRequestMessageEventArgs : EventArgs { /// /// Initializes a new instance of the class with the specified request message. /// - /// The HTTP request message that has been created. + /// The request about to be sent. /// Thrown if the is . public HttpRequestMessageEventArgs(HttpRequestMessage request) { @@ -30,10 +24,10 @@ public HttpRequestMessageEventArgs(HttpRequestMessage request) } /// - /// Gets the HTTP request message. + /// Gets the request about to be sent. /// /// - /// The HTTP request message involved in the event. + /// The about to be sent. Changes to it are sent. /// public HttpRequestMessage Request { get; } } diff --git a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs index 711ab3f..42d4fc2 100644 --- a/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs +++ b/src/Kampute.HttpClient/HttpRequestMessageExtensions.cs @@ -9,19 +9,18 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides extension methods for to enhance functionality related to HTTP request processing. + /// Provides extension methods for that error handlers use to retry a request. /// public static class HttpRequestMessageExtensions { /// - /// Clones the specified , including its headers, version, and properties. + /// Creates a copy of a request to send again. /// - /// The to clone. - /// A new instance of that is a clone of the original. - /// Thrown if the request contains a content that cannot be reused. + /// The request to copy. + /// A new with the method, URI, version, headers, and properties of . + /// Thrown if the content of the request cannot be sent again; see . /// - /// This method copies the provided , including its headers, version, and properties. The method reuses - /// the original request's in the cloned request. + /// The copy shares the content of the original request rather than copying it, so disposing either request disposes the content of both. /// public static HttpRequestMessage Clone(this HttpRequestMessage request) { @@ -37,55 +36,51 @@ public static HttpRequestMessage Clone(this HttpRequestMessage request) foreach (var header in request.Headers) clone.Headers.TryAddWithoutValidation(header.Key, header.Value); - foreach (var property in request.Properties) - clone.Properties.Add(property); + var cloneProperties = clone.GetPropertyBag(); + foreach (var property in request.GetPropertyBag()) + cloneProperties.Add(property); - clone.Properties[HttpRequestMessagePropertyKeys.CloneGeneration] = request.GetCloneGeneration() + 1; + cloneProperties[HttpRequestMessagePropertyKeys.CloneGeneration] = request.GetCloneGeneration() + 1; return clone; } /// - /// Determines whether the can be cloned without issues. + /// Determines whether a request can be copied with and sent again. /// - /// The to check. - /// if the request does not contain a content or the content is reusable; otherwise, . - /// - /// This is a quick check to prevent cloning of requests that contain one-time-use content, which could lead to unexpected behaviors - /// such as empty request bodies or . - /// + /// The request to check. + /// + /// if the request has no content or its content can be sent again; otherwise, . + /// + /// public static bool CanClone(this HttpRequestMessage request) { return request.Content is null || request.Content.IsReusable(); } /// - /// Checks if the is a clone. + /// Determines whether a request is a copy made with . /// - /// The to check. - /// if the request has been cloned; otherwise, . - /// - /// A cloned request is one that has been created through the extension method. - /// + /// The request to check. + /// if the request is a copy; otherwise, . /// public static bool IsCloned(this HttpRequestMessage request) { - return request.Properties.ContainsKey(HttpRequestMessagePropertyKeys.CloneGeneration); + return request.GetPropertyBag().ContainsKey(HttpRequestMessagePropertyKeys.CloneGeneration); } /// - /// Retrieves the number of times the original request was cloned to produce this instance. + /// Returns how many copies separate a request from the original request. /// - /// The to check. - /// The clone generation count or zero if the request has not been cloned. - /// - /// The clone generation increases by 1 every time the request is cloned. An original request that has not been cloned - /// will have a generation count of 0. - /// + /// The request to check. + /// + /// 0 for an original request, 1 for a copy of it, 2 for a copy of that copy, and so on. Because each retry of a call clones the request + /// last sent, this is usually the number of retries so far. + /// /// public static int GetCloneGeneration(this HttpRequestMessage request) { - return request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.CloneGeneration, out var cloneGeneration) ? (int)cloneGeneration : 0; + return request.GetPropertyBag().TryGetValue(HttpRequestMessagePropertyKeys.CloneGeneration, out var cloneGeneration) && cloneGeneration is int generation ? generation : 0; } } } diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index a357d26..7a1f8f0 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -10,22 +10,34 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Defines constant keys for storing and identifying custom properties in an . + /// Provides the keys of the request properties that sets and reads. /// + /// + /// + /// On .NET 5 and later, read these properties through HttpRequestMessage.Options with an HttpRequestOptionsKey<TValue> + /// named after the key, because HttpRequestMessage.Properties is obsolete there. Both show the same values. On .NET Framework, read + /// them through HttpRequestMessage.Properties. + /// + /// + /// + /// + /// if (request.Options.TryGetValue(new HttpRequestOptionsKey<Guid>(HttpRequestMessagePropertyKeys.TransactionId), out var transactionId)) + /// Console.WriteLine($"Transaction {transactionId}"); + /// + /// public static class HttpRequestMessagePropertyKeys { /// - /// A key used to store and identify the property within an that tracks - /// how many times the request has been cloned. + /// The key of the property that counts how many copies separate a request from the original request. /// /// - /// The value of this property is of type . + /// The value of this property is of type . Read it through + /// and . /// - public const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); + internal const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); /// - /// A key used to store and identify the property within an that identifies - /// the request and its clones. + /// The key of the property that identifies a request and the copies made for its retries. /// /// /// The value of this property is of type . @@ -33,8 +45,7 @@ public static class HttpRequestMessagePropertyKeys public const string TransactionId = nameof(HttpRestClient) + "." + nameof(TransactionId); /// - /// A key used to store and identify the property within an that identifies - /// the type of expected .NET object in the response. + /// The key of the property that holds the type into which the response is read. /// /// /// The value of this property is of type . @@ -42,19 +53,7 @@ public static class HttpRequestMessagePropertyKeys public const string ResponseObjectType = nameof(HttpRestClient) + "." + nameof(ResponseObjectType); /// - /// A key used to store and identify the property within an that references - /// the instance associated with the request which is responsible for scheduling - /// the retry logic for transient failures. - /// - /// - /// The value of this property is of type . - /// - public const string RetryScheduler = nameof(HttpRestClient) + "." + nameof(RetryScheduler); - - /// - /// A key used to store and identify the property within an that references - /// the instance associated with the request, which is responsible for processing - /// and potentially recovering from errors in the response. + /// The key of the property that holds the that decided to retry the request after an error response. /// /// /// The value of this property is of type . @@ -62,8 +61,8 @@ public static class HttpRequestMessagePropertyKeys public const string ErrorHandler = nameof(HttpRestClient) + "." + nameof(ErrorHandler); /// - /// A key used to store and identify the property within an that indicates - /// '401 Unauthorized' errors should not be automatically handled. + /// The key of the property that, when , stops from handling a + /// '401 Unauthorized' response to the request. /// /// /// The value of this property is of type . diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs new file mode 100644 index 0000000..20bf205 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyStore.cs @@ -0,0 +1,36 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using System.Collections.Generic; + using System.Net.Http; + using System.Runtime.CompilerServices; + + /// + /// Gives the library one way to read and write the properties of an on every target. + /// + /// + /// On modern .NET, HttpRequestMessage.Properties is obsolete and stores its values in HttpRequestMessage.Options, so both views + /// show the same values. The netstandard2.0 build has only Properties. + /// + internal static class HttpRequestMessagePropertyStore + { + /// + /// Returns the property bag of the request. + /// + /// The request whose properties to return. + /// request.Options on modern .NET; request.Properties on the netstandard2.0 build. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDictionary GetPropertyBag(this HttpRequestMessage request) + { +#if !NETSTANDARD2_0 + return request.Options; +#else + return request.Properties; +#endif + } + } +} diff --git a/src/Kampute.HttpClient/HttpRequestScope.cs b/src/Kampute.HttpClient/HttpRequestScope.cs index dbb7a32..afb67e8 100644 --- a/src/Kampute.HttpClient/HttpRequestScope.cs +++ b/src/Kampute.HttpClient/HttpRequestScope.cs @@ -5,8 +5,21 @@ using System.Threading.Tasks; /// - /// Represents a scope of properties and headers that can be used for requests. + /// Collects headers and request properties, and applies them to the requests that a client sends within . /// + /// + /// Create an instance with . The headers and properties apply only to the requests of the + /// action that runs, as with and + /// . + /// + /// + /// + /// var report = await client + /// .WithScope() + /// .SetHeader("Accept", MediaTypeNames.Text.Csv) + /// .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("reports/daily")); + /// + /// public sealed class HttpRequestScope { private Dictionary? _headers; @@ -15,42 +28,42 @@ public sealed class HttpRequestScope /// /// Initializes a new instance of the class. /// - /// The associated with this scope. - /// Thrown if the argument is >. + /// The client whose requests the scope applies to. + /// Thrown if is . public HttpRequestScope(HttpRestClient client) { Client = client ?? throw new ArgumentNullException(nameof(client)); } /// - /// Gets the associated with this scope. + /// Gets the client whose requests the scope applies to. /// - /// The that is used to send HTTP requests within this scope. + /// The of this scope. public HttpRestClient Client { get; } /// - /// Gets the collection of headers that are configured to be applied to the HTTP requests sent within this scope. + /// Gets the headers of this scope. /// /// - /// The read-only collection of key-value pairs representing the headers to be applied to the HTTP requests sent within this scope. + /// The headers to set, and, with a value, the headers to remove. /// public IReadOnlyCollection> Headers => _headers ?? []; /// - /// Gets the collection of properties that are configured to be applied to the HTTP requests sent within this scope. + /// Gets the request properties of this scope. /// /// - /// The read-only collection of key-value pairs representing the properties to be applied to the HTTP requests sent within this scope. + /// The properties to set, and, with a value, the properties to remove. /// public IReadOnlyCollection> Properties => _properties ?? []; /// - /// Specifies that a header should be used with the specified value for requests sent within this scope. + /// Sets a header of the requests sent within this scope, replacing any default value. /// /// The name of the header. /// The value of the header. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope SetHeader(string name, string value) { if (name is null) @@ -62,11 +75,11 @@ public HttpRequestScope SetHeader(string name, string value) } /// - /// Specifies that a header should be removed from requests sent within this scope. + /// Removes a header from the requests sent within this scope, including a default one. /// /// The header name to remove. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope UnsetHeader(string name) { if (name is null) @@ -78,7 +91,7 @@ public HttpRequestScope UnsetHeader(string name) } /// - /// Specifies that a property should be used with the specified value for requests sent within this scope. + /// Sets a request property of the requests sent within this scope. /// /// The name of the property. /// The value of the property. @@ -95,11 +108,11 @@ public HttpRequestScope SetProperty(string name, object value) } /// - /// Specifies that a property should be removed from requests sent within this scope. + /// Removes a request property from the requests sent within this scope. /// /// The name of the property. /// The same instance for fluent chaining. - /// Thrown if the argument is >. + /// Thrown if the argument is . public HttpRequestScope UnsetProperty(string name) { if (name is null) @@ -111,37 +124,57 @@ public HttpRequestScope UnsetProperty(string name) } /// - /// Executes a task within the configured scope, applying all set properties and headers to requests made by the client during the execution of the task. + /// Runs an asynchronous action whose requests get the headers and properties of this scope. /// - /// The asynchronous action to execute, which involves HTTP requests that will include the configured properties and headers. + /// The action to run. It receives the client of this scope. /// A task representing the asynchronous operation. - /// Thrown if the is >. - public async Task PerformAsync(Func scopedAction) + /// Thrown if is . + public Task PerformAsync(Func scopedAction) { if (scopedAction is null) throw new ArgumentNullException(nameof(scopedAction)); + return PerformCoreAsync(scopedAction); + } + + /// + /// Executes the action of within the scope, after its arguments have been validated. + /// + /// The asynchronous action to execute. + /// A task representing the asynchronous operation. + private async Task PerformCoreAsync(Func scopedAction) + { using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; - await scopedAction(Client); + await scopedAction(Client).ConfigureAwait(false); } /// - /// Executes a task within the configured scope, applying all set properties and headers to requests made by the client during the execution of the task, and - /// returns a result of type . + /// Runs an asynchronous function whose requests get the headers and properties of this scope, and returns its result. /// - /// The type of the result returned by the scoped action. - /// The asynchronous function to execute, which involves HTTP requests that will include the configured properties and headers. + /// The type of the result of the function. + /// The function to run. It receives the client of this scope. /// A task representing the asynchronous operation with a result of type . - /// Thrown if the is >. - public async Task PerformAsync(Func> scopedFunction) + /// Thrown if is . + public Task PerformAsync(Func> scopedFunction) { if (scopedFunction is null) throw new ArgumentNullException(nameof(scopedFunction)); + return PerformCoreAsync(scopedFunction); + } + + /// + /// Executes the function of within the scope, after its arguments have been validated. + /// + /// The type of the result returned by the scoped function. + /// The asynchronous function to execute. + /// A task representing the asynchronous operation with a result of type . + private async Task PerformCoreAsync(Func> scopedFunction) + { using var propertyScope = _properties is not null ? Client.BeginPropertyScope(_properties) : null; using var headerScope = _headers is not null ? Client.BeginHeaderScope(_headers) : null; - return await scopedFunction(Client); + return await scopedFunction(Client).ConfigureAwait(false); } } } diff --git a/src/Kampute.HttpClient/HttpResponseErrorContext.cs b/src/Kampute.HttpClient/HttpResponseErrorContext.cs index 9e6d69b..0190897 100644 --- a/src/Kampute.HttpClient/HttpResponseErrorContext.cs +++ b/src/Kampute.HttpClient/HttpResponseErrorContext.cs @@ -6,60 +6,76 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; /// - /// Represents the context of an HTTP response error, providing information about the HTTP request, the client that sent the request, - /// the error encountered, and the response that indicates failure. + /// Describes an error response to the error handlers that decide whether to retry the request: the client, the request, the response, and the + /// error. /// public class HttpResponseErrorContext : HttpRequestErrorContext { /// - /// Initializes an instance of the class. + /// Initializes a new instance of the class. /// - /// The instance used to send the request. - /// The that resulted in a failure. - /// The indicating the failure. - /// The containing details of the error encountered during the HTTP request. - /// Thrown if , , or or is . - public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage request, HttpResponseMessage response, HttpResponseException error) - : base(client, request, error) + /// The client that sent the request. + /// The request that received the error response. + /// The error response. + /// The exception for the error response. + /// The retry state of the call that sent the request, shared by all its attempts. + /// Thrown if , , , or is . + public HttpResponseErrorContext(HttpRestClient client, HttpRequestMessage request, HttpResponseMessage response, HttpResponseException error, HttpRetryState retryState) + : base(client, request, error, retryState) { Response = response ?? throw new ArgumentNullException(nameof(response)); } /// - /// Gets the indicating the failure. + /// Gets the error response. /// /// - /// The indicating the failure. + /// The with the error status code. /// public HttpResponseMessage Response { get; } /// - /// Gets the containing details of the HTTP response error. + /// Gets the exception for the error response. /// /// - /// The containing details of the HTTP response error. + /// The that the client throws if no handler retries the request. /// public new HttpResponseException Error => (HttpResponseException)base.Error; /// - /// Schedules a retry for the failed HTTP request using a provided scheduler factory. + /// Waits as the retry session of the source decides, and returns a clone of the request to retry, or a decision not to retry. /// - /// A function that returns an for scheduling retry attempts based on the error context. + /// + /// The component that handles this kind of failure and counts its retries, typically the that + /// handles the response. + /// + /// + /// A function that creates the retry session of the source from this context, or returns if the source does not retry. + /// /// A token that can be used to cancel the operation. - /// A task that resolves to an indicating whether a retry should be attempted. - /// Thrown if the is . - public Task ScheduleRetryAsync(Func schedulerFactory, CancellationToken cancellationToken = default) + /// + /// A task that resolves, after the wait, to a decision to retry with a clone of the request, or to . + /// + /// Thrown if or is . + /// + /// Each source has its own retry session for a call, as described for + /// . + /// + public Task ScheduleRetryAsync(object source, Func sessionFactory, CancellationToken cancellationToken = default) { - if (schedulerFactory is null) - throw new ArgumentNullException(nameof(schedulerFactory)); + if (source is null) + throw new ArgumentNullException(nameof(source)); + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); - return base.ScheduleRetryAsync(_ => schedulerFactory(this), cancellationToken); + return base.ScheduleRetryAsync(source, _ => sessionFactory(this), cancellationToken); } } } diff --git a/src/Kampute.HttpClient/HttpResponseException.cs b/src/Kampute.HttpClient/HttpResponseException.cs index 7dd0bab..7e4b49d 100644 --- a/src/Kampute.HttpClient/HttpResponseException.cs +++ b/src/Kampute.HttpClient/HttpResponseException.cs @@ -12,7 +12,7 @@ namespace Kampute.HttpClient using System.Text; /// - /// Represents an exception that is thrown when an HTTP request results in a failure HTTP status code. + /// The exception that is thrown when a request receives an error response that no error handler retries. /// public class HttpResponseException : HttpRequestException { @@ -21,7 +21,11 @@ public class HttpResponseException : HttpRequestException /// /// The HTTP status code associated with the exception. public HttpResponseException(HttpStatusCode statusCode) +#if !NETSTANDARD2_0 + : base(null, null, statusCode) +#else : base() +#endif { StatusCode = statusCode; } @@ -32,7 +36,11 @@ public HttpResponseException(HttpStatusCode statusCode) /// The HTTP status code associated with the exception. /// The error message that explains the reason for the exception. public HttpResponseException(HttpStatusCode statusCode, string message) +#if !NETSTANDARD2_0 + : base(message, null, statusCode) +#else : base(message) +#endif { StatusCode = statusCode; } @@ -44,7 +52,11 @@ public HttpResponseException(HttpStatusCode statusCode, string message) /// The error message that explains the reason for the exception. /// The exception that is the cause of the current exception, or a reference if no inner exception is specified. public HttpResponseException(HttpStatusCode statusCode, string message, Exception? innerException) +#if !NETSTANDARD2_0 + : base(message, innerException, statusCode) +#else : base(message, innerException) +#endif { StatusCode = statusCode; } @@ -55,29 +67,49 @@ public HttpResponseException(HttpStatusCode statusCode, string message, Exceptio /// /// The HTTP status code associated with the exception. /// + /// + /// On .NET 10 and later, the inherited HttpRequestException.StatusCode has the same value, so exception filters on + /// can test the status code of an error response. + /// +#if !NETSTANDARD2_0 + public new HttpStatusCode StatusCode { get; } +#else public HttpStatusCode StatusCode { get; } +#endif /// - /// Gets or sets the validation errors associated with the exception. + /// Gets or sets the validation errors that the error response reports. /// /// - /// The validation errors associated with the exception, if any. It maps error keys to their corresponding error messages arrays. Can be if there are no validation errors. + /// The error messages of each invalid field, keyed by the field name, or if there are none. An + /// can set them from the error body. /// public IDictionary? Errors { get; set; } /// - /// Gets or sets the HTTP response message associated with the exception. + /// Gets or sets the error response. /// /// - /// The HTTP response message associated with the exception. Can be if there is no HTTP response message. + /// The of the error, or if there is none. /// + /// + /// + /// When throws this exception, it has already disposed the response. The status code, reason phrase, + /// response headers and request message remain readable, but reading the response content throws . + /// + /// + /// To use a structured error body, set . The client then deserializes the error body before + /// disposing the response, and exposes it through . + /// + /// public HttpResponseMessage? ResponseMessage { get; set; } /// - /// Gets or sets the deserialized object from the HTTP response associated with the exception. + /// Gets or sets the body of the error response, read as . /// /// - /// The deserialized object from the HTTP response body. Can be if the HTTP response is not deserialized. + /// The object read from the error body, or if is not set or the body + /// could not be read. /// public object? ResponseObject { get; set; } diff --git a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs index 4565378..ae4c6ce 100644 --- a/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs +++ b/src/Kampute.HttpClient/HttpResponseHeadersExtensions.cs @@ -10,16 +10,19 @@ namespace Kampute.HttpClient using System.Net.Http.Headers; /// - /// Provides extension methods for to facilitate HTTP response processing. + /// Provides extension methods that read the retry time a response suggests from its headers. /// public static class HttpResponseHeadersExtensions { /// - /// Attempts to extract the retry-after time from the HTTP response headers. + /// Reads the retry time from the Retry-After header. /// - /// The HTTP response headers. - /// When this method returns, contains the extracted time if the operation is successful; otherwise, . This parameter is passed uninitialized. - /// if the time could be successfully extracted and parsed; otherwise, . + /// The response headers. + /// + /// When this method returns, the time the header suggests, or if it has none. A number of seconds is converted to a time + /// from now. + /// + /// if the header has a valid date or number of seconds; otherwise, . public static bool TryExtractRetryAfterTime(this HttpResponseHeaders headers, out DateTimeOffset? retryAfterTime) { if (headers.RetryAfter is RetryConditionHeaderValue retryAfterHeader) @@ -41,11 +44,22 @@ public static bool TryExtractRetryAfterTime(this HttpResponseHeaders headers, ou } /// - /// Attempts to extract the rate limit reset time from the HTTP response headers. + /// Reads the time when a rate limit resets from the Retry-After header or a rate limit reset header. /// - /// The HTTP response headers. - /// When this method returns, contains the extracted time if the operation is successful; otherwise, . This parameter is passed uninitialized. - /// if the time could be successfully extracted and parsed; otherwise, . + /// The response headers. + /// When this method returns, the time the headers suggest, or if they suggest none. + /// if a header has a valid value; otherwise, . + /// + /// + /// The method first looks for a Retry-After header. If there is none, it reads the first rate limit reset header it finds among + /// ratelimit-reset, rate-limit-reset, x-ratelimit-reset and x-rate-limit-reset. + /// + /// + /// A rate limit reset value from 0 to 86400 is read as a number of seconds from now. A larger value, up to 253402300799 (the Unix time + /// of ), is read as a Unix time in seconds. Any other value, such as a negative number or a time + /// in milliseconds, is treated as if the header were missing. + /// + /// public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers, out DateTimeOffset? resetTime) { if (headers.TryExtractRetryAfterTime(out resetTime)) @@ -55,9 +69,9 @@ public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers { if (headers.TryGetValues(name, out var values)) { - if (long.TryParse(values.FirstOrDefault(), out var value)) + if (long.TryParse(values.FirstOrDefault(), out var value) && value >= 0 && value <= Constants.MaxUnixTimeSeconds) { - resetTime = value > 86400 // seconds per day + resetTime = value > Constants.SecondsPerDay ? DateTimeOffset.FromUnixTimeSeconds(value) : DateTimeOffset.UtcNow.AddSeconds(value); return true; @@ -74,6 +88,16 @@ public static bool TryExtractRateLimitResetTime(this HttpResponseHeaders headers /// private static class Constants { + /// + /// The largest rate limit reset value read as seconds from now. Larger values are read as a Unix time in seconds. + /// + public const long SecondsPerDay = 86400; + + /// + /// The Unix time in seconds of , which is the largest rate limit reset value accepted. + /// + public const long MaxUnixTimeSeconds = 253402300799; + /// /// The collection of possible HTTP header names for a rate limit reset value. /// diff --git a/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs b/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs index ed8bb07..dc485c3 100644 --- a/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs +++ b/src/Kampute.HttpClient/HttpResponseMessageEventArgs.cs @@ -10,21 +10,14 @@ namespace Kampute.HttpClient using System.Net.Http; /// - /// Provides event data for events related to the receipt of HTTP responses. + /// Provides the response of the event. /// - /// - /// This class is typically used in scenarios where an application needs to process or inspect HTTP responses in a centralized - /// manner. It encapsulates an instance of , allowing event handlers to access and potentially - /// modify the response message. This capability is particularly useful in middle-ware, HTTP client wrappers, or other scenarios - /// where responses need to be logged, modified, or inspected for specific criteria (like status codes or headers) before being - /// processed further. - /// public class HttpResponseMessageEventArgs : EventArgs { /// /// Initializes a new instance of the class with the specified response message. /// - /// The received HTTP response message. + /// The response received. /// Thrown if the is . public HttpResponseMessageEventArgs(HttpResponseMessage response) { @@ -32,10 +25,10 @@ public HttpResponseMessageEventArgs(HttpResponseMessage response) } /// - /// Gets the HTTP response message. + /// Gets the response received. /// /// - /// The HTTP response message involved in the event. + /// The received, before the client checks its status code or reads its content. /// public HttpResponseMessage Response { get; } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 994f9d9..cd8354d 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -5,6 +5,7 @@ namespace Kampute.HttpClient { + using Kampute.HttpClient.Content; using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.Utilities; using System; @@ -16,34 +17,30 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Facilitates HTTP communication with RESTful APIs by wrapping . + /// Sends requests to a REST API through an , and converts between .NET objects and HTTP content. /// /// /// - /// abstracts the complexities of , supporting the sharing of a single - /// instance across multiple instances. This optimizes resource use and connection management, enhancing performance by - /// reusing HTTP connections, especially during concurrent access to various services or API endpoints. + /// By default, every uses one shared , so clients created for different APIs reuse the same + /// connections. A client can also use an that the application creates and configures. /// /// - /// The client allows for scoped request headers and properties, providing temporary configurations that do not alter global settings. This ensures - /// that changes remain isolated to specific contexts, increasing maintainability and reducing configuration errors during runtime. + /// apply to every request of the client. and + /// add or override headers and request properties until the scope is disposed, without changing the defaults. /// /// - /// It includes a collection that automatically deserializes HTTP response content into .NET objects based on the - /// response's Content-Type. If the Accept header is not predefined, the client dynamically adjusts it based on the configured response - /// deserializers and the expected .NET object type. + /// read response content into .NET objects by its Content-Type, and write the payloads of + /// . When a request + /// sets no Accept header, the client sets one from the media types the formatters can read into the expected type. /// /// - /// Transient failures and network interruptions are managed via the property, which outlines retry logic and wait times - /// between retries. This strategic approach helps avoid server overloads and improves communication success without excessive resource use. + /// Failures are recovered in two ways. decides whether to retry after a transient connection failure or a timeout, and + /// decide whether to retry after an error response. An error response that no handler retries is thrown as an + /// . /// /// - /// Extensible error handling is enabled through the collection, allowing custom implementations - /// to handle specific HTTP errors with tailored strategies. - /// - /// - /// Lifecycle events like and enhance request and response handling by enabling - /// modifications, inspections, and logging, allowing for a highly customizable interaction. + /// and let the application change or inspect each request and response, + /// for example to add headers or to log. /// /// public class HttpRestClient : IDisposable @@ -60,14 +57,15 @@ private static HttpRequestHeaders CreateRequestHeaders() private readonly ScopedCollection> _scopedHeaders = new(); private readonly ScopedCollection> _scopedProperties = new(); - private IHttpBackoffProvider _backoffStrategy = BackoffStrategies.None; + private IHttpRetryPolicy _retryPolicy = HttpRetryPolicy.None; private Uri? _baseAddress; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class that uses the shared . /// /// - /// This constructor initializes the using a shared instance acquired from the static class. + /// The client acquires a reference to the of and releases it when it is disposed. + /// The shared is disposed when its last reference is released. /// public HttpRestClient() : this(SharedHttpClient.AcquireReference()) @@ -75,13 +73,12 @@ public HttpRestClient() } /// - /// Initializes a new instance of the class with the specified shared reference. + /// Initializes a new instance of the class that uses a shared . /// - /// A reference to a shared instance, managed as . - /// Thrown if is >. - /// - /// This constructor takes ownership of the shared reference and ensures it is properly released when the is disposed. - /// + /// + /// A reference to the shared . The client takes ownership of the reference and releases it when it is disposed. + /// + /// Thrown if is . public HttpRestClient(SharedDisposable.Reference httpClientReference) { if (httpClientReference is null) @@ -94,8 +91,11 @@ public HttpRestClient(SharedDisposable.Reference httpClientReference /// /// Initializes a new instance of the class with the specified . /// - /// The to be used by the . - /// Specifies whether the should dispose of the provided when the is disposed. + /// The that sends the requests of this client. + /// + /// to dispose when this client is disposed; to leave it to its owner. + /// The default is . + /// /// Thrown if is . public HttpRestClient(HttpClient httpClient, bool disposeClient = true) { @@ -105,17 +105,11 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) } /// - /// Releases unmanaged resources. - /// - ~HttpRestClient() => Dispose(false); - - /// - /// Occurs when a new HTTP request message is about to be sent. + /// Occurs before a request is sent. /// /// - /// This event provides an opportunity for subscribers to modify the before it is sent. Common modifications include adding - /// custom headers, changing request properties, or logging request information. Modifications made to the request in this event are included in the outgoing - /// HTTP request. + /// The event is raised before every attempt of a request, including retries. Handlers can change the request, for example to add a header, + /// and the changes are sent. /// public event EventHandler? BeforeSendingRequest; @@ -123,41 +117,37 @@ public HttpRestClient(HttpClient httpClient, bool disposeClient = true) /// Occurs when an HTTP response has been received. /// /// - /// This event is raised after an HTTP response is received but before the response is processed further. It provides a way for subscribers to inspect the - /// . This can be useful for logging response details, handling specific HTTP status codes, or modifying the response content - /// or headers before they are processed by the rest of the application. + /// + /// The event is raised for every response, including error responses and responses that lead to a retry, before the client checks the status + /// code or reads the content. Handlers can inspect or log the response. + /// + /// + /// For requests sent with , such as those of , + /// and , the event is raised once the headers arrive + /// and the body is not buffered. A subscriber that reads the response content consumes the body stream, which leaves nothing for the caller. + /// /// public event EventHandler? AfterReceivingResponse; /// - /// Occurs just before the is disposed. + /// Occurs when the client is disposed, before it releases its . /// - /// - /// This event provides a way for subscribers to perform cleanup or other actions before the client is disposed. - /// public event EventHandler? Disposing; /// - /// Gets or sets the base address for HTTP requests. + /// Gets or sets the base address of the requests. /// /// - /// The base address for HTTP requests. + /// The URI that relative request URIs are resolved against, or if requests use absolute URIs. /// /// /// - /// If the provided base address does not end with a slash, one is automatically appended to ensure consistent URL resolution behavior. - /// - /// - /// This is important because the presence or absence of a trailing slash affects how relative URIs are combined with the base address. - /// For example, with a base address of http://example.com/api (no trailing slash), a relative URI of users resolves to - /// http://example.com/users. Conversely, with http://example.com/api/, it resolves to http://example.com/api/users. + /// If the base address does not end with a slash, one is appended. Without it, the last segment of the path would be replaced when a relative + /// URI is resolved: with http://example.com/api, the relative URI users would resolve to http://example.com/users instead + /// of http://example.com/api/users. /// /// - /// By appending the slash when missing, the library ensures predictable and correct routing of requests. - /// - /// - /// It's also worth noting that a value for the base address is acceptable and indicates that no base address is set. In such cases, - /// any HTTP request must use an absolute URL. + /// When the base address is , every request must use an absolute URI. /// /// public Uri? BaseAddress @@ -167,77 +157,114 @@ public Uri? BaseAddress } /// - /// Gets or sets the backoff strategy for handling transient connection failures during HTTP requests. + /// Gets or sets the retry policy for transient connection failures. /// /// - /// The backoff strategy for handling transient connection failures during HTTP requests. + /// The that decides whether and when a request is retried after a transient connection failure or a timeout. + /// The default is , which does not retry; setting restores it. /// /// - /// This property specifies the retry logic applied exclusively to connection failures, not to the processing of server responses. It determines - /// if and when the client should retry a failed connection attempt before giving up. This approach is crucial for dealing with transient network - /// issues or temporary server unavailability. The default is . + /// + /// The policy applies when a request fails without a response, for example because the connection is refused or reset, the host cannot be + /// reached, or elapses. Error responses, such as '503 Service Unavailable', are handled by + /// instead. + /// + /// + /// The limits of the policy apply to the retries after connection failures only. Error handlers count their own retries separately, so a + /// call that fails in several ways can be retried more times in total than this policy allows. + /// /// - public IHttpBackoffProvider BackoffStrategy + /// + /// This policy retries a request up to five times after connection failures, with a delay that starts at one second and doubles each time: + /// + /// client.RetryPolicy = RetryStrategies.Exponential(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); + /// + /// + public IHttpRetryPolicy RetryPolicy { - get => _backoffStrategy; - set => _backoffStrategy = value ?? BackoffStrategies.None; + get => _retryPolicy; + set => _retryPolicy = value ?? HttpRetryPolicy.None; } /// - /// Gets or sets the used to deserialize the response body when the response status code indicates an error. + /// Gets or sets the type into which the body of an error response is read. /// /// - /// The used to deserialize the response body when the response status code indicates an error. + /// The error model of the API, or to leave the body of error responses unread. /// /// /// - /// This property specifies the that the will use to deserialize the response content in cases - /// where the HTTP response indicates an error. It is important to ensure that the custom type specified is compatible with the expected error - /// response format and can be deserialized by the content deserializers available to the . + /// When this property is set, the client reads the body of an error response into this type with the formatters in + /// , and exposes the result through . If the body cannot be + /// read, is . /// /// - /// When the specified type implements the interface, the deserialized object is utilized to construct a more - /// informative exception. This mechanism enables the integration of custom error handling strategies by leveraging structured error information - /// returned from the server. + /// When the type implements , the object read from the body creates the exception, so it can carry the error + /// details of the API, such as validation errors. /// /// public Type? ResponseErrorType { get; set; } /// - /// Gets the mutable collection of HTTP error handlers used for handling error responses. + /// Gets the error handlers that decide whether to retry a request after an error response. /// /// - /// The mutable collection of HTTP error handlers used for handling error responses. + /// The error handlers of this client. /// /// - /// This property provides access to a collection of instances that are used to handle - /// HTTP error responses. The handlers in this collection are tried in order to handle errors. + /// When a request receives an error response, the handlers whose accepts the status code are asked in + /// the order they were added, until one of them returns a request to retry. If none does, the error is thrown as an . /// public HttpErrorHandlerCollection ErrorHandlers { get; } = []; /// - /// Gets the mutable collection of HTTP content deserializers used for deserializing response content. + /// Gets the content formatters that read response content and write request payloads. /// /// - /// The mutable collection of HTTP content deserializers used for deserializing response content. + /// The mutable collection of instances of this client. /// /// - /// This property provides access to a collection of instances that are used to - /// deserialize the content of HTTP responses. The deserializers in this list are tried in order to deserialize the response - /// content into .NET objects. + /// + /// The formatters are tried in order. The first one that can read the media type of a response into the expected .NET type reads it, and the + /// media types they can read feed the Accept header of requests that do not set one. The first one that can write a payload in the + /// requested media type writes the payloads of . + /// + /// + /// The collection is empty initially. Format packages register their formatters with extension methods such as UseJson. + /// /// - public HttpContentDeserializerCollection ResponseDeserializers { get; } = []; + public HttpContentFormatterCollection ContentFormatters { get; } = []; /// - /// Gets the headers which should be sent with each request. + /// Gets the headers sent with every request of this client. /// /// - /// The headers which should be sent with each request. + /// The default request headers of this client. /// + /// + /// + /// Headers of an active scope, begun with , override these headers. + /// + /// + /// is not thread-safe. The client locks this collection while it copies the headers into each new request, and + /// locks it while it updates the Authorization header. Code that + /// changes this collection while requests are in flight must lock the same object. + /// + /// + /// + /// This code replaces the access token while other requests may be in flight: + /// + /// lock (client.DefaultRequestHeaders) + /// { + /// client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, accessToken); + /// } + /// + /// public HttpRequestHeaders DefaultRequestHeaders { get; } = CreateRequestHeaders(); /// - /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. + /// Raises the event, then disposes the of this client if the client owns it, or releases + /// the reference of the client to a shared one. /// public void Dispose() { @@ -246,18 +273,18 @@ public void Dispose() } /// - /// Begins a new scope with the specified request properties. + /// Begins a scope that sets or removes request properties of the requests this client sends until the scope is disposed. /// - /// The request properties to be applied exclusively during the lifetime of the new scope. - /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request properties. + /// The properties to set, or, with a value, to remove. + /// An that ends the scope when it is disposed. /// Thrown if is . /// /// - /// This method creates a scope associated with the current instance to add, modify or remove any request properties in subsequent - /// requests during the lifetime of this scope. To remove a property, use for its value. + /// Request properties carry values for message handlers and for the handlers of ; they are not sent to the server. /// /// - /// Upon disposing of the scope, all property adjustments are reverted, restoring the properties to their state before the scope was activated. + /// A scope flows with the asynchronous context of the code that begins it: it applies to the requests that code makes, including in methods it + /// awaits, but not to requests made concurrently by unrelated code. Scopes can be nested, and an inner scope overrides an outer one. /// /// public virtual IDisposable BeginPropertyScope(IEnumerable> properties) @@ -269,24 +296,21 @@ public virtual IDisposable BeginPropertyScope(IEnumerable - /// Begins a new scope with the specified request headers. + /// Begins a scope that sets or removes headers of the requests this client sends until the scope is disposed. /// - /// The request headers to be applied exclusively during the lifetime of the new scope. - /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request headers. + /// The headers to set, or, with a value, to remove. + /// An that ends the scope when it is disposed. /// Thrown if is . /// /// - /// This method creates a scope associated with the current instance to add, modify or remove any request header in subsequent - /// requests during the lifetime of this scope. To remove a header, use for its value. - /// - /// - /// Any header modifications made within this scope take precedence over the client's default headers. Header adjustments by other active scopes are overridden - /// by those provided in this scope. However, the default request headers set on the underlying instance take precedence over the default - /// and scoped headers of the instance because they are applied later in the message handler pipeline. To avoid conflicts, it is - /// recommended to keep the default request headers of the underlying instance empty. + /// The headers of the scope replace the headers of the same name in . A scope flows with the asynchronous + /// context of the code that begins it: it applies to the requests that code makes, including in methods it awaits, but not to requests made + /// concurrently by unrelated code. Scopes can be nested, and an inner scope overrides an outer one. /// /// - /// Upon disposing of the scope, all header adjustments are reverted, restoring the headers to their state before the scope was activated. + /// The of the underlying are added to a request only for + /// the headers it does not already have, so a header that a scope removes is still sent if the sets it. Keep the + /// default headers of the empty, and set them on instead. /// /// public virtual IDisposable BeginHeaderScope(IEnumerable> headers) @@ -298,7 +322,7 @@ public virtual IDisposable BeginHeaderScope(IEnumerable - /// Sends an asynchronous HTTP request with the specified method, URI, and payload, returning response body deserialized as the specified type. + /// Sends an asynchronous HTTP request with the specified method, URI, and payload, and returns the response body read as the specified type. /// /// The type of the response object. /// The HTTP method to use for the request. @@ -308,20 +332,34 @@ public virtual IDisposable BeginHeaderScope(IEnumerableA task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public virtual async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public virtual Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) { if (method is null) throw new ArgumentNullException(nameof(method)); if (uri is null) throw new ArgumentNullException(nameof(uri)); + return SendCoreAsync(method, uri, payload, cancellationToken); + } + + /// + /// Sends the request of after its arguments have been validated. + /// + /// The type of the response object. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The HTTP request payload content. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the specified type. + private async Task SendCoreAsync(HttpMethod method, string uri, HttpContent? payload, CancellationToken cancellationToken) + { using var request = CreateHttpRequest(method, uri, typeof(T)); request.Content = payload; - using var response = await DispatchWithRetriesAsync(request, cancellationToken).ConfigureAwait(false); + using var response = await DispatchWithRetriesAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); return (T?)await DeserializeContentAsync(response, typeof(T), cancellationToken).ConfigureAwait(false); } @@ -331,70 +369,122 @@ public virtual IDisposable BeginHeaderScope(IEnumerableThe HTTP method to use for the request. /// The URI to which the request is sent. /// The HTTP request payload content (optional). + /// + /// When the operation completes: after the whole response body has been read (, the default), + /// or as soon as the response headers have been read (). + /// /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. The task result contains the response. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public virtual async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// With , the response body is not buffered: it is read from the network as the caller + /// reads the response content, and the caller must dispose the response to release the connection. then covers + /// only the time until the headers arrive, and a failure while the body is read is not retried. + /// + public virtual Task SendAsync + ( + HttpMethod method, + string uri, + HttpContent? payload = default, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default + ) { if (method is null) throw new ArgumentNullException(nameof(method)); if (uri is null) throw new ArgumentNullException(nameof(uri)); + return SendCoreAsync(method, uri, payload, completionOption, cancellationToken); + } + + /// + /// Sends the request of after its arguments + /// have been validated. + /// + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The HTTP request payload content. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation. The task result contains the response. + private async Task SendCoreAsync + ( + HttpMethod method, + string uri, + HttpContent? payload, + HttpCompletionOption completionOption, + CancellationToken cancellationToken + ) + { using var request = CreateHttpRequest(method, uri, responseObjectType: null); request.Content = payload; - return await DispatchWithRetriesAsync(request, cancellationToken).ConfigureAwait(false); + return await DispatchWithRetriesAsync(request, completionOption, cancellationToken).ConfigureAwait(false); } /// - /// Sends an asynchronous HTTP request, with the possibility of retrying the request based on specific failure conditions. + /// Sends a request, and retries it after a transient connection failure, a timeout, or an error response that a handler retries. /// /// The to send. + /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// - /// This method is responsible for sending the HTTP request and optionally retrying it under specific failure conditions. The decision to retry a request is based - /// on the nature of the failure, with potential consultation of external retry logic mechanisms. + /// After a transient connection failure or a timeout, the request is retried if allows it. After an error response, + /// it is retried if one of decides so. Otherwise, the failure is thrown. /// - /// + /// /// - protected virtual async Task DispatchWithRetriesAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + protected virtual Task DispatchWithRetriesAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken = default) { if (request is null) throw new ArgumentNullException(nameof(request)); + return DispatchWithRetriesCoreAsync(request, completionOption, cancellationToken); + } + + /// + /// Sends the request of and retries it, after its arguments have been validated. + /// + /// The to send. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the received in response to the request. + private async Task DispatchWithRetriesCoreAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { using var cloneManager = new HttpRequestMessageCloneManager(request); + var retryState = new HttpRetryState(); for (; ; ) { cancellationToken.ThrowIfCancellationRequested(); try { - return await DispatchAsync(cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + return await DispatchAsync(cloneManager.RequestToSend, completionOption, cancellationToken).ConfigureAwait(false); } catch (HttpResponseException httpError) when (httpError.ResponseMessage is not null) { - var decision = await DecideOnRetryAsync(httpError, cloneManager.RequestToSend, httpError.ResponseMessage, cancellationToken).ConfigureAwait(false); + var decision = await DecideOnRetryAsync(httpError, cloneManager.RequestToSend, httpError.ResponseMessage, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } catch (HttpRequestException networkError) when (networkError.IsTransientNetworkError()) { - var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } catch (TaskCanceledException timeoutError) when (!cancellationToken.IsCancellationRequested) { var networkError = new HttpRequestException("The HTTP request timed out.", timeoutError); - var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, cancellationToken).ConfigureAwait(false); + var decision = await DecideOnRetryAsync(networkError, cloneManager.RequestToSend, retryState, cancellationToken).ConfigureAwait(false); if (!cloneManager.TryApplyDecision(decision)) throw; } @@ -402,26 +492,55 @@ protected virtual async Task DispatchWithRetriesAsync(HttpR } /// - /// Asynchronously dispatches an HTTP request. + /// Sends a request once, without retries. /// /// The to send. + /// When the operation completes: after the whole response body has been read, or as soon as the response headers have been read. /// A token for canceling the request. /// A task that represents the asynchronous operation, with a result of the received in response to the request. /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. /// - /// This method sends the provided HTTP request and returns the response if the status code indicates a success. For any error status codes, it fails fast by throwing - /// an exception specific to the nature of the error. Additionally, the method incorporates pre-send and post-receive hooks for adding custom logic, such as modifying - /// request headers or logging response details. + /// This method returns the response if its status code indicates success; for an error status code, it throws an . + /// It raises before sending and when the response arrives. /// - protected virtual async Task DispatchAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected virtual Task DispatchAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) { if (request is null) throw new ArgumentNullException(nameof(request)); + return DispatchCoreAsync(request, completionOption, cancellationToken); + } + + /// + /// Sends the request of once, after its arguments have been validated. + /// + /// The to send. + /// When the operation completes. + /// A token for canceling the request. + /// A task that represents the asynchronous operation, with a result of the received in response to the request. + private async Task DispatchCoreAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken) + { OnBeforeSendingRequest(request); - var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); +#if !NETSTANDARD2_0 + var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); +#else + // HttpClient on .NET Framework disposes the request content after sending, but a retry sends the same content again. + var content = request.Content; + if (content is not null) + request.Content = new NonOwningContent(content); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false); + } + finally + { + request.Content = content; + } +#endif try { response.RequestMessage = request; @@ -440,69 +559,77 @@ protected virtual async Task DispatchAsync(HttpRequestMessa } /// - /// Asynchronously evaluates transient network issues to decide the appropriate action based on the error context and predefined error - /// handling strategies. + /// Decides whether to retry a request after a transient connection failure or a timeout. /// /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. + /// The retry state of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. - /// Thrown if or is . + /// Thrown if , or is . /// - /// This method assesses transient network issues, leveraging backoff strategies specified by . It returns an - /// that guides the next steps, either to retry the request with potentially modified parameters or - /// to handle the error as unrecoverable. + /// The base implementation retries as decides. An override that creates its own passes + /// to it, so that the retry sessions are kept across the attempts of the call. /// - /// + /// protected virtual Task DecideOnRetryAsync ( HttpRequestException error, HttpRequestMessage request, + HttpRetryState retryState, CancellationToken cancellationToken ) { - var ctx = new HttpRequestErrorContext(this, request, error); - return ctx.ScheduleRetryAsync(BackoffStrategy.CreateScheduler, cancellationToken); + var ctx = new HttpRequestErrorContext(this, request, error, retryState); + return ctx.ScheduleRetryAsync(this, RetryPolicy.CreateSession, cancellationToken); } /// - /// Asynchronously evaluates failed HTTP responses to decide the appropriate action based on the error context and predefined error - /// handling strategies. + /// Decides whether to retry a request after an error response. /// /// The encapsulating details of the encountered error during the HTTP request execution. /// The that led to the failed response. /// The received indicating a failure. + /// The retry state of the call, shared by all its attempts. /// A token for canceling the operation. /// A task that resolves to an , indicating whether to retry the request or that the error is unrecoverable. - /// Thrown if , or is . + /// Thrown if , , or is . /// - /// This method assesses HTTP request failures, leveraging error handling strategies within . It returns an - /// that guides the next steps, either to retry the request with potentially modified parameters or to handle the error as unrecoverable. + /// The base implementation asks the handlers in that can handle the status code, in order, until one of them + /// decides to retry. An override that + /// creates its own passes to it, so that the retry sessions are kept across the attempts + /// of the call. /// /// - protected virtual async Task DecideOnRetryAsync + protected virtual Task DecideOnRetryAsync ( HttpResponseException error, HttpRequestMessage request, HttpResponseMessage response, + HttpRetryState retryState, CancellationToken cancellationToken ) { - if (error is null) - throw new ArgumentNullException(nameof(error)); - if (request is null) - throw new ArgumentNullException(nameof(request)); - if (response is null) - throw new ArgumentNullException(nameof(response)); + var ctx = new HttpResponseErrorContext(this, request, response, error, retryState); + return ConsultErrorHandlersAsync(ctx, cancellationToken); + } - var ctx = new HttpResponseErrorContext(this, request, response, error); + /// + /// Asks the error handlers for the status code of the response, in order, until one of them decides to retry. + /// + /// The context of the failed response. + /// A token for canceling the operation. + /// A task that resolves to the decision of the first handler that retries, or . + private async Task ConsultErrorHandlersAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) + { + var response = ctx.Response; foreach (var errorHandler in ErrorHandlers.GetHandlersFor(response.StatusCode)) { var decision = await errorHandler.DecideOnRetryAsync(ctx, cancellationToken).ConfigureAwait(false); if (decision.RequestToRetry is not null) { - decision.RequestToRetry.Properties[HttpRequestMessagePropertyKeys.ErrorHandler] = errorHandler; + decision.RequestToRetry.GetPropertyBag()[HttpRequestMessagePropertyKeys.ErrorHandler] = errorHandler; return decision; } } @@ -511,7 +638,7 @@ CancellationToken cancellationToken } /// - /// Converts an into an appropriate exception. + /// Creates the exception for an error response. /// /// The HTTP response message to convert. /// A token for canceling the operation. @@ -521,17 +648,27 @@ CancellationToken cancellationToken /// /// Thrown if is . /// - /// This method endeavors to deserialize the response content into a , provided that the type is - /// specified and implements the interface. Upon successful deserialization, the resulting data - /// is transformed into an exception. If deserialization fails, a generic is generated, incorporating - /// the response's status code and a default error message. + /// If is set, this method reads the response content into that type and stores the result in + /// . If the result implements , it creates the exception; + /// otherwise, or if the content cannot be read, the exception carries the status code and a default message. /// /// - protected virtual async Task ToExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) + protected virtual Task ToExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response is null) throw new ArgumentNullException(nameof(response)); + return ToExceptionCoreAsync(response, cancellationToken); + } + + /// + /// Converts the response of into an exception, after its arguments have been validated. + /// + /// The HTTP response message to convert. + /// A token for canceling the operation. + /// A task that represents the asynchronous operation. The task result contains the exception that represents the error. + private async Task ToExceptionCoreAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { var responseObject = default(object); if (ResponseErrorType is not null && response.Content is not null && response.Content.Headers.ContentLength != 0) { @@ -555,7 +692,7 @@ protected virtual async Task ToExceptionAsync(HttpRespons } /// - /// Asynchronously deserializes the body of an and converts it into an object of a specified type. + /// Reads the body of a response into an object of the specified type. /// /// The to be read. /// The type of object to which the response body is to be converted. @@ -564,29 +701,41 @@ protected virtual async Task ToExceptionAsync(HttpRespons /// Thrown if or is . /// Thrown when the response body is empty, the content type is unsupported, or parsing the response fails. /// - /// This method uses configured content deserializers for content deserialization and supports custom content types. In case of deserialization - /// failures, an is thrown, which may contain an inner exception providing more details about the parsing error. + /// This method reads the content with the first formatter in that can read its media type into . + /// If the formatter fails, the carries its exception as the inner exception. /// - /// - protected virtual async Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) + /// + protected virtual Task DeserializeContentAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) { if (response is null) throw new ArgumentNullException(nameof(response)); if (objectType is null) throw new ArgumentNullException(nameof(objectType)); + return DeserializeContentCoreAsync(response, objectType, cancellationToken); + } + + /// + /// Deserializes the response body of , after its arguments have been validated. + /// + /// The to be read. + /// The type of object to which the response body is to be converted. + /// A token for canceling the operation. + /// A task representing the asynchronous operation, with the deserialized response body as an object. + private async Task DeserializeContentCoreAsync(HttpResponseMessage response, Type objectType, CancellationToken cancellationToken) + { if (response.Content is null || response.Content.Headers.ContentLength == 0) throw Error("The response body is empty."); var mediaType = (response.Content.Headers.ContentType?.MediaType) ?? throw Error("The media type of the response is unspecified."); - var deserializer = ResponseDeserializers.GetDeserializerFor(mediaType, objectType) - ?? throw Error($"Unable to deserialize response body due to the absence of a matching deserializer for '{mediaType}' media type."); + var formatter = ContentFormatters.GetReaderFor(mediaType, objectType) + ?? throw Error($"Unable to deserialize response body due to the absence of a content formatter that reads '{mediaType}' media type."); try { - return await deserializer.DeserializeAsync(response.Content, objectType, cancellationToken).ConfigureAwait(false); + return await formatter.ReadAsync(response.Content, objectType, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -608,45 +757,32 @@ HttpContentException Error(string message, Exception? innerException = null) } /// - /// Creates an with the specified method and URI. + /// Creates a request with the specified method and URI, and with the headers and properties of the client. /// - /// The HTTP method to be used for the request, such as GET, POST, PUT, etc. - /// The URI to which the request will be sent. Should be a valid, fully qualified URL. - /// The type of the object expected to be contained in the response. - /// An configured with the specified method and URI. + /// The HTTP method of the request. + /// The URI to which the request will be sent, absolute or relative to . + /// The type of the object expected in the response, or if the response body is not read. + /// The new . /// Thrown if or is . /// /// - /// This method constructs a new HTTP request message by setting the HTTP method and URI. It prepares the request for transmission by - /// configuring both the headers and custom properties appropriate for the given context and operation. - /// - /// - /// Headers are added or adjusted from both the default headers provided by the property and any scoped - /// headers that are active at the time of this request’s creation. Scoped headers are prioritized over default headers in case of key conflicts - /// to ensure that context-specific modifications are respected. - /// - /// - /// If an Accept header is absent in both default and scoped headers, it is added based on the media types supported by the content deserializers - /// for the specified . If is , the header defaults to accepting all - /// media types ("*/*"). + /// The request gets the headers of , overridden by the headers of the active scopes. If neither sets an + /// Accept header, it gets one with the media types that can read into + /// and into , or */* if is . /// /// - /// This method also includes scoped properties in the HTTP request message to provide additional context and facilitate easier tracking and processing - /// of the request. In addition to the scoped properties, the following properties are added: + /// The request gets the properties of the active scopes, and the following properties: /// /// /// /// - /// A unique identifier () generated and assigned to each request, aiding in the request's tracking, debugging, and logging - /// processes. The unique identifier ensures that each request can be individually tracked, even when multiple requests are executed simultaneously - /// or when requests are retried due to transient failures. + /// A unique identifier () of the request, which the clones made for its retries keep, so that the attempts of a call can be correlated in logs. /// /// /// /// /// - /// Defines the .NET type () expected in the response, if any. This metadata provides context that can improve debugging, enhance logging details, - /// and support error recovery strategies. + /// The .NET type () expected in the response, if any. /// /// /// @@ -669,8 +805,11 @@ protected virtual HttpRequestMessage CreateHttpRequest(HttpMethod method, string void AddRequestHeaders() { - foreach (var header in DefaultRequestHeaders) - request.Headers.TryAddWithoutValidation(header.Key, header.Value); + lock (DefaultRequestHeaders) + { + foreach (var header in DefaultRequestHeaders) + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } if (_scopedHeaders.HasActiveScope) { @@ -684,33 +823,39 @@ void AddRequestHeaders() if (!request.Headers.Contains(nameof(HttpRequestHeader.Accept))) { - foreach (var mediaType in ResponseDeserializers.GetAcceptableMediaTypes(responseObjectType, ResponseErrorType)) + foreach (var mediaType in ContentFormatters.GetAcceptableMediaTypes(responseObjectType, ResponseErrorType)) request.Headers.Accept.Add(MediaTypeHeaderValueStore.Get(mediaType)); } } void AddRequestProperties() { - request.Properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); - request.Properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; + var properties = request.GetPropertyBag(); + properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); + properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; if (_scopedProperties.HasActiveScope) { foreach (var property in _scopedProperties) { if (property.Value is not null) - request.Properties[property.Key] = property.Value; + properties[property.Key] = property.Value; else - request.Properties.Remove(property.Key); + properties.Remove(property.Key); } } } } /// - /// Disposes the instance. + /// Raises the event and releases the of this client, when called from . /// - /// Indicates whether the method is called from a method. + /// when called from ; when called from a finalizer. + /// + /// The class has no finalizer, so this method is called with set to + /// only by a finalizer that a derived class declares. A derived class that owns unmanaged resources must declare its own finalizer that calls this + /// method with . + /// protected virtual void Dispose(bool disposing) { if (disposing) @@ -731,9 +876,7 @@ protected virtual void Dispose(bool disposing) /// /// The HTTP request message that was created. /// - /// This method is called to trigger the event. This allows for centralized - /// handling of request modifications across various methods that send HTTP requests. The method is invoked - /// before an is sent. + /// This method is called before each attempt of a request is sent, including retries. /// protected virtual void OnBeforeSendingRequest(HttpRequestMessage request) { @@ -745,9 +888,7 @@ protected virtual void OnBeforeSendingRequest(HttpRequestMessage request) /// /// The HTTP response message that was received. /// - /// This method is called to trigger the event. This allows to react to the - /// reception of an HTTP response. The method is invoked after a response is received from an HTTP request but - /// before any processing is performed on the response. + /// This method is called after a response is received and before the client processes it. /// protected virtual void OnAfterReceivingResponse(HttpResponseMessage response) { @@ -757,9 +898,9 @@ protected virtual void OnAfterReceivingResponse(HttpResponseMessage response) /// /// Raises the event. /// - /// This method is called as part of the disposal process of the instance, specifically - /// just before the client starts releasing its resources. It triggers the event, allowing - /// subscribed entities to perform any necessary cleanup actions before the client is fully disposed. + /// + /// This method is called when the client is disposed, before it releases its . + /// protected virtual void OnDisposing() { Disposing?.Invoke(this, EventArgs.Empty); diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 967f475..6774dc1 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -6,24 +6,29 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Content; + using Kampute.HttpClient.Interfaces; using System; using System.IO; + using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; /// - /// Provides extension methods for to facilitate sending HTTP requests using various methods, - /// including GET, POST, PUT, PATCH, and DELETE. + /// Provides extension methods for that send requests with the common HTTP methods. /// /// - /// This static class enriches by adding convenient extension methods for making HTTP requests. - /// These methods simplify the process of constructing and sending requests for common HTTP methods, enabling more readable - /// and concise client code. + /// The generic methods read the response body into the requested type with the content formatters of the client. The other methods read the + /// response body as a string, a byte array, or a stream, or not at all. /// public static class HttpRestClientExtensions { + /// + /// The buffer size used to copy a response body into a stream; the same default that uses. + /// + private const int CopyBufferSize = 81920; + /// /// Sends an asynchronous HEAD request to the specified URI and returns the response headers. /// @@ -33,13 +38,11 @@ public static class HttpRestClientExtensions /// A task representing the asynchronous operation, returning the response headers. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task HeadAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken).ConfigureAwait(false); - return response.Headers; + return ReadHeadersAsync(client.SendAsync(HttpVerb.Head, uri, payload: null, cancellationToken: cancellationToken)); } /// @@ -51,13 +54,11 @@ public static async Task HeadAsync(this HttpRestClient clie /// A task representing the asynchronous operation, returning the response headers. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task OptionsAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken).ConfigureAwait(false); - return response.Headers; + return ReadHeadersAsync(client.SendAsync(HttpVerb.Options, uri, payload: null, cancellationToken: cancellationToken)); } /// @@ -70,9 +71,9 @@ public static async Task OptionsAsync(this HttpRestClient c /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task GetAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken); @@ -87,12 +88,17 @@ public static async Task OptionsAsync(this HttpRestClient c /// A task representing the asynchronous operation, returning an array of bytes. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task GetAsByteArrayAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); - return response.Content is not null ? await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : []; + return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); + + static async Task ReadBodyAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Content is not null ? await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false) : []; + } } /// @@ -104,12 +110,17 @@ public static async Task GetAsByteArrayAsync(this HttpRestClient client, /// A task representing the asynchronous operation, returning a string. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task GetAsStringAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); - return response.Content is not null ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + return ReadBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken: cancellationToken)); + + static async Task ReadBodyAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Content is not null ? await response.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + } } /// @@ -121,23 +132,46 @@ public static async Task GetAsStringAsync(this HttpRestClient client, st /// A task that represents the asynchronous operation, returning a . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetAsStreamAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// + /// The task completes as soon as the response headers arrive. The response body is not buffered: the returned stream reads it from the network, + /// so the caller must dispose the stream to release the connection. Reading the stream can fail with an if the transfer fails. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes the stream. + /// + /// + public static Task GetAsStreamAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); - if (response.Content is not null) + return OpenBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken)); + + static async Task OpenBodyAsync(Task sending) { - // The response is intentionally not disposed to avoid disposal of the underlying stream. - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - } + var response = await sending.ConfigureAwait(false); + if (response.Content is not null) + { + try + { + // The caller owns the stream once it has been opened successfully. + return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch + { + response.Dispose(); + throw; + } + } - response.Dispose(); - return Stream.Null; + response.Dispose(); + return Stream.Null; + } } /// - /// Sends an asynchronous GET request to the specified URI and write the response body into the provided . + /// Sends an asynchronous GET request to the specified URI and writes the response body into the provided . /// /// The instance to be used for sending the request. /// The URI to which the request is sent. @@ -146,16 +180,35 @@ public static async Task GetAsStreamAsync(this HttpRestClient client, st /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task GetToStreamAsync(this HttpRestClient client, string uri, Stream stream, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if transferring the response body fails, or writing to fails. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// + /// The response body is not buffered: it is copied from the network into as it arrives, and the cancellation token + /// can stop the copy. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes it, and nothing is copied. + /// + /// + public static Task GetToStreamAsync(this HttpRestClient client, string uri, Stream stream, CancellationToken cancellationToken = default) { if (stream is null) throw new ArgumentNullException(nameof(stream)); - using var response = await client.SendAsync(HttpVerb.Get, uri, payload: null, cancellationToken).ConfigureAwait(false); - if (response.Content is not null) - await response.Content.CopyToAsync(stream).ConfigureAwait(false); + return CopyBodyAsync(client.SendAsync(HttpVerb.Get, uri, payload: null, HttpCompletionOption.ResponseHeadersRead, cancellationToken), stream, cancellationToken); + + static async Task CopyBodyAsync(Task sending, Stream stream, CancellationToken cancellationToken) + { + using var response = await sending.ConfigureAwait(false); + if (response.Content is not null) + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } + } } /// @@ -169,9 +222,9 @@ public static async Task GetToStreamAsync(this HttpRestClient client, string uri /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken); @@ -187,12 +240,11 @@ public static async Task GetToStreamAsync(this HttpRestClient client, string uri /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task PostAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Post, uri, payload, cancellationToken: cancellationToken)); } /// @@ -206,12 +258,12 @@ public static async Task PostAsync(this HttpRestClient client, string uri, HttpC /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - return client.SendAsync(HttpMethod.Put, uri, payload, cancellationToken); + return client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken); } /// @@ -224,12 +276,11 @@ public static async Task PostAsync(this HttpRestClient client, string uri, HttpC /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Put, uri, payload, cancellationToken: cancellationToken)); } /// @@ -243,9 +294,9 @@ public static async Task PutAsync(this HttpRestClient client, string uri, HttpCo /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken); @@ -261,12 +312,11 @@ public static async Task PutAsync(this HttpRestClient client, string uri, HttpCo /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task PatchAsync(this HttpRestClient client, string uri, HttpContent? payload, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken).ConfigureAwait(false); + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Patch, uri, payload, cancellationToken: cancellationToken)); } /// @@ -279,9 +329,9 @@ public static async Task PatchAsync(this HttpRestClient client, string uri, Http /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { return client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken); @@ -296,12 +346,120 @@ public static async Task PatchAsync(this HttpRestClient client, string uri, Http /// A task that represents the asynchronous operation. /// Thrown if is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + { + return ReleaseResponseAsync(client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken: cancellationToken)); + } + + /// + /// Sends an asynchronous request whose payload is written in the specified media type by a registered content formatter, and returns the + /// response body deserialized as the specified type. + /// + /// The type of the response object. + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The media type in which to write . + /// A token for canceling the request (optional). + /// A task representing the asynchronous operation, returning a deserialized object of type . + /// Thrown if , , , or is . + /// Thrown if no formatter in can write in . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// The payload is written by the first formatter in that can write its type in . + /// The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType, CancellationToken cancellationToken = default) { - using var _ = await client.SendAsync(HttpVerb.Delete, uri, payload: null, cancellationToken).ConfigureAwait(false); + var content = CreateContent(client, method, uri, payload, mediaType); + return client.SendAsync(method, uri, content, cancellationToken); + } + + /// + /// Sends an asynchronous request whose payload is written in the specified media type by a registered content formatter, without processing the + /// response body. + /// + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The media type in which to write . + /// A token for canceling the request (optional). + /// A task that represents the asynchronous operation. + /// Thrown if , , , or is . + /// Thrown if no formatter in can write in . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// The payload is written by the first formatter in that can write its type in . + /// The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, mediaType); + return ReleaseResponseAsync(client.SendAsync(method, uri, content, cancellationToken: cancellationToken)); + } + + /// + /// Sends an asynchronous request whose payload is written by the specified content formatter, and returns the response body deserialized as the + /// specified type. + /// + /// The type of the response object. + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The formatter that writes , in the first media type it can write for the type of the payload. + /// A token for canceling the request (optional). + /// A task representing the asynchronous operation, returning a deserialized object of type . + /// Thrown if , , , or is . + /// Thrown if cannot write the type of . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the response body is empty or its media type is not supported. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// The formatters in are not consulted to write the payload, so the payload is written by + /// even when another formatter is registered for the same media type. The response is still read by the registered + /// formatters. The argument exceptions and the are thrown when the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, formatter); + return client.SendAsync(method, uri, content, cancellationToken); + } + + /// + /// Sends an asynchronous request whose payload is written by the specified content formatter, without processing the response body. + /// + /// The instance to be used for sending the request. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The object to send as the request payload. An is sent as it is. + /// The formatter that writes , in the first media type it can write for the type of the payload. + /// A token for canceling the request (optional). + /// A task that represents the asynchronous operation. + /// Thrown if , , , or is . + /// Thrown if cannot write the type of . + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// The formatters in are not consulted, so the payload is written by even + /// when another formatter is registered for the same media type. The argument exceptions and the are thrown when + /// the method is called, before anything is sent. + /// + public static Task SendObjectAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter, CancellationToken cancellationToken = default) + { + var content = CreateContent(client, method, uri, payload, formatter); + return ReleaseResponseAsync(client.SendAsync(method, uri, content, cancellationToken: cancellationToken)); } /// @@ -319,9 +477,21 @@ public static async Task DeleteAsync(this HttpRestClient client, string uri, Can /// Thrown if , , or is . /// Thrown if returns . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task DownloadAsync + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if transferring the response body fails, or writing to the stream from fails. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + /// + /// + /// The response body is not buffered: it is copied from the network into the stream returned by as it + /// arrives, and the cancellation token can stop the copy. If the copy fails or is canceled, that stream is disposed before the exception + /// is rethrown. + /// + /// + /// covers only the time until the response headers arrive. Because the body is not buffered, + /// an handler that reads the response content consumes it, and nothing is copied. + /// + /// + public static Task DownloadAsync ( this HttpRestClient client, HttpMethod method, @@ -338,23 +508,125 @@ public static async Task DownloadAsync if (streamProvider is null) throw new ArgumentNullException(nameof(streamProvider)); - using var response = await client.SendAsync(method, uri, payload, cancellationToken).ConfigureAwait(false); - response.Content ??= new EmptyContent(); + return CopyBodyAsync(client.SendAsync(method, uri, payload, HttpCompletionOption.ResponseHeadersRead, cancellationToken), streamProvider, cancellationToken); - var stream = streamProvider(response.Content.Headers) ?? throw new InvalidOperationException("The stream provider must not return null."); - await response.Content.CopyToAsync(stream).ConfigureAwait(false); - return stream; + static async Task CopyBodyAsync(Task sending, Func streamProvider, CancellationToken cancellationToken) + { + using var response = await sending.ConfigureAwait(false); + response.Content ??= new EmptyContent(); + + var stream = streamProvider(response.Content.Headers) ?? throw new InvalidOperationException("The stream provider must not return null."); + try + { + using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await body.CopyToAsync(stream, CopyBufferSize, cancellationToken).ConfigureAwait(false); + } + catch + { + stream.Dispose(); + throw; + } + return stream; + } } /// - /// Creates a new for managing scoped modifications of properties and headers for HTTP requests sent using the . + /// Starts a fluent definition of headers and request properties that apply to the requests of one operation. /// /// The instance for which the scope is created. - /// An instance of that allows properties and headers to be temporarily modified for requests made through the client. - /// Thrown if the argument is >. + /// A new for . + /// Thrown if the argument is . public static HttpRequestScope WithScope(this HttpRestClient client) { return new HttpRequestScope(client); } + + /// + /// Waits for a request to complete and disposes of its response. + /// + /// The task of the request that is being sent. + /// A task that represents the asynchronous operation. + internal static async Task ReleaseResponseAsync(Task sending) + { + using var _ = await sending.ConfigureAwait(false); + } + + /// + /// Validates the arguments of a SendObjectAsync call and creates the request content with the registered formatter for the media type. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + /// The media type in which to write . + /// The request content. + private static HttpContent CreateContent(HttpRestClient client, HttpMethod method, string uri, object payload, string mediaType) + { + ValidateSendArguments(client, method, uri, payload); + if (mediaType is null) + throw new ArgumentNullException(nameof(mediaType)); + + if (payload is HttpContent content) + return content; + + var formatter = client.ContentFormatters.GetWriterFor(mediaType, payload.GetType()) + ?? throw new InvalidOperationException($"No content formatter of the client can write an object of type '{payload.GetType()}' as '{mediaType}'."); + + return formatter.Write(payload, mediaType); + } + + /// + /// Validates the arguments of a SendObjectAsync call and creates the request content with the specified formatter. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + /// The formatter that writes . + /// The request content. + private static HttpContent CreateContent(HttpRestClient client, HttpMethod method, string uri, object payload, IHttpContentFormatter formatter) + { + ValidateSendArguments(client, method, uri, payload); + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + if (payload is HttpContent content) + return content; + + var mediaType = formatter.GetWritableMediaTypes(payload.GetType()).FirstOrDefault() + ?? throw new InvalidOperationException($"{formatter.GetType().Name} cannot write an object of type '{payload.GetType()}'."); + + return formatter.Write(payload, mediaType); + } + + /// + /// Validates the arguments shared by all SendObjectAsync overloads. + /// + /// The client that sends the request. + /// The HTTP method of the request. + /// The URI of the request. + /// The object to send. + private static void ValidateSendArguments(HttpRestClient client, HttpMethod method, string uri, object payload) + { + if (client is null) + throw new ArgumentNullException(nameof(client)); + if (method is null) + throw new ArgumentNullException(nameof(method)); + if (uri is null) + throw new ArgumentNullException(nameof(uri)); + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + } + + /// + /// Waits for a request to complete, disposes of its response, and returns the response headers. + /// + /// The task of the request that is being sent. + /// A task that represents the asynchronous operation, returning the response headers. + private static async Task ReadHeadersAsync(Task sending) + { + using var response = await sending.ConfigureAwait(false); + return response.Headers; + } } } diff --git a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs index 358626f..faf4ab4 100644 --- a/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientFormExtensions.cs @@ -5,6 +5,7 @@ namespace Kampute.HttpClient { + using Kampute.HttpClient.Content; using System; using System.Collections.Generic; using System.Net.Http; @@ -12,11 +13,10 @@ namespace Kampute.HttpClient using System.Threading.Tasks; /// - /// Provides extension methods for to support sending HTTP requests with URL-encoded form content. + /// Provides extension methods for that send key-value pairs as application/x-www-form-urlencoded content. /// /// - /// This static class extends functionality by adding methods for sending HTTP requests with content - /// type 'application/x-www-form-urlencoded'. + /// The methods write the payload with the of the client, or with a new one if none is registered. /// public static class HttpRestClientFormExtensions { @@ -32,9 +32,9 @@ public static class HttpRestClientFormExtensions /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsFormAsync ( this HttpRestClient client, @@ -44,14 +44,11 @@ public static Task SendAsFormAsync CancellationToken cancellationToken = default ) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken); + return client.SendObjectAsync(method, uri, payload, client.ContentFormatters.FindOrDefault(), cancellationToken); } /// - /// Sends an asynchronous POST request with URL-encoded form content to the specified URI without processing the response body. + /// Sends an asynchronous request with URL-encoded form content to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -61,9 +58,8 @@ public static Task SendAsFormAsync /// A task representing the asynchronous operation. /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task SendAsFormAsync ( this HttpRestClient client, @@ -73,11 +69,7 @@ public static Task SendAsFormAsync CancellationToken cancellationToken = default ) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return client.SendAsync(method, uri, new FormUrlEncodedContent(payload), cancellationToken) - .ContinueWith(task => task.Result.Dispose(), TaskContinuationOptions.OnlyOnRanToCompletion); + return client.SendObjectAsync(method, uri, payload, client.ContentFormatters.FindOrDefault(), cancellationToken); } /// @@ -91,9 +83,9 @@ public static Task SendAsFormAsync /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsFormAsync ( this HttpRestClient client, @@ -115,9 +107,8 @@ public static Task PostAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsFormAsync ( this HttpRestClient client, @@ -140,9 +131,9 @@ public static Task PostAsFormAsync /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsFormAsync ( this HttpRestClient client, @@ -164,9 +155,8 @@ public static Task PutAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsFormAsync ( this HttpRestClient client, @@ -189,9 +179,9 @@ public static Task PutAsFormAsync /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsFormAsync ( this HttpRestClient client, @@ -213,9 +203,8 @@ public static Task PatchAsFormAsync /// A task that represents the asynchronous operation. /// Thrown if or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsFormAsync ( this HttpRestClient client, diff --git a/src/Kampute.HttpClient/HttpRetryPolicy.cs b/src/Kampute.HttpClient/HttpRetryPolicy.cs new file mode 100644 index 0000000..da4e433 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRetryPolicy.cs @@ -0,0 +1,130 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; + using System; + + /// + /// Retries failed HTTP requests as a retry strategy decides. + /// + /// + /// + /// Create a policy from any with . Each call that fails gets + /// its own , so the strategy, and the policy, can be shared by any number of requests and clients. + /// + /// + /// To choose the strategy or the session from the failure, use or + /// . + /// + /// + /// + /// This policy retries a request up to five times after connection failures, with delays that grow with the Fibonacci sequence from one second: + /// + /// client.RetryPolicy = RetryStrategies.Fibonacci(TimeSpan.FromSeconds(1)).WithMaxRetries(5).ToHttpRetryPolicy(); + /// + /// + /// + public class HttpRetryPolicy : IHttpRetryPolicy + { + /// + /// Initializes a new instance of the class with the specified retry strategy. + /// + /// The retry strategy that decides whether and when a failed request is retried. + /// Thrown if is . + public HttpRetryPolicy(IRetryStrategy strategy) + { + Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); + } + + /// + /// Gets a policy that never retries. + /// + /// + /// A policy based on . + /// + public static HttpRetryPolicy None { get; } = new(RetryStrategies.None); + + /// + /// Gets the retry strategy of this policy. + /// + /// + /// The that decides whether and when a failed request is retried. + /// + public virtual IRetryStrategy Strategy { get; } + + /// + /// Creates a retry session that applies the strategy of this policy to a failed request. + /// + /// The context of the failure. + /// A new for . + /// Thrown if is . + public virtual IRetrySession CreateSession(HttpRequestErrorContext ctx) + { + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + return Strategy.StartSession(); + } + + /// + /// Creates a policy that chooses the retry strategy for each failed request. + /// + /// A function that returns the retry strategy for the context of a failure. + /// A policy that starts a session with the strategy that returns. + /// Thrown if is . + /// + /// If returns , creating the session throws . + /// + public static IHttpRetryPolicy Dynamic(Func strategyFactory) + { + if (strategyFactory is null) + throw new ArgumentNullException(nameof(strategyFactory)); + + return new DynamicPolicy(ctx => + { + var strategy = strategyFactory(ctx) ?? throw new InvalidOperationException("The strategy factory function returned null."); + return strategy.StartSession(); + }); + } + + /// + /// Creates a policy that creates the retry session for each failed request. + /// + /// A function that returns the retry session for the context of a failure. + /// A policy that uses the session that returns. + /// Thrown if is . + /// + /// If returns , creating the session throws . + /// + public static IHttpRetryPolicy Dynamic(Func sessionFactory) + { + if (sessionFactory is null) + throw new ArgumentNullException(nameof(sessionFactory)); + + return new DynamicPolicy(ctx => sessionFactory(ctx) ?? throw new InvalidOperationException("The session factory function returned null.")); + } + + /// + /// A policy that delegates the creation of each session to a function. + /// + private sealed class DynamicPolicy : IHttpRetryPolicy + { + private readonly Func _sessionFactory; + + public DynamicPolicy(Func sessionFactory) => _sessionFactory = sessionFactory; + + public IRetrySession CreateSession(HttpRequestErrorContext ctx) + { + if (ctx is null) + throw new ArgumentNullException(nameof(ctx)); + + return _sessionFactory(ctx); + } + } + } +} diff --git a/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs new file mode 100644 index 0000000..46e6b30 --- /dev/null +++ b/src/Kampute.HttpClient/HttpRetryPolicyExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.Resilience; + using System; + + /// + /// Provides extension methods that use an for retrying HTTP requests. + /// + public static class HttpRetryPolicyExtensions + { + /// + /// Creates an HTTP retry policy that retries failed requests as the strategy decides. + /// + /// The retry strategy of the policy. + /// A new for . + /// Thrown if is . + public static HttpRetryPolicy ToHttpRetryPolicy(this IRetryStrategy source) => new(source); + } +} diff --git a/src/Kampute.HttpClient/HttpRetryState.cs b/src/Kampute.HttpClient/HttpRetryState.cs new file mode 100644 index 0000000..276ce9b --- /dev/null +++ b/src/Kampute.HttpClient/HttpRetryState.cs @@ -0,0 +1,52 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient +{ + using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; + using System; + using System.Collections.Generic; + + /// + /// Holds the retry sessions of one call to an , shared by every attempt of that call. + /// + /// + /// + /// The creates one instance for each call it sends, and passes it to every error context created for that call. + /// Each component that handles a kind of failure, such as the client for connection failures or an for error + /// responses, has its own retry session in this state, which counts its retries and measures the time since its first failure. Because the + /// state belongs to the call rather than to a request, the sessions are kept even when an error handler retries with a request it built itself + /// instead of a clone of the failed request. + /// + /// + /// Code that creates an or outside the client, such as a unit test + /// of a custom error handler, creates a new instance for each call it simulates. An instance is not thread-safe, and must not be shared by calls + /// that run concurrently. + /// + /// + /// + public sealed class HttpRetryState + { + private readonly Dictionary _sessions = []; + + /// + /// Returns the retry session of the specified source, creating it on first use. + /// + /// The component that handles the failure. + /// The function that creates the session, or returns if the source does not retry. + /// The session of , or if the source does not retry. + internal IRetrySession? GetOrCreateSession(object source, Func sessionFactory) + { + if (!_sessions.TryGetValue(source, out var session)) + { + session = sessionFactory(); + _sessions[source] = session; + } + + return session; + } + } +} diff --git a/src/Kampute.HttpClient/HttpVerb.cs b/src/Kampute.HttpClient/HttpVerb.cs index 31d0db0..f1aeca7 100644 --- a/src/Kampute.HttpClient/HttpVerb.cs +++ b/src/Kampute.HttpClient/HttpVerb.cs @@ -6,88 +6,55 @@ namespace Kampute.HttpClient { /// - /// A helper class for retrieving the standard HTTP methods. + /// Provides the standard HTTP methods on every target framework. /// /// - /// This class supplements the standard class with additional, commonly - /// used HTTP methods that are not covered by the .NET Standard 2.0 specification. + /// has no Patch property on .NET Standard 2.0; this class provides it there too. /// public static class HttpVerb { /// - /// Represents an HTTP DELETE protocol method. + /// The DELETE method, which removes the target resource. /// - /// - /// The DELETE method requests that the target resource be removed. It is used to delete a resource identified - /// by a URI. - /// public readonly static System.Net.Http.HttpMethod Delete = System.Net.Http.HttpMethod.Delete; /// - /// Represents an HTTP GET protocol method. + /// The GET method, which retrieves a representation of the target resource without changing it. /// - /// - /// The GET method requests a representation of the specified resource. Requests using GET should only retrieve - /// data and should have no other effect. - /// public readonly static System.Net.Http.HttpMethod Get = System.Net.Http.HttpMethod.Get; /// - /// Represents an HTTP HEAD protocol method. + /// The HEAD method, which is identical to GET except that the response has no body. /// - /// - /// The HEAD method is identical to GET except that the server responds with headers only and no message body. - /// It is often used for testing hypertext links for validity, accessibility, and recent modification. - /// public readonly static System.Net.Http.HttpMethod Head = System.Net.Http.HttpMethod.Head; /// - /// Represents an HTTP OPTIONS protocol method. + /// The OPTIONS method, which asks for the communication options of the target resource, such as the methods it supports. /// - /// - /// The OPTIONS method describes the communication options for the target resource. It can be used to query - /// the server for supported HTTP methods and other options, without implying a resource action. - /// public readonly static System.Net.Http.HttpMethod Options = System.Net.Http.HttpMethod.Options; /// - /// Represents an HTTP PATCH protocol method. + /// The PATCH method, which applies a partial update to the target resource. /// - /// - /// The PATCH method applies partial modifications to a resource. It is used to make a partial update on a resource, - /// in contrast to PUT which typically requires a complete resource representation. - /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public readonly static System.Net.Http.HttpMethod Patch = System.Net.Http.HttpMethod.Patch; #else public readonly static System.Net.Http.HttpMethod Patch = new("PATCH"); #endif /// - /// Represents an HTTP POST protocol method. + /// The POST method, which submits the payload to the target resource for processing, such as to create a resource. /// - /// - /// The POST method is used to submit an entity to the specified resource, often causing a change in state or side - /// effects on the server. - /// public readonly static System.Net.Http.HttpMethod Post = System.Net.Http.HttpMethod.Post; /// - /// Represents an HTTP PUT protocol method. + /// The PUT method, which replaces the target resource with the payload. /// - /// - /// The PUT method replaces all current representations of the target resource with the request payload. It is - /// used to update a resource entirely. - /// public readonly static System.Net.Http.HttpMethod Put = System.Net.Http.HttpMethod.Put; /// - /// Represents an HTTP TRACE protocol method. + /// The TRACE method, which asks the server to echo the request it received, for diagnostics. /// - /// - /// The TRACE method performs a message loop-back test along the path to the target resource, providing a useful - /// debugging mechanism. - /// public readonly static System.Net.Http.HttpMethod Trace = System.Net.Http.HttpMethod.Trace; } } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs b/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs deleted file mode 100644 index 405e2f1..0000000 --- a/src/Kampute.HttpClient/Interfaces/IHttpBackoffProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using System; - - /// - /// Defines a contract for creating retry schedulers tailored to specific retry strategies for HTTP requests. - /// - /// - /// This interface allows for the implementation of various retry strategies tailored to HTTP communications, such as fixed delay, exponential backoff, - /// or adaptive strategies. It primarily focuses on generating schedulers that determine the timing and conditions for retry attempts based on the nature o - /// f HTTP request failures. - /// - public interface IHttpBackoffProvider - { - /// - /// Creates a scheduler responsible for managing retry attempts for HTTP requests, based on a specified retry strategy. - /// - /// Provides context containing detailed information about the failed HTTP request, including client, request, and error specifics. - /// An instance of that coordinates the retry attempts for the given context according to the defined strategy. - /// Thrown if is . - IRetryScheduler CreateScheduler(HttpRequestErrorContext ctx); - } -} diff --git a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs b/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs deleted file mode 100644 index ebff42b..0000000 --- a/src/Kampute.HttpClient/Interfaces/IHttpContentDeserializer.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using System; - using System.Collections.Generic; - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Defines the functionality for deserializing an object from the HTTP request body. - /// - /// - /// - /// The interface is designed for the purpose of abstracting the mechanism of deserializing data - /// from HTTP responses into .NET objects. Implementers of this interface provide the logic necessary to convert HTTP content, identified - /// by a specific media type, into instances of model types used within an application. - /// - /// - /// The method is designed to communicate the content types that the deserializer can process, effectively - /// informing the server about the media types acceptable to the client. It ensures that the client and server can agree on a common format for - /// data exchange, enhancing the efficiency and compatibility of HTTP communications. - /// - /// - /// Through the method, implementations can provide a quick check to ascertain compatibility between the deserializer, - /// the media type of the content, and the target model type. This check is typically performed before attempting deserialization to ensure that - /// the deserializer is capable of processing the content as expected. - /// - /// - /// The asynchronous method forms the core of the interface, where the actual deserialization logic is implemented. - /// This method takes , along with the target model type, and returns a task that, when completed, yields the deserialized - /// object. Implementations must handle the asynchronous nature of this operation, catering to potential cancellation requests through the - /// provided provided by the parameter. - /// - /// - /// The implementations of should be thread-safe and reusable across multiple deserialization operations to - /// facilitate efficient processing of HTTP response content in a concurrent environment. - /// - /// - /// - public interface IHttpContentDeserializer - { - /// - /// Retrieves a collection of supported media types for a specific model type. - /// - /// The type of the model for which to retrieve supported media types. - /// An enumerable of strings representing the media types supported for the specified model type. - IEnumerable GetSupportedMediaTypes(Type modelType); - - /// - /// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type. - /// - /// The media type of the content. - /// The type of the model to be deserialized. - /// if this deserializer can handle the specified content type and model type; otherwise, . - bool CanDeserialize(string mediaType, Type modelType); - - /// - /// Asynchronously deserializes an object from the provided . - /// - /// The from which to deserialize the data. - /// The type of the object to be deserialized. - /// A token for canceling the operation (optional). - /// A task representing the asynchronous deserialization operation. Contains the deserialized object. - /// Thrown if or is . - Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); - } -} diff --git a/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs b/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs new file mode 100644 index 0000000..dc6ed29 --- /dev/null +++ b/src/Kampute.HttpClient/Interfaces/IHttpContentFormatter.cs @@ -0,0 +1,87 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Interfaces +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Defines a content format that converts between HTTP content and .NET objects. + /// + /// + /// + /// A formatter has a reading side and a writing side. The reading side turns response content into objects: + /// lists the media types the formatter can read into a model type, which uses to build the Accept header, and + /// and select the formatter for a response and read its content. The writing side turns objects into + /// request content: and select the formatter for a payload, and creates + /// the content. + /// + /// + /// A formatter can support one side only. A receive-only formatter returns no writable media types and never accepts a payload, and a send-only + /// formatter returns no readable media types and never accepts a response. + /// + /// + /// Media types are case-insensitive, so implementations compare them ignoring case. Implementations are expected to be thread-safe, because a + /// formatter registered with a client is shared by all its requests. + /// + /// + /// + public interface IHttpContentFormatter + { + /// + /// Returns the media types that this formatter can read into the specified model type. + /// + /// The type of the object to read. + /// The media types that this formatter can read into , in order of preference; empty if there are none. + IEnumerable GetReadableMediaTypes(Type modelType); + + /// + /// Determines whether this formatter can read content of the specified media type into the specified model type. + /// + /// The media type of the content. + /// The type of the object to read. + /// if this formatter can read the content; otherwise, . + bool CanRead(string mediaType, Type modelType); + + /// + /// Asynchronously reads an object of the specified type from HTTP content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation (optional). + /// A task that resolves to the object read from . + /// Thrown if or is . + Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default); + + /// + /// Returns the media types in which this formatter can write a payload of the specified type. + /// + /// The type of the payload to write. + /// The media types in which this formatter can write , in order of preference; empty if there are none. + IEnumerable GetWritableMediaTypes(Type payloadType); + + /// + /// Determines whether this formatter can write a payload of the specified type in the specified media type. + /// + /// The media type of the content to create. + /// The type of the payload to write. + /// if this formatter can write the payload; otherwise, . + bool CanWrite(string mediaType, Type payloadType); + + /// + /// Creates HTTP content that carries the specified payload in the specified media type. + /// + /// The object to write. + /// The media type of the content to create. + /// The that carries . + /// Thrown if or is . + /// Thrown if this formatter cannot write in . + HttpContent Write(object payload, string mediaType); + } +} diff --git a/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs b/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs index c392a8b..45a1b0a 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpErrorHandler.cs @@ -11,21 +11,17 @@ namespace Kampute.HttpClient.Interfaces using System.Threading.Tasks; /// - /// Defines a contract for handling HTTP error status codes and determining retry logic in HTTP requests. + /// Defines a handler that decides whether to retry a request after an error response. /// /// /// - /// This interface provides a mechanism to extend the retry logic of the . By implementing this interface, - /// consumers of the client can implement custom logic to evaluate failure responses and decide whether to attempt a retry. + /// When a request receives an error response, asks the handlers in whose + /// accepts the status code, in order, until one of them returns a request to retry. If none does, the error is thrown + /// as an . A handler can retry a clone of the failed request or a request it builds, for example one with + /// new credentials after a '401 Unauthorized' response. /// /// - /// Implementors can define their own strategies for handling specific HTTP error statuses, such as '401 Unauthorized' for re-authentication, - /// '429 Too Many Requests' for rate limit handling, or '503 Service Unavailable' for backoff and retry. This flexible approach allows for - /// sophisticated error handling and recovery mechanisms, tailored to the requirements of the application. - /// - /// - /// The implementations of should be thread-safe and reusable across multiple error handling operations to - /// facilitate efficient processing of HTTP responses in a concurrent environment. + /// A handler is shared by all the requests of the clients it is added to, so implementations must be thread-safe. /// /// /// @@ -35,15 +31,18 @@ public interface IHttpErrorHandler /// Determines whether the handler is capable of handling the provided HTTP status code. /// /// The HTTP status code to evaluate. - /// if the handler can handle the specified status code; otherwise, . + /// if the handler can handle the specified status code; otherwise, . bool CanHandle(HttpStatusCode statusCode); /// - /// Evaluates whether a failed request should be retried based on the error context. + /// Decides whether to retry a request after an error response. /// /// The context containing information about the HTTP response that indicates a failure. /// A token for canceling the operation. - /// A task that resolves to an . + /// + /// A task that resolves to with the request to send, or to + /// to let the next handler decide. + /// /// Thrown if is . Task DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken); } diff --git a/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs b/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs index 5e0f7f7..505760b 100644 --- a/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs +++ b/src/Kampute.HttpClient/Interfaces/IHttpErrorResponse.cs @@ -8,12 +8,11 @@ namespace Kampute.HttpClient.Interfaces using System.Net; /// - /// Defines an interface for handling HTTP error responses and converting them into a . + /// Defines an error model that creates the exception for an error response. /// /// - /// This interface is especially beneficial in RESTful operation contexts where the server provides error details in a - /// distinct format. It facilitates the conversion of these details into a structured , - /// thereby enhancing error handling and its integration into client-side logic. + /// Implement this interface on the type assigned to to turn the error details that an API returns, + /// such as a message or validation errors, into the that the client throws. /// public interface IHttpErrorResponse { diff --git a/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs new file mode 100644 index 0000000..5d3f220 --- /dev/null +++ b/src/Kampute.HttpClient/Interfaces/IHttpRetryPolicy.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Interfaces +{ + using Kampute.Resilience; + using System; + + /// + /// Defines how failed HTTP requests are retried. + /// + /// + /// A policy creates a retry session for a call when its request first fails in a way the policy covers. The session decides, for that failure + /// and the later ones of the same kind during the call, whether and when the request is retried. creates sessions from an + /// , and chooses the strategy or the + /// session from the failure. + /// + /// + public interface IHttpRetryPolicy + { + /// + /// Creates the retry session for a failed HTTP request. + /// + /// The context of the failure, with the client, the request and the error. + /// The that decides whether and when the request is retried. + /// Thrown if is . + IRetrySession CreateSession(HttpRequestErrorContext ctx); + } +} diff --git a/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs b/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs deleted file mode 100644 index 26e8101..0000000 --- a/src/Kampute.HttpClient/Interfaces/IRetryScheduler.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.Interfaces -{ - using System.Threading; - using System.Threading.Tasks; - - /// - /// Defines a contract for scheduling retry attempts for an operation. This includes determining if a retry is appropriate - /// and managing the waiting period before the next attempt. - /// - public interface IRetryScheduler - { - /// - /// Waits for the appropriate time before the next retry attempt, according to the scheduler's strategy, and determines if a retry should - /// be attempted. - /// - /// A token that can be used to cancel the wait operation. - /// A task that resolves to if a retry should be attempted; otherwise, . - /// - /// - /// The asynchronous nature of the method allows implementations to incorporate not just simple time-based waiting but also - /// more complex logic, such as querying external services for guidance on when to retry or dynamically adjusting backoff - /// parameters in response to system load. - /// - /// - /// Implementations should respect the provided to ensure that the application remains responsive, - /// especially during shutdown sequences or when operations need to be canceled prematurely. - /// - /// - Task WaitAsync(CancellationToken cancellationToken); - } -} diff --git a/src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs b/src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs deleted file mode 100644 index edec1ba..0000000 --- a/src/Kampute.HttpClient/Interfaces/IRetryStrategy.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Kampute.HttpClient.Interfaces -{ - using System; - - /// - /// Defines a strategy for calculating the delay duration between retry attempts based on elapsed time and the number of attempts already made. - /// - public interface IRetryStrategy - { - /// - /// Calculates the delay duration for the next retry attempt and indicates whether a retry should be attempted. - /// - /// The total time elapsed since the start of retry attempts. - /// The count of retry attempts made so far. - /// When this method returns, contains the calculated delay duration for the next retry attempt, if a retry is advisable. This parameter is passed uninitialized. - /// if a retry attempt is advisable and should be made after the calculated delay; otherwise, indicating no further retry attempts should be made. - bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay); - } -} diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 58492e6..7ab089b 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -1,11 +1,11 @@  - netstandard2.0;netstandard2.1 + netstandard2.0;net10.0 Kampute.HttpClient - Kampute.HttpClient is a versatile and lightweight .NET library that simplifies RESTful API communication. Its core HttpRestClient class provides a streamlined approach to HTTP interactions, offering advanced features such as flexible serialization/deserialization, robust error handling, configurable backoff strategies, and detailed request-response processing. Striking a balance between simplicity and extensibility, Kampute.HttpClient empowers developers with a powerful yet easy-to-use client for seamless API integration across a wide range of .NET applications. + Kampute.HttpClient is a versatile and lightweight .NET library that simplifies RESTful API communication. Its core HttpRestClient class provides a streamlined approach to HTTP interactions, offering advanced features such as two-way content formatters with built-in XML support, robust error handling, configurable retry policies, and detailed request-response processing. Striking a balance between simplicity and extensibility, Kampute.HttpClient empowers developers with a powerful yet easy-to-use client for seamless API integration across a wide range of .NET applications. Kambiz Khojasteh - 2.5.1 + 3.0.0 Kampute Copyright (c) 2025 Kampute latest @@ -32,6 +32,10 @@ ../../SigningKey.snk + + + + diff --git a/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs b/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs index 961a9e2..f86a2a4 100644 --- a/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs +++ b/src/Kampute.HttpClient/MediaTypeHeaderValueStore.cs @@ -5,24 +5,24 @@ using System.Net.Http.Headers; /// - /// Provides a cache for instances to improve performance by reusing instances for - /// frequently requested media types and quality settings. + /// Provides shared instances for Accept headers, so that a value is not parsed again for every + /// request. /// public static class MediaTypeHeaderValueStore { /// - /// Retrieves a from the cache or creates a new one if it does not exist. + /// Returns the shared header value of a media type without a quality factor. /// - /// The media type as a string. - /// A corresponding to the specified media type. + /// The media type, such as application/json. + /// The shared of . public static MediaTypeWithQualityHeaderValue Get(string mediaType) => WithoutQuality.Store.Get(mediaType); /// - /// Retrieves a from the cache or creates a new one if it does not exist. + /// Returns the shared header value of a media type with a quality factor. /// - /// The media type as a string. - /// The quality factor associated with this media type, expressed as a value between 0 and 1. - /// A corresponding to the specified media type and quality factor. + /// The media type, such as application/json. + /// The quality factor, from 0 to 1. + /// The shared of and . public static MediaTypeWithQualityHeaderValue Get(string mediaType, float quality) => WithQuality.Store.Get((mediaType, quality)); /// diff --git a/src/Kampute.HttpClient/MediaTypeNames.cs b/src/Kampute.HttpClient/MediaTypeNames.cs index 333eb60..08327da 100644 --- a/src/Kampute.HttpClient/MediaTypeNames.cs +++ b/src/Kampute.HttpClient/MediaTypeNames.cs @@ -6,11 +6,10 @@ namespace Kampute.HttpClient { /// - /// Provides constants for common media type names used in MIME content types. + /// Provides the names of common media types. /// /// - /// This class supplements the standard with additional, commonly - /// used media types that are not covered by the .NET Standard 2.0 specification. + /// This class covers more media types than , which has fewer of them on .NET Standard 2.0. /// public static class MediaTypeNames { @@ -47,7 +46,7 @@ public static class Application /// /// Media type name for XML data. /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public const string Xml = System.Net.Mime.MediaTypeNames.Application.Xml; #else public const string Xml = "application/xml"; @@ -56,7 +55,7 @@ public static class Application /// /// Media type name for JSON data. /// -#if NETSTANDARD2_1_OR_GREATER +#if !NETSTANDARD2_0 public const string Json = System.Net.Mime.MediaTypeNames.Application.Json; #else public const string Json = "application/json"; diff --git a/src/Kampute.HttpClient/NamespaceDoc.cs b/src/Kampute.HttpClient/NamespaceDoc.cs index 7e3b069..871158b 100644 --- a/src/Kampute.HttpClient/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/NamespaceDoc.cs @@ -6,7 +6,7 @@ namespace Kampute.HttpClient { /// - /// This namespace contains classes and methods for making HTTP requests and handling responses. + /// This namespace contains , its request helpers, retry policies, and the types that describe failures. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/README.md b/src/Kampute.HttpClient/README.md index d0e07ff..b369766 100644 --- a/src/Kampute.HttpClient/README.md +++ b/src/Kampute.HttpClient/README.md @@ -1,260 +1,34 @@ # Kampute.HttpClient -`Kampute.HttpClient` is a .NET library designed to simplify HTTP communication with RESTful APIs by enhancing the native `HttpClient` capabilities. -Tailored for developers seeking a potent yet flexible HTTP client for API integration within .NET applications, it combines ease of use with a wide -array of functionalities to address the complexities of web service consumption. +A .NET REST client built on `HttpClient`, with shared connections, scoped headers and properties, configurable retries, error handlers, and request/response events. -## Key Features - -- **Shared HttpClient Instances:** - Facilitates the reuse of a single `HttpClient` instance across multiple `HttpRestClient` instances, promoting efficient resource and connection - management. This approach significantly boosts performance in scenarios involving concurrent access to multiple services or API endpoints. - -- **Flexible HttpClient Configuration:** - Allows the integration of custom or shared `HttpClient` instances, complete with configurations for message handlers, timeouts, and advanced authentication - mechanisms to fit specific application needs. - -- **Dynamic Request Customization:** - Offers the capability to define request headers and properties scoped to specific request blocks, allowing for temporary changes that do not affect the global - configuration. Scoped headers and properties ensure that modifications are contextually isolated, enhancing maintainability and reducing the risk of configuration - errors during runtime. - -- **Custom Error Handling and Exception Management:** - Converts HTTP response errors into detailed, meaningful exceptions, streamlining the process of interpreting API-specific errors with the aid of a customizable - error response type set through the `ResponseErrorType` property. Furthermore, it enhances flexibility in error management with the `ErrorHandlers` collection, - allowing for response status code-specific handling. Developers can craft and utilize custom `IHttpErrorHandler` implementations to address distinct HTTP errors - directly, facilitating the development of refined retry strategies and precise error responses tailored to specific needs. - -- **Retry Strategies with Backoff Mechanisms:** - Implements backoff strategies to handle transient failures and network interruptions effectively. These strategies, configurable via the `BackoffStrategy` - property, ensure resilient communication by dictating the logic for retrying requests, thereby preventing server overload and optimizing resource use. - -- **Modular Content Processing:** - Supports extendable serialization/deserialization modules for seamless integration with common and custom content types. It uses a collection of response - deserializers that automatically convert HTTP response content into .NET objects based on the response's `Content-Type`, and proactively informs the service - of the content types it is configured to accept by setting the appropriate `Accept` headers. This dual-functionality simplifies the process of working with - API responses and ensures seamless data integration by aligning expected response formats with the client’s capabilities. - -- **Streamlined Authentication and Authorization:** - Simplifies the process of integrating various authentication schemes and dynamic reauthorization, facilitating straightforward implementation of authentication - strategies. - -- **Request and Response Interception:** - Provides events such as `BeforeSendingRequest` and `AfterReceivingResponse` for executing custom logic before sending a request or after receiving a response. - This feature enables detailed request modification, response inspection, and logging, offering developers full control over the HTTP communication process. - -- **Asynchronous API for Enhanced Performance:** - Promotes fully asynchronous network operations with support for cancellation tokens, ensuring efficient management of long-running requests in line with modern - asynchronous programming practices in .NET. - -## Serialization Support - -By default, `Kampute.HttpClient` does not include any content deserializer. To accommodate popular content types, the following extension packages are available: - -- **[Kampute.HttpClient.Json](https://www.nuget.org/packages/Kampute.HttpClient.Json)**: - Utilizes the `System.Text.Json` library for handling JSON content types, offering high-performance serialization and deserialization that integrates tightly - with the .NET ecosystem. - -- **[Kampute.HttpClient.NewtonsoftJson](https://www.nuget.org/packages/Kampute.HttpClient.NewtonsoftJson)**: - Leverages the `Newtonsoft.Json` library for handling JSON content types, providing extensive customization options and compatibility with a vast number of JSON - features and formats. - -- **[Kampute.HttpClient.Xml](https://www.nuget.org/packages/Kampute.HttpClient.Xml)**: - Employs the `XmlSerializer` for handling XML content types, enabling straightforward serialization and deserialization of XML into .NET objects using custom - class structures. - -- **[Kampute.HttpClient.DataContract](https://www.nuget.org/packages/Kampute.HttpClient.DataContract)**: - Utilizes the `DataContractSerializer` for handling XML content types, focusing on serialization and deserialization of .NET objects into XML based on data contract - attributes for fine-grained control over the XML output. - -For scenarios where the provided serialization packages do not meet specific requirements, `Kampute.HttpClient` allows the implementation of custom deserializers. -Developers can create their own serialization modules by implementing interfaces for deserialization, thus enabling support for custom content types or proprietary -data formats. +[Documentation](https://kampute.github.io/http-client/) · [Getting started](https://kampute.github.io/http-client/overview/getting-started.html) · [API reference](https://kampute.github.io/http-client/api/Kampute.HttpClient.html) ## Installation -Install `Kampute.HttpClient` via NuGet: - ```shell dotnet add package Kampute.HttpClient ``` -## Usage Examples - -The examples below demonstrate how to use the library for common tasks. - -### Basic Usage - -To get started with `HttpRestClient`, simply instantiate it and use it to perform HTTP requests. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.Json; - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Configure the client to accept JSON responses, using System.Text.Json library. -// This is an extension method provided by the Kampute.HttpClient.Json package. -client.AcceptJson(); - -// Perform a GET request. -// The GetAsync method will automatically deserialize the JSON response -// into the specified MyModel type. -var data = await client.GetAsync("https://api.example.com/resource"); -``` - -### Scoped Request Headers - -In addition to setting default request headers that apply to all requests, you can define headers for a specific set of requests using a scoped approach. This feature -allows for temporary modifications to headers that override default settings within a defined context. This is particularly useful for handling varying endpoint requirements -or for testing scenarios. - -Below is an example that demonstrates how to temporarily override the `Accept` header for a series of requests, ensuring that all requests within the scope explicitly request -a specific media type. - -```csharp -using Kampute.HttpClient; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -string csv; - -// Begin a scoped block where the 'Accept' header is set to 'text/csv'. -// All HTTP requests within this using block will include this 'Accept' header. -using (client.BeginHeaderScope(new Dictionary { ["Accept"] = MediaTypeNames.Text.Csv })) -{ - // Perform a GET request to retrieve data as CSV. The 'Accept' header for this request - // will be 'text/csv', as specified by the scoped header. - csv = await client.GetAsStringAsync("https://api.example.com/resource/csv"); -} -``` - -Alternatively, you can use the `WithScope` extension method to simplify the code as follows: - -```csharp -using Kampute.HttpClient; - -using var client = new HttpRestClient(); - -var csv = await client - .WithScope() - .SetHeader("Accept", MediaTypeNames.Text.Csv) - .PerformAsync(scopedClient => scopedClient.GetAsStringAsync("https://api.example.com/resource/csv")); -``` - -### Scoped Request Properties - -Similar to headers, you can also scope request properties. This capability is invaluable in scenarios where you need to maintain state or context-specific information temporarily -during a series of HTTP operations. Scoped properties work similarly to scoped headers, allowing developers to define temporary data attached to requests that are automatically -cleared once the scope is exited. This feature enhances the adaptability of your HTTP interactions, especially in complex or state-dependent communication scenarios. +## Usage -### Custom Retry Strategies - -The library offers various retry strategies to manage transient failures, ensuring your application remains resilient during network instability or temporary -service unavailability. The example below demonstrates how to apply a Fibonacci backoff strategy, which gradually increases the delay between retries, balancing -the need to retry soon against the need to wait longer as the number of attempts increases. +Read a response as text without a content formatter. Replace the example URL with your API. ```csharp using Kampute.HttpClient; -// Create a new instance of the HttpRestClient using var client = new HttpRestClient(); - -// Configure the client's retry mechanism. -// The Fibonacci strategy will retry up to 5 times -// with an initial delay of 1 second between retries -// and delay increases following the Fibonacci sequence for subsequent retries. -client.BackoffStrategy = BackoffStrategies.Fibonacci(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(1)); +var text = await client.GetAsStringAsync("https://api.example.com/resource"); ``` -### Handling HTTP Errors - -The library includes built-in handlers for managing common HTTP errors, streamlining the implementation of custom logic for error responses. -Here's how to utilize the built-in '401 Unauthorized' error handler: - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.ErrorHandlers; - -// Create an instance of the built-in '401 Unauthorized' error handler. -// This handler defines the logic to handle unauthorized responses. -using var unauthorizedErrorHandler = new HttpError401Handler(async (client, challenges, cancellationToken) => -{ - // In this example, we're handling the unauthorized error by making a POST request to an - // authentication endpoint to obtain a new authentication token. - var auth = await client.PostAsFormAsync("https://api.example.com/auth", - [ - KeyValuePair.Create("client_id", MY_APP_ID), - KeyValuePair.Create("client_secret", MY_APP_SECRET) - ]); - - // Return a new AuthenticationHeaderValue with the obtained token. - // This will be used to include the authentication header in subsequent requests. - return new AuthenticationHeaderValue(AuthSchemes.Bearer, auth.Token); -}); - -// Create a new instance of the HttpRestClient -using var client = new HttpRestClient(); - -// Register the unauthorized error handler with the client. -// This allows the client to handle '401 Unauthorized' responses automatically. -client.ErrorHandlers.Add(unauthorizedErrorHandler); -``` - -Additionally, handling '503 Service Unavailable' and '429 Too Many Requests' errors is simplified with the built-in handler, ensuring your application can gracefully -retry requests during service outages and rate limit encounters. - -### Handling Content Types - -For handling specific content types like JSON or XML, consider using the available extension packages. - -In the example below, we assume that both the `Kampute.HttpClient.NewtonsoftJson` package, which facilitates JSON content handling through the `Newtonsoft.Json` -library, and the `Kampute.HttpClient.DataContract` package, enabling XML content management via `DataContractSerializer`, have been installed. - -```csharp -using Kampute.HttpClient; -using Kampute.HttpClient.NewtonsoftJson; -using Kampute.HttpClient.DataContract; - -// Create a new instance of the HttpRestClient. -using var client = new HttpRestClient(); - -// Configure the client to accept JSON responses, using the Newtonsoft.Json library. -// This is an extension method provided by the Kampute.HttpClient.NewtonsoftJson package -client.AcceptJson(); - -// Configure the client to accept XML responses, using DataContractSerializer. -// This is an extension method provided by the Kampute.HttpClient.DataContract package -client.AcceptXml(); - -// Execute a GET request. The server may respond in either JSON or XML format. -// The GetAsync method will automatically deserialize the response -// into the specified MyResource type, based on the response content type (JSON or XML). -var result = await client.GetAsync("https://api.example.com/resource"); - -// Send a PATCH request with a payload in JSON format. -// The PatchAsJsonAsync method is provided by the Kampute.HttpClient.NewtonsoftJson package. -await client.PatchAsJsonAsync("https://api.example.com/resource", new { name = "new name" }); - -// Send a POST request with a payload in XML format. -// The PostAsXmlAsync method is provided by the Kampute.HttpClient.DataContract package. -var newResource = new MyResource(); -await client.PostAsXmlAsync("https://api.example.com/resource", newResource); -``` +The core package registers no content formatter. For typed responses, call `UseXml()` from `Kampute.HttpClient.Xml`, or install a JSON extension and register its formatter. ## Documentation -Explore the `Kampute.HttpClient` library's [API Documentation](https://kampute.github.io/http-client/api/Kampute.HttpClient.html) for an in-depth understanding of its -functionalities. You'll find detailed class references, method signatures, and descriptions of properties to guide your implementation and leverage the library's full -potential. - -## Contributing - -Contributions are welcomed! Please feel free to fork the repository, make changes, and submit pull requests. For major changes or new features, please open an issue -first to discuss what you would like to change. +- [Content formats](https://kampute.github.io/http-client/overview/content-formats.html): XML, JSON packages, and custom formatters. +- [Request customization](https://kampute.github.io/http-client/overview/request-customization.html): temporary headers, properties, and events. +- [Error handling](https://kampute.github.io/http-client/overview/error-handling.html): retries after connection failures, and recovery from HTTP error responses. ## License -`Kampute.HttpClient` is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for more details. +Kampute.HttpClient is released under the [MIT License](LICENSE). diff --git a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs deleted file mode 100644 index 1ee5322..0000000 --- a/src/Kampute.HttpClient/RetryManagement/BackoffStrategy.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient; - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A factory for creating instances configured with a specific retry strategy. - /// - /// - /// This factory encapsulates the creation logic of retry schedulers, allowing for consistent configuration of schedulers - /// across an application. It uses a designated retry strategy to configure each scheduler it creates, ensuring that all - /// schedulers have a uniform approach to handling retry attempts. - /// - public class BackoffStrategy : IHttpBackoffProvider - { - /// - /// Initializes a new instance of the class with a specified retry strategy. - /// - /// The retry strategy to be used by schedulers created by this factory. - /// Thrown if is . - public BackoffStrategy(IRetryStrategy strategy) - { - Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); - } - - /// - /// Gets the retry strategy associated with this scheduler factory. - /// - /// The used by schedulers created by this factory. - public virtual IRetryStrategy Strategy { get; } - - /// - /// Creates a instance using the associated retry strategy. - /// - /// A new instance of configured with the factory's retry strategy. - public virtual IRetryScheduler CreateScheduler() => new RetryScheduler(Strategy); - - /// - IRetryScheduler IHttpBackoffProvider.CreateScheduler(HttpRequestErrorContext ctx) => CreateScheduler(); - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs b/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs deleted file mode 100644 index ae5bc2a..0000000 --- a/src/Kampute.HttpClient/RetryManagement/DynamicBackoffStrategy.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// Represents a factory that dynamically creates retry schedulers based on runtime conditions of HTTP requests. - /// - /// - /// - /// The class leverages a factory function to instantiate objects, enabling - /// the selection of specific retry strategies tailored to the conditions observed during the execution of HTTP requests. The decision-making - /// process utilizes detailed context provided by , which includes information about the HTTP client, - /// the request, and any encountered exceptions. - /// - /// - /// This dynamic approach allows for the implementation of sophisticated retry strategies that can adjust to varying error types and operational - /// scenarios. It is especially beneficial in complex distributed systems where distinct error conditions or system states may necessitate - /// different retry behaviors for optimizing overall system performance and reliability. - /// - /// - public class DynamicBackoffStrategy : IHttpBackoffProvider - { - private readonly Func _schedulerFactory; - - /// - /// Initializes a new instance of the with a scheduler factory function. - /// - /// A factory function that produces instances, allowing for dynamic - /// selection of retry strategies based on the detailed context of failed HTTP requests. - /// Thrown if is . - public DynamicBackoffStrategy(Func schedulerFactory) - { - _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); - } - - /// - /// Initializes a new instance of the with a strategy factory function. - /// - /// A factory function that produces instances, allowing for dynamic - /// selection of retry strategies based on the detailed context of failed HTTP requests. - /// Thrown if is . - public DynamicBackoffStrategy(Func strategyFactory) - { - if (strategyFactory is null) - throw new ArgumentNullException(nameof(strategyFactory)); - - _schedulerFactory = ctx => - { - var strategy = strategyFactory(ctx); - return strategy is not null - ? strategy.ToScheduler() - : throw new InvalidOperationException("The strategy factory function returned null."); - }; - } - - /// - /// Creates a retry scheduler tailored to the specific conditions of a failed HTTP request, as determined by the provided context. - /// - /// The context containing detailed information about the failed HTTP request, such as the client, request, and error details. - /// An instance of configured to manage retry attempts for the given request context. - /// Thrown if is . - /// Thrown if the scheduler factory function returns . - public IRetryScheduler CreateScheduler(HttpRequestErrorContext ctx) - { - if (ctx is null) - throw new ArgumentNullException(nameof(ctx)); - - return _schedulerFactory(ctx) ?? throw new InvalidOperationException("The scheduler factory function returned null."); - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs b/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs deleted file mode 100644 index 358ce62..0000000 --- a/src/Kampute.HttpClient/RetryManagement/NamespaceDoc.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - /// - /// This namespace provides classes and interfaces for managing retry - /// logic and backoff strategies in HTTP requests. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs b/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs deleted file mode 100644 index 907840b..0000000 --- a/src/Kampute.HttpClient/RetryManagement/RetryScheduler.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Represents a scheduler for managing retry attempts. - /// - public class RetryScheduler : IRetryScheduler - { - private readonly Stopwatch _timer = Stopwatch.StartNew(); - private uint _attempts = 0; - - /// - /// Initializes a new instance of the class with a specified retry strategy. - /// - /// The retry strategy to be used by this scheduler. - /// Thrown if is . - public RetryScheduler(IRetryStrategy strategy) - { - Strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); - } - - /// - /// Gets the retry strategy associated with this scheduler. - /// - /// The instance used by this scheduler. - public virtual IRetryStrategy Strategy { get; } - - /// - /// Gets the number of retry attempts that have been made. - /// - /// The number of retry attempts. - public virtual uint Attempts => _attempts; - - /// - /// Gets the total elapsed time since the retry attempts were started. - /// - /// The elapsed time as a . - public virtual TimeSpan Elapsed => _timer.Elapsed; - - /// - /// Waits for the appropriate time before the next retry attempt, and determines if a retry should be attempted. - /// - /// A token that can be used to cancel the wait operation. - /// A task that resolves to if a retry should be attempted; otherwise, . - /// Thrown if the wait operation is canceled. - public virtual async Task WaitAsync(CancellationToken cancellationToken) - { - if (Strategy.TryGetRetryDelay(Elapsed, Attempts, out var delay)) - { - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - else - cancellationToken.ThrowIfCancellationRequested(); - - ReadyNextAttempt(); - return true; - } - return false; - } - - /// - /// Resets the internal state of the scheduler to its initial condition. - /// - public virtual void Reset() - { - _timer.Restart(); - _attempts = 0; - } - - /// - /// Prepares the internal state for the next retry attempt. - /// - /// - /// This method is called immediately after a retry attempt is determined to be necessary and before the delay for the next attempt begins. - /// It allows for updating the internal state or performing any preparations required before the next attempt. - /// - protected virtual void ReadyNextAttempt() - { - ++_attempts; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs b/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs deleted file mode 100644 index 8433480..0000000 --- a/src/Kampute.HttpClient/RetryManagement/RetryStrategyExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Kampute.HttpClient.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; - using System; - - /// - /// Provides extension methods for to enhance retry strategies with additional behavior. - /// - public static class RetryStrategyExtensions - { - /// - /// Enhances a retry strategy with jitter to add randomness to the retry delay. - /// - /// The original retry strategy to be enhanced. - /// The factor by which to adjust the delay randomly, with a default of 0.5. - /// A instance wrapping the original retry strategy with added jitter. - /// Thrown if is . - /// Thrown if is not between 0 and 1. - public static JitterStrategyModifier WithJitter(this IRetryStrategy source, double jitterFactor = 0.5) => new(source, jitterFactor); - - /// - /// Enhances a retry strategy with a maximum number of retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum number of attempts allowed before giving up. - /// A instance wrapping the original retry strategy with a limit on the number of attempts. - /// Thrown if is . - public static LimitedAttemptsStrategyModifier WithMaxAttempts(this IRetryStrategy source, uint maxAttempts) => new(source, maxAttempts); - - /// - /// Enhances a retry strategy with a timeout, limiting the total duration allowed for retry attempts. - /// - /// The original retry strategy to be enhanced. - /// The maximum duration to attempt retries before giving up. - /// A instance wrapping the original retry strategy with a timeout limit. - /// Thrown if is . - public static LimitedDurationStrategyModifier WithTimeout(this IRetryStrategy source, TimeSpan timeout) => new(source, timeout); - - /// - /// Converts an into a , creating a scheduler instance based on the provided strategy. - /// - /// The retry strategy to convert into a scheduler. - /// A new instance of . - /// Thrown if is . - public static RetryScheduler ToScheduler(this IRetryStrategy source) => new(source); - - /// - /// Converts an into a , creating a factory capable of producing schedulers based - /// on the provided strategy. - /// - /// The retry strategy to use for creating a scheduler factory. - /// A new instance of . - /// Thrown if is . - public static BackoffStrategy ToBackoffStrategy(this IRetryStrategy source) => new(source); - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs deleted file mode 100644 index 277a28b..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/ExponentialStrategy.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that exponentially increases the delay before each retry attempt. - /// - /// - /// The class calculates the delay between retry attempts by starting with an initial delay and then increasing it exponentially - /// with each subsequent retry. This strategy is effective in scenarios where the load on the underlying systems needs to be progressively reduced in the - /// face of ongoing failures, or where it is beneficial to wait longer between attempts to increase the chance of success. - /// - public sealed class ExponentialStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and exponential rate. - /// - /// The initial delay duration before the first retry attempt. - /// The rate at which the delay duration increases exponentially between retries. - /// Thrown if is less than 1. - public ExponentialStrategy(TimeSpan initialDelay, double rate) - { - if (rate < 1.0) - throw new ArgumentOutOfRangeException(nameof(rate), "Rate must be at least 1."); - - InitialDelay = initialDelay; - Rate = rate; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the rate at which the delay duration increases exponentially between retries. - /// - /// - /// The rate at which the delay duration increases exponentially between retries. - /// - public double Rate { get; } - - /// - /// Calculates the delay for the next retry attempt, exponentially increasing based on the number of attempts made so far. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - var millisecondsDelay = InitialDelay.TotalMilliseconds * Math.Pow(Rate, attempts); - delay = TimeSpan.FromMilliseconds(millisecondsDelay); - return true; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs deleted file mode 100644 index 8400c7e..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/FibonacciStrategy.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that increases the delay before each retry attempt based on the Fibonacci sequence. - /// - /// - /// The class uses the Fibonacci sequence to adjust the wait time between retries, starting with an initial - /// delay and scaling the delay between subsequent attempts according to Fibonacci numbers. This approach provides a more moderate - /// and controlled increase in delay times compared to the approach, making it suitable for scenarios where - /// a less aggressive increase in delay is desired. - /// - public sealed class FibonacciStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and step increment. - /// - /// The initial delay duration before the first retry attempt. - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - public FibonacciStrategy(TimeSpan initialDelay, TimeSpan delayStep) - { - InitialDelay = initialDelay; - DelayStep = delayStep; - } - - /// - /// Initializes a new instance of the class with a specified initial delay. - /// - /// The initial delay duration before the first retry attempt. - public FibonacciStrategy(TimeSpan initialDelay) - { - InitialDelay = initialDelay; - DelayStep = initialDelay; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// - /// - /// The fixed amount of time that is scaled by the Fibonacci sequence and added to the initial delay for each subsequent retry attempt. - /// - public TimeSpan DelayStep { get; } - - /// - /// Calculates the delay for the next retry attempt based on the Fibonacci sequence. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = TimeSpan.FromMilliseconds(InitialDelay.TotalMilliseconds + DelayStep.TotalMilliseconds * FibonacciNumber(attempts)); - return true; - } - - /// - /// Calculates the Fibonacci number at a given position in the sequence using Binet's formula. - /// - /// The position in the Fibonacci sequence for which to calculate the number. - /// The Fibonacci number at the specified position. - private static double FibonacciNumber(uint n) - { - if (n == 0) return 0; - - var sqrt5 = Math.Sqrt(5); - var phi = (1 + sqrt5) / 2; - return Math.Round(Math.Pow(phi, n) / sqrt5); - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs deleted file mode 100644 index bafaa5f..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/LinearStrategy.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that linearly increases the delay before each retry attempt. - /// - /// - /// The class calculates the delay between retry attempts by starting with an initial delay and then increasing it linearly with - /// each subsequent retry. This linear increment strategy offers a controlled escalation of wait times, making it suitable for scenarios where - /// gradually backing off is preferred to reduce the load on resources or to increase the chances of a successful retry under improving conditions. - /// - public sealed class LinearStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified initial delay and step increment. - /// - /// The initial delay duration before the first retry attempt. - /// The fixed amount of time by which the delay is incremented for each subsequent retry. - public LinearStrategy(TimeSpan initialDelay, TimeSpan delayStep) - { - InitialDelay = initialDelay; - DelayStep = delayStep; - } - - /// - /// Initializes a new instance of the class with a specified initial delay. - /// - /// The initial delay duration before the first retry attempt. - public LinearStrategy(TimeSpan initialDelay) - { - InitialDelay = initialDelay; - DelayStep = initialDelay; - } - - /// - /// Gets the initial delay duration before the first retry attempt. - /// - /// - /// The initial delay duration before the first retry attempt. - /// - public TimeSpan InitialDelay { get; } - - /// - /// Gets the fixed amount of time by which the delay is increased with each retry attempt. - /// - /// - /// The fixed amount of time by which the delay is increased with each retry attempt. - /// - public TimeSpan DelayStep { get; } - - /// - /// Calculates the delay for the next retry attempt, linearly increasing based on the number of attempts made so far. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = InitialDelay + TimeSpan.FromTicks(DelayStep.Ticks * attempts); - return true; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs deleted file mode 100644 index 7b72d85..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/JitterStrategyModifier.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that adds random jitter to the delay durations provided by another retry strategy. - /// - /// - /// Jitter is added to the delays to prevent thundering herd problems and to provide a more distributed set of retry attempts over time. - /// This can be beneficial in high-load scenarios where many clients are retrying operations simultaneously. - /// - public sealed class JitterStrategyModifier : IRetryStrategy - { - private readonly Random _random = new(); - - /// - /// Initializes a new instance of the class with a specified source retry strategy and jitter factor. - /// - /// The underlying retry strategy to which jitter will be added. - /// The factor to apply to the delay to introduce jitter, represented as a value between 0 and 1. - /// Thrown if is . - /// Thrown if is not between 0 and 1. - /// - /// The jitter factor allows fine-tuning of the randomness applied to the retry delay, enabling a balance between predictability and the - /// benefits of desynchronization. It is a double value between 0 and 1 that determines the maximum proportion of the delay that can be - /// adjusted randomly to introduce jitter. A value of 0 means no jitter, while 1 allows the delay to vary by up to ±100% of the base delay. - /// - public JitterStrategyModifier(IRetryStrategy source, double jitterFactor) - { - if (jitterFactor < 0.0 || jitterFactor > 1.0) - throw new ArgumentOutOfRangeException(nameof(jitterFactor), "Jitter factor must be a value between 0 and 1, inclusive."); - - Source = source ?? throw new ArgumentNullException(nameof(source)); - JitterFactor = jitterFactor; - } - - /// - /// Gets the underlying retry strategy to which jitter is added. - /// - /// - /// The underlying to which jitter is added. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the factor to apply to the delay to introduce jitter. - /// - /// - /// The factor to apply to the delay to introduce jitter. It is a floating-point number between 0 and 1, inclusive. - /// - public double JitterFactor { get; } - - /// - /// Calculates the delay for the next retry attempt, adding random jitter based on the jitter factor to the delay provided by the underlying strategy. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - if (Source.TryGetRetryDelay(elapsed, attempts, out delay)) - { - var jitter = delay.TotalMilliseconds * JitterFactor * (2 * _random.NextDouble() - 1); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds + jitter); - return true; - } - return false; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs deleted file mode 100644 index fbe3c9e..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifier.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that limits the number of retry attempts to a specified maximum. - /// - /// - /// This class wraps another strategy and enforces a maximum number of attempts. Once the limit is reached, no further - /// retries are suggested. - /// - public sealed class LimitedAttemptsStrategyModifier : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified source provider and maximum number of - /// attempts. - /// - /// The underlying retry strategy. - /// The maximum number of retry attempts before giving up. - /// Thrown if is . - public LimitedAttemptsStrategyModifier(IRetryStrategy source, uint maxAttempts) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - MaxAttempts = maxAttempts; - } - - /// - /// Gets the underlying retry strategy, to which the specified maximum number of retry attempts is applied as a limit. - /// - /// - /// The underlying , to which the specified maximum number of retry attempts is applied as a limit. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the maximum number of retry attempts before giving up. - /// - /// - /// The maximum number of retry attempts before giving up. - /// - public uint MaxAttempts { get; } - - /// - /// Calculates the delay for the next retry attempt, enforcing the maximum attempt limit. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the number of attempts has not yet reached the maximum and the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - if (attempts < MaxAttempts & Source.TryGetRetryDelay(elapsed, attempts, out delay)) - return true; - - delay = default; - return false; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs deleted file mode 100644 index 7dd3e35..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifier.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that limits the retry attempts to a specified time frame. - /// - /// - /// This class wraps another retry strategy and enforces a total time limit for retries. It ensures that the total elapsed time does - /// not exceed the specified timeout before suggesting another retry attempt. - /// - public sealed class LimitedDurationStrategyModifier : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified source retry strategy and timeout duration. - /// - /// The underlying retry strategy. - /// The maximum duration to continue attempting retries before giving up. - /// Thrown if is . - public LimitedDurationStrategyModifier(IRetryStrategy source, TimeSpan timeout) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - Timeout = timeout; - } - - /// - /// Gets the underlying retry strategy, to which the specified timeout duration is applied as a limit for the total retry attempts. - /// - /// - /// The underlying , to which the specified timeout duration is applied as a limit for the total retry attempts. - /// - public IRetryStrategy Source { get; } - - /// - /// Gets the maximum duration to continue attempting retries before giving up. - /// - /// - /// The maximum duration to continue attempting retries before giving up. - /// - public TimeSpan Timeout { get; } - - /// - /// Calculates the delay for the next retry attempt, enforcing the timeout limit. - /// - /// The total time elapsed since the start of retry attempts. - /// The number of retry attempts made so far. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// if the total elapsed time is within the specified timeout and the underlying strategy indicates that a retry should be attempted; otherwise, . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - var remaining = Timeout - elapsed; - if (remaining > TimeSpan.Zero && Source.TryGetRetryDelay(elapsed, attempts, out delay)) - { - if (delay > remaining) - delay = remaining; - - return true; - } - - delay = default; - return false; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs deleted file mode 100644 index c781982..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/Modifiers/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies.Modifiers -{ - /// - /// This namespace provides modifiers that can be applied to retry strategies to alter their behavior. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs deleted file mode 100644 index d6af15f..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/NamespaceDoc.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - /// - /// This namespace contains implementations of various retry strategies for HTTP requests. - /// - internal static class NamespaceDoc { } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs deleted file mode 100644 index ca40f65..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/NoneStrategy.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that always indicates no retry should be attempted. - /// - public sealed class NoneStrategy : IRetryStrategy - { - /// - /// Prevents a default instance of the class from being created. - /// - private NoneStrategy() { } - - /// - /// Gets the singleton instance of the class. - /// - /// - /// The singleton instance of the class. - /// - public static NoneStrategy Instance { get; } = new NoneStrategy(); - - /// - /// Always returns to indicate that no further retry attempts should be made, setting the delay to its default value. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. This parameter is ignored in this implementation. - /// When this method returns, contains the default value for , indicating no delay. This parameter is passed uninitialized. - /// Always returns , indicating that no further retry attempts should be made. - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = default; - return false; - } - } -} diff --git a/src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs b/src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs deleted file mode 100644 index 068a21e..0000000 --- a/src/Kampute.HttpClient/RetryManagement/Strategies/UniformStrategy.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2025 Kampute -// -// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.HttpClient.RetryManagement.Strategies -{ - using Kampute.HttpClient.Interfaces; - using System; - - /// - /// A retry strategy that schedules retries with a uniform delay. - /// - /// - /// The class provides a retry mechanism with a constant wait time between retries. This strategy is useful for scenarios where a predictable - /// retry pattern is desired, applying the same delay duration between each retry attempt without regard to the number of attempts made or the total elapsed time. - /// - public sealed class UniformStrategy : IRetryStrategy - { - /// - /// Initializes a new instance of the class with a specified delay duration between retry attempts. - /// - /// The delay duration between retry attempts. - public UniformStrategy(TimeSpan delay) - { - Delay = delay; - } - - /// - /// Gets the delay duration between retries. - /// - /// - /// The delay duration between retries. - /// - public TimeSpan Delay { get; } - - /// - /// Returns a uniform delay for every retry attempt. - /// - /// The total time elapsed since the start of retry attempts. This parameter is ignored in this implementation. - /// The number of retry attempts made so far. This parameter is ignored in this implementation. - /// When this method returns, contains the calculated delay for the next retry attempt. This parameter is passed uninitialized. - /// Always returns , indicating that a retry attempt should be made after the calculated . - public bool TryGetRetryDelay(TimeSpan elapsed, uint attempts, out TimeSpan delay) - { - delay = Delay; - return true; - } - } -} diff --git a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs index 95315b7..70e1948 100644 --- a/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs +++ b/src/Kampute.HttpClient/Utilities/AsyncUpdateThrottle.cs @@ -5,23 +5,23 @@ using System.Threading.Tasks; /// - /// Manages thread-safe, asynchronous updates to a value, ensuring efficiency by reducing unnecessary update operations. + /// Holds a value that is updated asynchronously, and skips an update when another one completed while it waited. /// - /// The type of the value to be managed, preferably immutable for thread safety. + /// The type of the value. An immutable type is safest, because the value is shared between threads. /// - /// This class provides a mechanism to update a value asynchronously while ensuring that updates are serialized and efficient. It is designed to prevent multiple, - /// concurrent update operations from being processed if they are requested in quick succession. By employing a timing check before applying updates, the class ensures - /// that only necessary updates proceed when the value has not been recently updated, making it ideal for scenarios where collecting or calculating the updated value is - /// resource-intensive or costly. + /// Updates run one at a time. When several callers request an update at the same time, such as requests that all find an access token expired, + /// the first one runs and the others return without running their update, because the value changed after they asked. This suits values that + /// are costly to obtain. /// public sealed class AsyncUpdateThrottle : IDisposable { private readonly SemaphoreSlim _semaphore = new(1, 1); private VolatileWrapper _value; private long _lastUpdateTime; + private int _version; /// - /// Initializes a new instance of the class with a default value. + /// Initializes a new instance of the class with the default value of . /// public AsyncUpdateThrottle() : this(default) @@ -29,9 +29,9 @@ public AsyncUpdateThrottle() } /// - /// Initializes a new instance of the class with a specified value. + /// Initializes a new instance of the class with the specified value. /// - /// The initial value of the type . + /// The initial value. public AsyncUpdateThrottle(T? initialValue) { _value = new VolatileWrapper(initialValue); @@ -41,8 +41,7 @@ public AsyncUpdateThrottle(T? initialValue) /// Gets the current value. /// /// - /// The current value of type . This value is thread-safe to access and represents the most recent state - /// managed by the . + /// The value of the last completed update, or the initial value if none has completed. It can be read from any thread. /// public T? Value { @@ -51,49 +50,57 @@ public T? Value } /// - /// Gets the time of the last successful update operation. + /// Gets the time of the last completed update. /// /// - /// The time when the last successful update to the value was made. If no update has been applied, the value is . + /// The time, in UTC, when the last update completed, or if none has completed. /// public DateTimeOffset LastUpdateTime { - get => DateTimeOffset.FromUnixTimeMilliseconds(Volatile.Read(ref _lastUpdateTime)); - private set => Volatile.Write(ref _lastUpdateTime, value.ToUnixTimeMilliseconds()); + get => new(Volatile.Read(ref _lastUpdateTime), TimeSpan.Zero); + private set => Volatile.Write(ref _lastUpdateTime, value.UtcTicks); } /// - /// Attempts to update the value asynchronously using the provided updater function. + /// Updates the value with the result of a function, unless another update completes after this method is called. /// - /// The asynchronous function used to update the value. - /// A token for canceling the operation. - /// A task that represents the asynchronous operation. The task result contains a boolean value that indicates whether the value was updated. + /// The function that produces the new value. + /// A token for canceling the wait for an update in progress. + /// + /// A task that resolves to if ran and its result became the value, or + /// if another update completed first and did not run. + /// /// Thrown if is . - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if is canceled while the method waits for an update in progress. /// - /// - /// This method allows for a thread-safe update of the value. The update will only be applied if no other update has been completed since - /// this update attempt was initiated, preventing unnecessary updates or overwrites by concurrent operations. - /// - /// - /// If the update proceeds and is successful, the method returns ; if another update has already been applied, it returns . - /// This behavior ensures that the value reflects the most recent update attempt that was actually needed. - /// + /// If throws, the value is unchanged and the exception propagates to the caller. /// - public async Task TryUpdateAsync(Func> asyncUpdater, CancellationToken cancellationToken = default) + public Task TryUpdateAsync(Func> asyncUpdater, CancellationToken cancellationToken = default) { if (asyncUpdater is null) throw new ArgumentNullException(nameof(asyncUpdater)); - var requestTime = DateTimeOffset.UtcNow; + return TryUpdateCoreAsync(asyncUpdater, Volatile.Read(ref _version), cancellationToken); + } + + /// + /// Updates the value unless another update has completed since was read. + /// + /// The function that produces the new value. + /// The version of the value when the update was requested. + /// A token for canceling the operation. + /// A task that resolves to if the value was updated; otherwise, . + private async Task TryUpdateCoreAsync(Func> asyncUpdater, int requestVersion, CancellationToken cancellationToken) + { await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (requestTime <= LastUpdateTime) + if (requestVersion != _version) return false; // The value is already up to date. Value = await asyncUpdater().ConfigureAwait(false); LastUpdateTime = DateTimeOffset.UtcNow; + Interlocked.Increment(ref _version); return true; } finally @@ -103,7 +110,7 @@ public async Task TryUpdateAsync(Func> asyncUpdater, Cancellation } /// - /// Releases all resources used by the . + /// Releases the lock that serializes the updates. /// public void Dispose() { diff --git a/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs b/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs index 5bed7fb..1657989 100644 --- a/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs +++ b/src/Kampute.HttpClient/Utilities/ExceptionExtensions.cs @@ -9,15 +9,18 @@ namespace Kampute.HttpClient.Utilities using System.Net.Sockets; /// - /// Provides extension methods for to enhance functionality related to HTTP request execution. + /// Provides extension methods that classify the exceptions of HTTP requests. /// public static class ExceptionExtensions { /// - /// Determines whether a given exception can be considered as a transient network issue for the purposes of retrying an HTTP request. + /// Determines whether an exception reports a network failure that a retry could overcome. /// - /// The exception encountered during the HTTP request execution. - /// if the error can be considered as a transient network issue and might warrant a retry attempt; otherwise. + /// The exception of a failed request. + /// + /// if the innermost exception is a , or a + /// for a transient condition, such as a refused or reset connection or an unreachable host; otherwise, . + /// public static bool IsTransientNetworkError(this Exception exception) => exception.GetBaseException() switch { TimeoutException => true, diff --git a/src/Kampute.HttpClient/Utilities/FlyweightCache.cs b/src/Kampute.HttpClient/Utilities/FlyweightCache.cs index 91c3f98..d86111d 100644 --- a/src/Kampute.HttpClient/Utilities/FlyweightCache.cs +++ b/src/Kampute.HttpClient/Utilities/FlyweightCache.cs @@ -5,14 +5,13 @@ using System.Collections.Generic; /// - /// Provides a thread-safe cache for efficiently retrieving and lazily adding key-value pairs. + /// Provides a thread-safe cache that creates the value of a key on first use and returns the same value afterwards. /// /// The type of keys used in the cache. /// The type of values stored in the cache. /// - /// This cache utilizes to ensure thread-safe access while optimizing for - /// high read scenarios and infrequent writes. Values are created on demand using a specified factory method when they - /// are accessed and not already present, allowing for efficient memory usage and avoiding pre-population overhead. + /// Entries are never removed except by , so use the cache for a small, bounded set of keys. When two threads ask for a + /// missing key at the same time, the factory can run twice, but both get the same value. /// public sealed class FlyweightCache where TKey : notnull { @@ -22,8 +21,8 @@ public sealed class FlyweightCache where TKey : notnull /// /// Initializes a new instance of the class using a specified value factory. /// - /// A delegate that defines the method to create values if the key does not exist in the cache. - /// Thrown if the provided is . + /// The function that creates the value of a key. + /// Thrown if is . public FlyweightCache(Func valueFactory) { _valueFactory = valueFactory ?? throw new ArgumentNullException(nameof(valueFactory)); @@ -33,9 +32,9 @@ public FlyweightCache(Func valueFactory) /// /// Initializes a new instance of the class using a specified value factory and key comparer. /// - /// A delegate that defines the method to create values if the key does not exist in the cache. - /// The equality comparison implementation to use when comparing keys. - /// Thrown if the provided or is . + /// The function that creates the value of a key. + /// The comparer that decides whether two keys are equal. + /// Thrown if or is . public FlyweightCache(Func valueFactory, IEqualityComparer keyComparer) { _valueFactory = valueFactory ?? throw new ArgumentNullException(nameof(valueFactory)); @@ -43,27 +42,27 @@ public FlyweightCache(Func valueFactory, IEqualityComparer k } /// - /// Gets the number of key/value pairs contained in the cache. + /// Gets the number of cached values. /// - /// The number of key/value pairs currently stored in the cache. + /// The number of keys in the cache. public int Count => _store.Count; /// - /// Retrieves a value for the specified key. + /// Returns the value of a key, creating it if the cache has none. /// - /// The key whose value to retrieve. - /// The value associated with the specified key. + /// The key whose value to return. + /// The cached value of . public TValue Get(TKey key) => _store.GetOrAdd(key, _valueFactory); /// - /// Checks if the cache contains a value associated with the specified key. + /// Determines whether the cache has a value for a key. /// - /// The key to check in the cache. + /// The key to check. /// if the key exists in the cache; otherwise, . public bool Contains(TKey key) => _store.ContainsKey(key); /// - /// Clears the cache. + /// Removes all values from the cache. /// public void Clear() => _store.Clear(); } diff --git a/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs b/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs index 3309cd9..62cb492 100644 --- a/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Utilities/NamespaceDoc.cs @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.Utilities { /// - /// This namespace contains utility classes and extensions for managing - /// shared resources, caching, and asynchronous operations. + /// This namespace contains the general-purpose types that the client builds on: the shared HttpClient, scoped collections, caches, + /// and throttled updates. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Utilities/ScopedCollection.cs b/src/Kampute.HttpClient/Utilities/ScopedCollection.cs index f1f9b5d..2fe400e 100644 --- a/src/Kampute.HttpClient/Utilities/ScopedCollection.cs +++ b/src/Kampute.HttpClient/Utilities/ScopedCollection.cs @@ -7,22 +7,18 @@ using System.Threading; /// - /// Manages items within specific contexts. + /// Holds items in nested scopes that flow with the asynchronous context of the code that begins them. /// - /// The type of items managed within the scopes. + /// The type of the items. /// /// - /// This class facilitates the management of contextual items, which are elements associated with distinct operational contexts, - /// such as HTTP requests, database transactions, or other scenarios requiring contextual data preservation. + /// A scope begun with holds its items until it is disposed. The items are visible to the code that began the scope + /// and to the asynchronous operations it starts or awaits, but not to unrelated code running at the same time, because the active scope is + /// kept in an . /// /// - /// When enumerating the collection, items are presented from the outermost to the innermost scope, ensuring that items in outer - /// scopes are encountered before those in nested scopes. This ordering reflects the hierarchical relationship, where items defined - /// in outer scopes may be overridden by those in inner scopes. - /// - /// - /// This class is thread-safe and can be utilized reliably in concurrent and asynchronous operations, ensuring that contextual items - /// remain accessible and intact across the lifespan of a context. + /// Enumerating the collection yields the items of the outermost scope first, so that a consumer that applies them in order lets inner scopes + /// override outer ones. visits the innermost scope first. /// /// public class ScopedCollection : IEnumerable @@ -30,7 +26,7 @@ public class ScopedCollection : IEnumerable private readonly AsyncLocal _activeScope = new(); /// - /// Gets a value indicating whether the current context has an active scope. + /// Gets a value indicating whether a scope is active in the current asynchronous context. /// /// /// if an active scope is present; otherwise, . @@ -38,10 +34,10 @@ public class ScopedCollection : IEnumerable public bool HasActiveScope => _activeScope.Value is not null; /// - /// Initiates a new scope within the current context, incorporating the specified items. + /// Begins a scope with the specified items, nested in the active scope if there is one. /// - /// The items to include in the new scope. - /// A new instance of the class, containing the specified items. + /// The items of the new scope. + /// The new , which ends when it is disposed. /// Thrown if is . public virtual Scope BeginScope(IEnumerable items) { @@ -57,9 +53,9 @@ public virtual Scope BeginScope(IEnumerable items) } /// - /// Ends the specified scope and removes it from the current context. + /// Ends a scope, making its parent the active scope. /// - /// The scope to be removed. + /// The scope to end. /// Thrown if is . protected virtual void EndScope(Scope scope) { @@ -74,15 +70,10 @@ protected virtual void EndScope(Scope scope) } /// - /// Traverses items in each scope from the innermost to the outermost, applying an action to each item. + /// Applies an action to the items of the active scopes, starting with the innermost scope. /// - /// The action to perform on each item within the scopes. - /// Thrown if the is . - /// - /// Unlike the standard enumeration, which traverses items from outermost to innermost scopes, this method traverses the scopes - /// starting from the current active scope and moving outward to the parent scopes. This order ensures that actions are performed - /// on items starting from the most specific (innermost) to the most general (outermost) context. - /// + /// The action to apply to each item. + /// Thrown if is . public virtual void Traverse(Action action) { if (action is null) @@ -94,9 +85,9 @@ public virtual void Traverse(Action action) } /// - /// Returns an enumerator that iterates through the collection of items in the current context. + /// Returns an enumerator over the items of the active scopes, starting with the outermost scope. /// - /// An enumerator that can be used to iterate through the collection. + /// An enumerator over the items. public virtual IEnumerator GetEnumerator() { return GetEnumerable().GetEnumerator(); @@ -122,16 +113,16 @@ IEnumerable GetEnumerable() IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); /// - /// Represents a scope containing items within a specific context. + /// Represents a scope of a , which ends when it is disposed. /// public sealed class Scope : IDisposable { /// - /// Initializes a new instance of the class, linking it to its owner and parent scope with specified items. + /// Initializes a new instance of the class. /// - /// The that this scope is part of. - /// The parent scope of this instance, if any. - /// The items to be associated with this scope. + /// The collection that the scope belongs to. + /// The enclosing scope, if any. + /// The items of the scope. internal Scope(ScopedCollection owner, Scope? parent, IEnumerable items) { Owner = owner; @@ -140,25 +131,25 @@ internal Scope(ScopedCollection owner, Scope? parent, IEnumerable items) } /// - /// Gets the that owns this scope. + /// Gets the collection that the scope belongs to. /// - /// The instance that owns this scope. + /// The of this scope. public ScopedCollection Owner { get; } /// - /// Gets the parent scope of this instance, if any. + /// Gets the enclosing scope. /// - /// The parent scope of this scope. It is if there is no parent scope. + /// The scope that was active when this scope began, or if there was none. public Scope? Parent { get; } /// - /// Gets the read-only collection of items in this scope. + /// Gets the items of the scope. /// - /// The read-only collection of items in this scope. + /// The items of this scope. public IReadOnlyCollection Items { get; } /// - /// Disposes this scope, effectively removing it from the active context. + /// Ends the scope. /// public void Dispose() => Owner.EndScope(this); } diff --git a/src/Kampute.HttpClient/Utilities/SharedDisposable.cs b/src/Kampute.HttpClient/Utilities/SharedDisposable.cs index 225e25c..bf13fd4 100644 --- a/src/Kampute.HttpClient/Utilities/SharedDisposable.cs +++ b/src/Kampute.HttpClient/Utilities/SharedDisposable.cs @@ -9,19 +9,12 @@ namespace Kampute.HttpClient.Utilities using System.Threading; /// - /// Manages shared access to a disposable resource, ensuring it is correctly disposed of when no longer in use. + /// Shares one instance of a disposable resource among its users, and disposes it when the last of them releases it. /// - /// The type of the disposable object. Must be a class that implements . + /// The type of the resource. /// - /// - /// This class is particularly useful for managing resources that are expensive to create and can be safely shared across different parts - /// of an application. It ensures that the resource remains alive as long as it is needed and is properly cleaned up afterwards. This pattern - /// helps prevent resource leaks and promotes efficient resource usage. - /// - /// - /// The implementation is thread-safe, making it suitable for use in multi-threaded environments where resources may be accessed concurrently. - /// The resource is created lazily, only when it is first requested, and is disposed of when the last reference is released. - /// + /// Each user acquires a and disposes it when done. The resource is created when the first reference is acquired, and + /// disposed when the last reference is disposed; a later reference creates a new instance. The class is thread-safe. /// public sealed class SharedDisposable where T : class, IDisposable { @@ -31,7 +24,7 @@ public sealed class SharedDisposable where T : class, IDisposable private readonly object _lock = new(); /// - /// Initializes a new instance of the class that uses the default constructor of . + /// Initializes a new instance of the class that creates the resource with the parameterless constructor of . /// public SharedDisposable() : this(Activator.CreateInstance) @@ -39,9 +32,9 @@ public SharedDisposable() } /// - /// Initializes a new instance of the class with a factory function. + /// Initializes a new instance of the class that creates the resource with a function. /// - /// A function that creates an instance of the object when needed. + /// The function that creates the resource. /// Thrown if is . public SharedDisposable(Func factory) { @@ -49,15 +42,15 @@ public SharedDisposable(Func factory) } /// - /// Gets the current number of active references to the managed disposable object. + /// Gets the number of references that are not yet disposed. /// /// The number of active references. public int ReferenceCount => Volatile.Read(ref _referenceCount); /// - /// Creates a new reference to the shared disposable resource, increasing the reference count. + /// Acquires a reference to the resource, creating the resource if no other reference is active. /// - /// A new instance. + /// A new , which releases the resource when it is disposed. public Reference AcquireReference() => new(this); /// @@ -91,7 +84,7 @@ private void DecReferenceCount() } /// - /// Represents a reference to the shared disposable resource. + /// Represents one user's reference to the shared resource. /// public sealed class Reference : IDisposable { @@ -109,20 +102,20 @@ internal Reference(SharedDisposable owner) } /// - /// Gets the instance that owns this reference. + /// Gets the that the reference belongs to. /// - /// The owning instance. + /// The of this reference. public SharedDisposable Owner => _owner; /// - /// Gets the instance of the shared disposable resource. + /// Gets the shared resource. /// - /// The shared disposable resource instance. + /// The shared instance of . /// Thrown if the reference has been disposed. public T Instance => _instance ?? throw new ObjectDisposedException(typeof(Reference).Name); /// - /// Decreases the reference count and disposes the resource if it is no longer needed. + /// Releases the reference, and disposes the resource if this was the last active reference. /// public void Dispose() { @@ -134,10 +127,10 @@ public void Dispose() } /// - /// Allows implicit conversion of the to the shared resource type. + /// Returns the shared resource of a reference. /// - /// The reference instance. - /// The shared disposable resource instance. + /// The reference. + /// The of . public static implicit operator T(Reference reference) => reference.Instance; } } diff --git a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs index d995f6c..6f0eecb 100644 --- a/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs +++ b/src/Kampute.HttpClient/Utilities/SharedHttpClient.cs @@ -4,12 +4,11 @@ using System.Net.Http; /// - /// Provides a singleton-like access to a shared instance across the application. + /// Provides the that instances of share by default. /// /// - /// This static class manages the lifecycle of a single instance. It ensures efficient resource usage by allowing - /// the to be reused throughout the application. The is managed as a shared disposable resource, - /// which means it is only disposed when no longer in use by any part of the application. + /// Sharing one lets all clients reuse the same connections. The is created when the first + /// reference is acquired, and disposed when the last reference is released; a later reference creates a new one with . /// public static class SharedHttpClient { @@ -18,9 +17,9 @@ public static class SharedHttpClient private static SharedDisposable? _instance; /// - /// Acquires a reference to the shared instance. + /// Acquires a reference to the shared . /// - /// A that manages the lifetime of the shared . + /// A to the shared , which releases it when it is disposed. public static SharedDisposable.Reference AcquireReference() { if (_instance is null) @@ -36,21 +35,21 @@ public static SharedDisposable.Reference AcquireReference() /// - /// Gets the current number of active references to the shared instance. + /// Gets the number of active references to the shared . /// /// The number of active references. public static int ReferenceCount => _instance is not null ? _instance.ReferenceCount : 0; /// - /// Gets or sets the factory method used to create the instance. + /// Gets or sets the function that creates the shared . /// /// - /// A function that returns an when invoked. + /// The function that creates the shared , or to create one with its parameterless constructor. /// - /// Thrown if attempting to change the factory after the instance has been created. + /// Thrown if the value is set after the first reference has been acquired. /// - /// This property allows for the customization of the creation process. Changing this property after the - /// has been created will throw an to prevent inconsistent states by modifying the factory method post creation. + /// Set this property at application startup, before any is created with the shared , + /// to configure its handler, proxy, or timeout. /// public static Func? Factory { @@ -61,9 +60,9 @@ public static Func? Factory { if (_instance is not null) throw new InvalidOperationException("Cannot change the factory once the HttpClient instance has been created."); - } - _factory = value; + _factory = value; + } } } } diff --git a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs similarity index 63% rename from src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs rename to src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs index c0f8c9f..0e59077 100644 --- a/src/Kampute.HttpClient.Xml/HttpRestClientXmlExtensions.cs +++ b/src/Kampute.HttpClient/Xml/HttpRestClientXmlExtensions.cs @@ -1,6 +1,6 @@ // Copyright (C) 2025 Kampute // -// This file is part of the Kampute.HttpClient.Xml package and is released under the terms of the MIT license. +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. namespace Kampute.HttpClient.Xml @@ -11,33 +11,39 @@ namespace Kampute.HttpClient.Xml using System.Threading.Tasks; /// - /// Provides extension methods for to support XML-based HTTP operations. + /// Provides extension methods for that register an XML formatter and send XML payloads. /// /// - /// This static class extends with methods tailored for handling HTTP requests and responses - /// involving XML data. It facilitates the sending and receiving of XML content by abstracting the complexities of serialization - /// and deserialization of XML to and from .NET objects. + /// registers an , which lets the client read XML responses and advertise XML in the Accept + /// header. The send helpers write their payloads with the registered , or with a new one with default settings if + /// none is registered. /// public static class HttpRestClientXmlExtensions { /// - /// Configures the to accept XML responses by adding a in its response deserializers collection. + /// Registers an with the client, or updates the registered one. /// /// The instance to configure. - /// The used for XML content deserialization. + /// An action that sets the options of the formatter, such as (optional). + /// The registered . + /// Thrown if is . /// - /// If the client already has a , this method does nothing. Otherwise, it adds a new to - /// the client's response deserializers. + /// If the client already has an , this method passes that formatter to . Otherwise, it + /// adds a new to and passes the new one. /// - public static XmlContentDeserializer AcceptXml(this HttpRestClient client) + public static XmlFormatter UseXml(this HttpRestClient client, Action? configure = null) { - var deserializer = client.ResponseDeserializers.Find(); - if (deserializer is null) + if (client is null) + throw new ArgumentNullException(nameof(client)); + + var formatter = client.ContentFormatters.Find(); + if (formatter is null) { - deserializer = new XmlContentDeserializer(); - client.ResponseDeserializers.Add(deserializer); + formatter = new XmlFormatter(); + client.ContentFormatters.Add(formatter); } - return deserializer; + configure?.Invoke(formatter); + return formatter; } /// @@ -50,28 +56,18 @@ public static XmlContentDeserializer AcceptXml(this HttpRestClient client) /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - return client.SendAsync(method, uri, new XmlContent(payload), cancellationToken); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// - /// Sends an asynchronous POST request with XML-formatted payload to the specified URI without processing the response body. + /// Sends an asynchronous request with XML-formatted payload to the specified URI without processing the response body. /// /// The instance to be used for sending the request. /// The HTTP method to use for the request. @@ -79,24 +75,13 @@ public static Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation. - /// Thrown if , or is . + /// Thrown if , , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. - public static async Task SendAsXmlAsync - ( - this HttpRestClient client, - HttpMethod method, - string uri, - object payload, - CancellationToken cancellationToken = default - ) + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. + public static Task SendAsXmlAsync(this HttpRestClient client, HttpMethod method, string uri, object payload, CancellationToken cancellationToken = default) { - if (payload is null) - throw new ArgumentNullException(nameof(payload)); - - using var _ = await client.SendAsync(method, uri, new XmlContent(payload), cancellationToken).ConfigureAwait(false); + return client.SendObjectAsync(method, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -108,14 +93,14 @@ public static async Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -126,14 +111,13 @@ public static async Task SendAsXmlAsync /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Post, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Post, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -145,14 +129,14 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -163,14 +147,13 @@ public static Task PostAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Put, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Put, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -182,14 +165,14 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation, with a result of the specified type. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); } /// @@ -200,14 +183,26 @@ public static Task PutAsXmlAsync(this HttpRestClient client, string uri, object /// The object to serialize as the XML-formatted HTTP request payload. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. - /// Thrown if or is . + /// Thrown if , or is . /// Thrown if the response status code indicates a failure. - /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. - /// Thrown if the response body is empty or its media type is not supported. - /// Thrown if the operation is canceled via the cancellation token. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, or server certificate validation. + /// Thrown if the operation is canceled via the cancellation token, or if the timeout of the underlying elapses. public static Task PatchAsXmlAsync(this HttpRestClient client, string uri, object payload, CancellationToken cancellationToken = default) { - return client.SendAsXmlAsync(HttpVerb.Patch, uri, payload, cancellationToken); + return client.SendObjectAsync(HttpVerb.Patch, uri, payload, FormatterOf(client), cancellationToken); + } + + /// + /// Returns the registered of the client, or a new one with default settings. + /// + /// The client whose formatter to return. + /// The that writes the payloads of the client. + /// Thrown if is . + private static XmlFormatter FormatterOf(HttpRestClient client) + { + return client is not null + ? client.ContentFormatters.FindOrDefault() + : throw new ArgumentNullException(nameof(client)); } } } diff --git a/src/Kampute.HttpClient.Xml/NamespaceDoc.cs b/src/Kampute.HttpClient/Xml/NamespaceDoc.cs similarity index 56% rename from src/Kampute.HttpClient.Xml/NamespaceDoc.cs rename to src/Kampute.HttpClient/Xml/NamespaceDoc.cs index c55a2d8..5fda0cb 100644 --- a/src/Kampute.HttpClient.Xml/NamespaceDoc.cs +++ b/src/Kampute.HttpClient/Xml/NamespaceDoc.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Kampute +// Copyright (C) 2025 Kampute // // This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. // See the LICENSE file in the project root for the full license text. @@ -6,8 +6,8 @@ namespace Kampute.HttpClient.Xml { /// - /// This namespace provides support for XML serialization in HTTP requests and responses, - /// specifically for handling application/xml content types using the XmlSerializer. + /// This namespace provides support for application/xml content in HTTP requests and responses, using either XmlSerializer or + /// DataContractSerializer. /// internal static class NamespaceDoc { } } diff --git a/src/Kampute.HttpClient/Xml/XmlContent.cs b/src/Kampute.HttpClient/Xml/XmlContent.cs new file mode 100644 index 0000000..65d8a8a --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlContent.cs @@ -0,0 +1,108 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Runtime.Serialization; + using System.Text; + using System.Threading.Tasks; + + /// + /// Represents HTTP content based on XML serialized from an object. + /// + /// + /// The object is serialized when the content is sent, by the serializer that selects for its runtime type. Use this class + /// to send XML with and + /// the other helpers that take an . + /// + public sealed class XmlContent : HttpContent + { + private static readonly Encoding utf8WithoutMarker = new UTF8Encoding(false); + + private readonly object _content; + + /// + /// Initializes a new instance of the class, encoded as UTF-8 without a byte order mark. + /// + /// The object to serialize into XML format. + /// Thrown if is . + public XmlContent(object content) + : this(content, utf8WithoutMarker) + { + } + + /// + /// Initializes a new instance of the class with the specified character encoding. + /// + /// The object to serialize into XML format. + /// The character encoding of the XML. + /// Thrown if or is . + public XmlContent(object content, Encoding encoding) + { + _content = content ?? throw new ArgumentNullException(nameof(content)); + Encoding = encoding ?? throw new ArgumentNullException(nameof(encoding)); + + Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Xml) + { + CharSet = encoding.WebName + }; + } + + /// + /// Gets the character encoding of the XML. + /// + /// + /// The character encoding of the XML. + /// + public Encoding Encoding { get; } + + /// + /// Gets or sets which serializer writes the object. + /// + /// + /// The serializer choice. The default is , which uses for types marked + /// with or , and + /// for any other type. + /// + public XmlSerializerKind Serializer { get; set; } + + /// + /// Gets or sets the settings of . + /// + /// + /// The settings used when writes the object, or for its defaults. They have no + /// effect when writes the object. + /// + public DataContractSerializerSettings? DataContractSettings { get; set; } + + /// + /// Serializes the object to a stream. + /// + /// The target stream. + /// The transport context. + /// A completed task, because the object is serialized synchronously. + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + XmlSerialization.Write(stream, Encoding, _content, Serializer, DataContractSettings); + return Task.CompletedTask; + } + + /// + /// Indicates that the length of the content is not known in advance. + /// + /// Always -1. + /// Always . + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlFormatter.cs b/src/Kampute.HttpClient/Xml/XmlFormatter.cs new file mode 100644 index 0000000..34c8171 --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlFormatter.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using Kampute.HttpClient.Content.Abstracts; + using System; + using System.Net.Http; + using System.Runtime.Serialization; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Reads and writes application/xml content with or . + /// + /// + /// + /// The property selects the serializer. The same rule applies when a response is read, by the requested model type, and when + /// a payload is written, by the runtime type of the payload. With the default, , a type marked with + /// or uses , and any other type + /// uses . + /// + /// + /// Register the formatter with . Change its properties before the client sends requests, because + /// a registered formatter is shared by all requests of the client. + /// + /// + public sealed class XmlFormatter : HttpContentFormatter + { + /// + /// Initializes a new instance of the class. + /// + public XmlFormatter() + : base([MediaTypeNames.Application.Xml], [MediaTypeNames.Application.Xml]) + { + } + + /// + /// Gets or sets which serializer reads and writes XML. + /// + /// + /// The serializer choice. The default is . + /// + public XmlSerializerKind Serializer { get; set; } + + /// + /// Gets or sets the settings of . + /// + /// + /// The settings used in both directions when is selected, or for its defaults. + /// They have no effect when is selected. + /// + public DataContractSerializerSettings? DataContractSettings { get; set; } + + /// + /// Asynchronously reads an object of the specified type from XML content. + /// + /// The to read. + /// The type of the object to read. + /// A token for canceling the operation. + /// A task that resolves to the object read from . + /// + /// The content is decoded with the character set of its Content-Type header, or as UTF-8 if it has none. + /// + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + var encoding = content.FindCharacterEncoding() ?? Encoding.UTF8; + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + return XmlSerialization.Read(stream, encoding, modelType, Serializer, DataContractSettings); + } + + /// + /// Creates XML content that carries the specified payload. + /// + /// The object to write. + /// The media type of the content to create. + /// An with the and of this formatter. + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new XmlContent(payload) + { + Serializer = Serializer, + DataContractSettings = DataContractSettings, + }; + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlSerialization.cs b/src/Kampute.HttpClient/Xml/XmlSerialization.cs new file mode 100644 index 0000000..4b029fa --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlSerialization.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System; + using System.IO; + using System.Runtime.Serialization; + using System.Text; + using System.Xml; + using System.Xml.Serialization; + + /// + /// Reads and writes XML with the serializer that an selects for a type. + /// + internal static class XmlSerialization + { + /// + /// Determines whether the serializer choice selects for the specified type. + /// + /// The serializer choice. + /// The type to read or write. + /// if is selected; if is. + public static bool UsesDataContract(XmlSerializerKind kind, Type type) => kind switch + { + XmlSerializerKind.DataContractSerializer => true, + XmlSerializerKind.XmlSerializer => false, + _ => type.IsDefined(typeof(DataContractAttribute), inherit: false) || type.IsDefined(typeof(CollectionDataContractAttribute), inherit: false), + }; + + /// + /// Reads an object of the specified type from a stream. + /// + /// The stream to read. + /// The character encoding of the stream. + /// The type of the object to read. + /// The serializer choice. + /// The settings of , if it is used. + /// The object read from . + public static object? Read(Stream stream, Encoding encoding, Type type, XmlSerializerKind kind, DataContractSerializerSettings? dataContractSettings) + { + using var streamReader = new StreamReader(stream, encoding); + using var xmlReader = XmlReader.Create(streamReader); + return UsesDataContract(kind, type) + ? new DataContractSerializer(type, dataContractSettings).ReadObject(xmlReader) + : new XmlSerializer(type).Deserialize(xmlReader); + } + + /// + /// Writes an object to a stream. + /// + /// The stream to write. + /// The character encoding of the output. + /// The object to write. + /// The serializer choice. + /// The settings of , if it is used. + public static void Write(Stream stream, Encoding encoding, object value, XmlSerializerKind kind, DataContractSerializerSettings? dataContractSettings) + { + using var streamWriter = new StreamWriter(stream, encoding, 4096, leaveOpen: true); + using var xmlWriter = XmlWriter.Create(streamWriter, new XmlWriterSettings + { + Encoding = encoding, + OmitXmlDeclaration = false, + CheckCharacters = true, + Indent = false, + }); + + var type = value.GetType(); + if (UsesDataContract(kind, type)) + new DataContractSerializer(type, dataContractSettings).WriteObject(xmlWriter, value); + else + new XmlSerializer(type).Serialize(xmlWriter, value); + } + } +} diff --git a/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs b/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs new file mode 100644 index 0000000..1187ff6 --- /dev/null +++ b/src/Kampute.HttpClient/Xml/XmlSerializerKind.cs @@ -0,0 +1,33 @@ +// Copyright (C) 2025 Kampute +// +// This file is part of the Kampute.HttpClient package and is released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.HttpClient.Xml +{ + using System.Runtime.Serialization; + + /// + /// Specifies which .NET serializer reads and writes XML content. + /// + /// + /// + public enum XmlSerializerKind + { + /// + /// Chooses the serializer by type: a type marked with or + /// uses , and any other type uses . + /// + Auto, + + /// + /// Uses for every type, including types marked for data contracts. + /// + XmlSerializer, + + /// + /// Uses for every type, including types without data contract attributes. + /// + DataContractSerializer, + } +} diff --git a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs deleted file mode 100644 index 2bad5a4..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/HttpRestClientXmlExtensionsTests.cs +++ /dev/null @@ -1,254 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using Kampute.HttpClient; - using Kampute.HttpClient.TestSupport; - using Moq; - using NUnit.Framework; - using System; - using System.Net; - using System.Net.Http; - using System.Net.Sockets; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; - - [TestFixture] - public class HttpRestClientXmlExtensionsTests - { - private readonly Mock _mockMessageHandler = new(); - private HttpRestClient _restClient; - - private Uri AbsoluteUrl(string url) - { - return _restClient.BaseAddress is not null - ? new Uri(_restClient.BaseAddress, url) - : new Uri(url); - } - - [SetUp] - public void Setup() - { - var httpClient = new HttpClient(_mockMessageHandler.Object, false); - _restClient = new HttpRestClient(httpClient) - { - BaseAddress = new Uri("http://api.test.com/xml"), - }; - _restClient.AcceptXml(); - } - - [TearDown] - public void Cleanup() - { - _restClient.Dispose(); - } - - [Test] - public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PostAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); - } - - [Test] - public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Put)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PutAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); - } - - [Test] - public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() - { - var payload = new TestModel { Name = "XML Test" }; - - _mockMessageHandler.MockHttpResponse(request => - { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Patch)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PatchAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); - } - - [TestCase("gzip", SocketError.HostUnreachable)] - [TestCase("gzip", SocketError.TimedOut)] - [TestCase("deflate", SocketError.HostUnreachable)] - [TestCase("deflate", SocketError.TimedOut)] - public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_RetriesSerializedPayload(string encoding, SocketError socketError) - { - var payload = new TestModel { Name = "XML Test" }; - var maxRetries = 2; - var attempts = 0; - - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); - - _mockMessageHandler.MockHttpResponse(request => - { - ++attempts; - - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); - } - - if (attempts <= maxRetries) - throw new HttpRequestException("Connection failure", new SocketException((int)socketError)); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - }); - - using var content = new XmlContent(payload); - using var compressedContent = CompressContent(content, encoding); - - using var response = await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - - Assert.That(attempts, Is.EqualTo(maxRetries + 1)); - } - - [TestCase("gzip")] - [TestCase("deflate")] - public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) - { - var payload = new TestModel { Name = "XML Test" }; - var attempts = 0; - using var cancellationTokenSource = new CancellationTokenSource(); - - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); - - _mockMessageHandler.MockHttpResponse((request, cancellationToken) => - { - ++attempts; - - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); - } - - cancellationTokenSource.Cancel(); - throw new OperationCanceledException(cancellationToken); - }); - - using var content = new XmlContent(payload); - using var compressedContent = CompressContent(content, encoding); - - Assert.ThrowsAsync - ( - Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) - ); - Assert.That(attempts, Is.EqualTo(1)); - } - - [TestCase("gzip")] - [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) - { - var payload = new TestModel { Name = "XML Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); - - var attempts = 0; - using var testHandler = new TestHttpMessageHandler - { - ResponseFactory = async (request, cancellationToken) => - { - ++attempts; - - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Content, Is.Not.Null); - Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); - } - - if (attempts == 1) - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - - return new HttpResponseMessage(HttpStatusCode.NoContent); - } - }; - using var timedOutHttpClient = new HttpClient(testHandler, disposeHandler: false) - { - Timeout = TimeSpan.FromMilliseconds(50) - }; - - using var timedOutClient = new HttpRestClient(timedOutHttpClient) - { - BaseAddress = new Uri("http://api.test.com/xml"), - }; - timedOutClient.AcceptXml(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; - - using var content = new XmlContent(payload); - using var compressedContent = CompressContent(content, encoding); - - using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); - using (Assert.EnterMultipleScope()) - { - Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); - Assert.That(attempts, Is.EqualTo(2)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj b/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj deleted file mode 100644 index 08104ef..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/Kampute.HttpClient.DataContract.Test.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - false - true - latest - enable - 1701;1702;IDE0290;IDE0028 - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs b/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs deleted file mode 100644 index 90e7c8a..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/TestModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using System; - using System.Net; - using System.Runtime.Serialization; - using System.Text; - - [DataContract] - public class TestModel - { - [DataMember] - public string? Name { get; set; } - - public override bool Equals(object? obj) - { - return obj is TestModel other && Name == other.Name; - } - - public override int GetHashCode() - { - return HashCode.Combine(Name); - } - - public string ToXmlString(Encoding encoding) - { - return $"" - + "" - + Element(nameof(Name), Name) - + ""; - - static string Element(string name, string? value) - { - return value is null - ? $"<{name} xsi:nil=\"true\"/>" - : $"<{name}>{WebUtility.HtmlEncode(value)}"; - } - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs deleted file mode 100644 index 9b5397c..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentDeserializerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new XmlContentDeserializer(); - - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.UTF8; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.BigEndianUnicode; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs b/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs deleted file mode 100644 index 5cf4bb6..0000000 --- a/tests/Kampute.HttpClient.DataContract.Test/XmlContentTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Kampute.HttpClient.DataContract.Test -{ - using NUnit.Framework; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentTests - { - [Test] - public async Task WithDefaultEncoding_SetsContentCorrectly() - { - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(Encoding.UTF8); - - using var xmlContent = new XmlContent(model); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(Encoding.UTF8.WebName)); - } - } - - [Test] - public async Task WithCustomEncoding_SetsContentCorrectly() - { - var encoding = Encoding.BigEndianUnicode; - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(encoding); - - using var xmlContent = new XmlContent(model, encoding); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs index 98cf39c..4b11534 100644 --- a/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Json.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,12 +2,17 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; + using System.Net.Http.Headers; using System.Net.Sockets; + using System.Text; + using System.Text.Json; using System.Threading; using System.Threading.Tasks; using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; @@ -33,8 +38,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/json"), }; - _restClient.AcceptJson(TestModel.JsonOption); - _restClient.SetJsonSerializerOptions(TestModel.JsonOption); + _restClient.UseJson(TestModel.JsonOption); } [TearDown] @@ -70,6 +74,19 @@ public async Task PostAsJsonAsync_InvokesHttpClientCorrectly() Assert.That(result, Is.EqualTo(payload)); } + [Test] + public async Task GetAsync_WithJsonMediaTypeInDifferentCase_DeserializesResponse() + { + var expected = new TestModel { Name = "JSON Test" }; + var content = new StringContent(expected.ToJsonString(), Encoding.UTF8); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("Application/JSON; charset=utf-8"); + _mockMessageHandler.MockHttpResponse(HttpStatusCode.OK, content); + + var result = await _restClient.GetAsync("/resource"); + + Assert.That(result, Is.EqualTo(expected)); + } + [Test] public async Task PutAsJsonAsync_InvokesHttpClientCorrectly() { @@ -134,7 +151,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -173,7 +190,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -199,17 +216,17 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesRetryPolicy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -240,8 +257,8 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.AcceptJson(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.UseJson(); + timedOutClient.RetryPolicy = mockRetryPolicy.Object; using var content = new JsonContent(payload) { @@ -251,13 +268,79 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(attempts, Is.EqualTo(2)); } } + + [Test] + public void UseJson_RegistersOneFormatterAndUpdatesItsOptions() + { + var options = new JsonSerializerOptions(); + + var formatter = _restClient.UseJson(options); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.Options, Is.SameAs(options)); + Assert.That(_restClient.ContentFormatters.OfType().Single(), Is.SameAs(formatter)); + } + } + + [Test] + public async Task UseJson_OptionsApplyToRequestAndResponse() + { + _restClient.UseJson(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"name\":\"Echo\"}", Encoding.UTF8, MediaTypeNames.Application.Json), + }; + }); + + var result = await _restClient.PostAsJsonAsync("/echo", new TestModel { Name = "JSON Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo("{\"name\":\"JSON Test\"}")); + Assert.That(result, Is.EqualTo(new TestModel { Name = "Echo" })); + } + } + + [Test] + public async Task PostAsJsonAsync_WithoutRegistration_SendsWithDefaultOptions() + { + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + await client.PostAsJsonAsync("/models", new TestModel { Name = "JSON Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new TestModel { Name = "JSON Test" }.ToJsonString())); + Assert.That(client.ContentFormatters, Is.Empty); + } + } + + [Test] + public void PostAsJsonAsync_WithNullPayload_ThrowsBeforeReturningTask() + { + Assert.Throws(() => _restClient.PostAsJsonAsync("/models", null!)); + } } } diff --git a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs deleted file mode 100644 index be30378..0000000 --- a/tests/Kampute.HttpClient.Json.Test/JsonContentDeserializerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Kampute.HttpClient.Json.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class JsonContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new JsonContentDeserializer(); - - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Options = TestModel.JsonOption }; - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs new file mode 100644 index 0000000..5c9d274 --- /dev/null +++ b/tests/Kampute.HttpClient.Json.Test/JsonFormatterTests.cs @@ -0,0 +1,100 @@ +namespace Kampute.HttpClient.Json.Test +{ + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class JsonFormatterTests + { + [Test] + public void MediaTypes_AreApplicationJsonInBothDirections() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaType_ReturnTrue() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaTypeInDifferentCase_ReturnTrue() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("Application/JSON", typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite("Application/JSON", typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForUnsupportedMediaType_ReturnFalse() + { + var formatter = new JsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + } + } + + [Test] + public async Task ReadAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); + var formatter = new JsonFormatter { Options = TestModel.JsonOption }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); + var formatter = new JsonFormatter { Options = TestModel.JsonOption }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task Write_CreatesJsonContentWithTheFormatterOptions() + { + var options = TestModel.JsonOption; + var formatter = new JsonFormatter { Options = options }; + var model = new TestModel { Name = "Test" }; + + using var content = formatter.Write(model, MediaTypeNames.Application.Json); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.TypeOf()); + Assert.That(((JsonContent)content).Options, Is.SameAs(options)); + Assert.That(await content.ReadAsStringAsync(), Is.EqualTo(model.ToJsonString())); + } + } + } +} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj new file mode 100644 index 0000000..fb10b1f --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/Kampute.HttpClient.NetFramework.Test.csproj @@ -0,0 +1,22 @@ + + + + net48 + false + true + latest + enable + 1701;1702;IDE0290;IDE0028 + + + + + + + + + + + + + diff --git a/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs new file mode 100644 index 0000000..dac7aa8 --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/RetryWithContentTests.cs @@ -0,0 +1,95 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.Resilience; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Net.Sockets; + using System.Threading.Tasks; + + [TestFixture] + public class RetryWithContentTests + { + private const string Payload = "test payload"; + + [Test] + public async Task OnConnectionFailure_WithStringContent_RetriesWithSameBody() + { + using var handler = new TestHttpMessageHandler(request => + { + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); + + using var content = new StringContent(Payload); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + [TestCase("gzip")] + [TestCase("deflate")] + public async Task OnConnectionFailure_WithCompressedContent_RetriesWithSameBody(string encoding) + { + using var handler = new TestHttpMessageHandler(request => + { + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Content!.Headers.ContentEncoding, Does.Contain(encoding)); + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + } + return Attempt(request) == 1 ? throw ConnectionFailure() : new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); + + using var content = new StringContent(Payload); + using var compressedContent = encoding == "gzip" ? (HttpContent)content.AsGzip() : content.AsDeflate(); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", compressedContent); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + [Test] + public async Task On401Response_WithStringContent_RetriesPostWithSameBody() + { + var authorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "token"); + + using var handler = new TestHttpMessageHandler(request => + { + Assert.That(TestHttpMessageHandler.ReadContent(request.Content!), Is.EqualTo(Payload)); + return authorization.Equals(request.Headers.Authorization) + ? new HttpResponseMessage(HttpStatusCode.OK) + : new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + using var client = CreateClient(handler); + using var unauthorizedHandler = new HttpError401Handler((_, _) => Task.FromResult(authorization)); + client.ErrorHandlers.Add(unauthorizedHandler); + + using var content = new StringContent(Payload); + using var response = await client.SendAsync(HttpMethod.Post, "/resource", content); + + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) + { + return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + private static int Attempt(HttpRequestMessage request) => request.GetCloneGeneration() + 1; + + private static HttpRequestException ConnectionFailure() + { + return new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + } + } +} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs new file mode 100644 index 0000000..feb3376 --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/TargetSpecificBehaviorTests.cs @@ -0,0 +1,128 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.Resilience; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class TargetSpecificBehaviorTests + { + [Test] + public async Task On429Response_IsHandledByHttpError429Handler() + { + using var handler = new TestHttpMessageHandler(request => request.GetCloneGeneration() == 0 + ? new HttpResponseMessage((HttpStatusCode)429) + : new HttpResponseMessage(HttpStatusCode.OK)); + using var client = CreateClient(handler); + client.ErrorHandlers.Add(new HttpError429Handler + { + OnRetryPolicy = (_, _) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy() + }); + + using var response = await client.SendAsync(HttpMethod.Get, "/rate-limited/resource"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(handler.Attempts, Is.EqualTo(2)); + } + } + + [Test] + public async Task AsGzip_WhenOriginalLengthIsKnown_ReportsLengthOfCompressedBuffer() + { + using var originalContent = new StringContent("Original content"); + _ = originalContent.Headers.ContentLength; + + using var compressedContent = originalContent.AsGzip(); + + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + + // HttpClientHandler buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } + + [Test] + public async Task HttpVerbPatch_SendsPatchMethod() + { + var sentMethod = default(string); + using var handler = new TestHttpMessageHandler(request => + { + sentMethod = request.Method.Method; + return new HttpResponseMessage(HttpStatusCode.OK); + }); + using var client = CreateClient(handler); + + using var response = await client.SendAsync(HttpVerb.Patch, "/resource"); + + Assert.That(sentMethod, Is.EqualTo("PATCH")); + } + + [Test] + public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + using var handler = new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + using var client = CreateClient(handler); + + var exception = Assert.ThrowsAsync(() => client.PostAsFormAsync("/resource", [new KeyValuePair("name", "value")])); + + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void OnErrorResponse_ResponseMessageKeepsHeadersButContentIsDisposed() + { + using var handler = new TestHttpMessageHandler(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Error details") }; + response.Headers.Add("X-Error-Id", "42"); + return response; + }); + using var client = CreateClient(handler); + + var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + + Assert.That(exception.ResponseMessage, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.ResponseMessage!.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); + Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + } + } + + [Test] + public async Task GetToStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + var body = new byte[1024]; + new Random(1).NextBytes(body); + + using var handler = new TestHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) }); + using var client = new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false) { MaxResponseContentBufferSize = 16 }) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + using var resultStream = new System.IO.MemoryStream(); + await client.GetToStreamAsync("/resource", resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(body)); + } + + private static HttpRestClient CreateClient(TestHttpMessageHandler handler) + { + return new HttpRestClient(new System.Net.Http.HttpClient(handler, disposeHandler: false)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + } +} diff --git a/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs b/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs new file mode 100644 index 0000000..1cba96e --- /dev/null +++ b/tests/Kampute.HttpClient.NetFramework.Test/TestHttpMessageHandler.cs @@ -0,0 +1,62 @@ +namespace Kampute.HttpClient.NetFramework.Test +{ + using System; + using System.IO; + using System.IO.Compression; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// A message handler that answers each request with a delegate. It sits under a real , + /// so the .NET Framework client code that sends and disposes request content still runs. + /// + public sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + public TestHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory ?? throw new ArgumentNullException(nameof(responseFactory)); + } + + public int Attempts { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ++Attempts; + try + { + return Task.FromResult(_responseFactory(request)); + } + catch (Exception error) + { + // A real handler reports failures through the returned task, not by throwing synchronously. + return Task.FromException(error); + } + } + + /// + /// Reads the request body the way a real handler would when it sends the request. + /// + public static string ReadContent(HttpContent content) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + + using var buffer = new MemoryStream(); + content.CopyToAsync(buffer).GetAwaiter().GetResult(); + buffer.Position = 0; + + using Stream decoded = content.Headers.ContentEncoding.Count == 0 ? buffer : content.Headers.ContentEncoding.ToString() switch + { + "gzip" => new GZipStream(buffer, CompressionMode.Decompress), + "deflate" => new DeflateStream(buffer, CompressionMode.Decompress), + _ => throw new InvalidOperationException("Unsupported encoding") + }; + using var reader = new StreamReader(decoded, Encoding.UTF8); + return reader.ReadToEnd(); + } + } +} diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs index 6028e11..d753690 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/HttpRestClientJsonExtensionsTests.cs @@ -2,12 +2,17 @@ { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; using Moq; + using Newtonsoft.Json; + using Newtonsoft.Json.Serialization; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; + using System.Text; using System.Threading; using System.Threading.Tasks; using static Kampute.HttpClient.TestSupport.CompressedContentHelpers; @@ -33,8 +38,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/json"), }; - _restClient.AcceptJson(TestModel.JsonSettings); - _restClient.SetJsonSerializerSettings(TestModel.JsonSettings); + _restClient.UseNewtonsoftJson(TestModel.JsonSettings); } [TearDown] @@ -134,7 +138,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -154,7 +158,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedJsonContent_Retrie return new HttpResponseMessage(HttpStatusCode.NoContent); }); - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -173,7 +177,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -190,7 +194,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr throw new OperationCanceledException(cancellationToken); }); - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -199,17 +203,17 @@ public void SendAsync_OnCallerCancellation_WithCompressedJsonContent_DoesNotRetr Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_UsesRetryPolicy(string encoding) { var payload = new TestModel { Name = "JSON Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -240,10 +244,10 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.AcceptJson(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.UseNewtonsoftJson(); + timedOutClient.RetryPolicy = mockRetryPolicy.Object; - using var content = new JsonContent(payload) + using var content = new NewtonsoftJsonContent(payload) { Settings = TestModel.JsonSettings }; @@ -251,13 +255,102 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedJsonContent_Uses using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(attempts, Is.EqualTo(2)); } } + + [Test] + public void UseNewtonsoftJson_RegistersOneFormatterAndUpdatesItsSettings() + { + var settings = new JsonSerializerSettings(); + + var formatter = _restClient.UseNewtonsoftJson(settings); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.Settings, Is.SameAs(settings)); + Assert.That(_restClient.ContentFormatters.OfType().Single(), Is.SameAs(formatter)); + } + } + + [Test] + public async Task UseNewtonsoftJson_SettingsApplyToRequestAndResponse() + { + _restClient.UseNewtonsoftJson(new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), MissingMemberHandling = MissingMemberHandling.Error }); + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"name\":\"Echo\"}", Encoding.UTF8, MediaTypeNames.Application.Json), + }; + }); + + var result = await _restClient.PostAsJsonAsync("/echo", new TestModel { Name = "JSON Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo("{\"name\":\"JSON Test\"}")); + Assert.That(result, Is.EqualTo(new TestModel { Name = "Echo" })); + } + } + + [Test] + public async Task PostAsJsonAsync_WithoutRegistration_SendsWithDefaultSettings() + { + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + var sentBody = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + await client.PostAsJsonAsync("/models", new TestModel { Name = "JSON Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new TestModel { Name = "JSON Test" }.ToJsonString())); + Assert.That(client.ContentFormatters, Is.Empty); + } + } + + [Test] + public void PostAsJsonAsync_WithNullPayload_ThrowsBeforeReturningTask() + { + Assert.Throws(() => _restClient.PostAsJsonAsync("/models", null!)); + } + + [Test] + public async Task PostAsJsonAsync_WithBothJsonFormattersRegistered_EachPackageUsesItsOwnFormatter() + { + using var client = new HttpRestClient(new HttpClient(_mockMessageHandler.Object, false)) + { + BaseAddress = new Uri("http://api.test.com/json"), + }; + Kampute.HttpClient.Json.HttpRestClientJsonExtensions.UseJson(client, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + client.UseNewtonsoftJson(new JsonSerializerSettings { ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() } }); + var sentBodies = new System.Collections.Generic.List(); + _mockMessageHandler.MockHttpResponse(request => + { + sentBodies.Add(request.Content!.ReadAsStringAsync().Result); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + var payload = new { FullName = "JSON Test" }; + + await Kampute.HttpClient.Json.HttpRestClientJsonExtensions.PostAsJsonAsync(client, "/models", payload); + await client.PostAsJsonAsync("/models", payload); + + Assert.That(sentBodies, Is.EqualTo(new[] { "{\"fullName\":\"JSON Test\"}", "{\"full_name\":\"JSON Test\"}" })); + } } } diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs deleted file mode 100644 index a377b6b..0000000 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentDeserializerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Kampute.HttpClient.NewtonsoftJson.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class JsonContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new JsonContentDeserializer(); - - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Json)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new JsonContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() - { - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); - var deserializer = new JsonContentDeserializer { Settings = TestModel.JsonSettings }; - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj index 6dfbb3a..2fac162 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/Kampute.HttpClient.NewtonsoftJson.Test.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs similarity index 84% rename from tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs rename to tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs index ecbdccb..10ccddf 100644 --- a/tests/Kampute.HttpClient.NewtonsoftJson.Test/JsonContentTests.cs +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonContentTests.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; [TestFixture] - public class JsonContentTests + public class NewtonsoftJsonContentTests { [Test] public async Task SetsContentCorrectly() @@ -13,7 +13,7 @@ public async Task SetsContentCorrectly() var model = new TestModel { Name = "Test" }; var expectedString = model.ToJsonString(); - using var jsonContent = new JsonContent(model) { Settings = TestModel.JsonSettings }; + using var jsonContent = new NewtonsoftJsonContent(model) { Settings = TestModel.JsonSettings }; var jsonString = await jsonContent.ReadAsStringAsync(); diff --git a/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs new file mode 100644 index 0000000..b398a29 --- /dev/null +++ b/tests/Kampute.HttpClient.NewtonsoftJson.Test/NewtonsoftJsonFormatterTests.cs @@ -0,0 +1,100 @@ +namespace Kampute.HttpClient.NewtonsoftJson.Test +{ + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class NewtonsoftJsonFormatterTests + { + [Test] + public void MediaTypes_AreApplicationJsonInBothDirections() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + Assert.That(formatter.GetWritableMediaTypes(typeof(TestModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Json })); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaType_ReturnTrue() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForSupportedMediaTypeInDifferentCase_ReturnTrue() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead("Application/JSON", typeof(TestModel)), Is.True); + Assert.That(formatter.CanWrite("Application/JSON", typeof(TestModel)), Is.True); + } + } + + [Test] + public void CanReadAndCanWrite_ForUnsupportedMediaType_ReturnFalse() + { + var formatter = new NewtonsoftJsonFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Xml, typeof(TestModel)), Is.False); + } + } + + [Test] + public async Task ReadAsync_WithUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.UTF8, MediaTypeNames.Application.Json); + var formatter = new NewtonsoftJsonFormatter { Settings = TestModel.JsonSettings }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithNonUtf8EncodedJsonContent_ReturnsCorrectObject() + { + var expected = new TestModel { Name = "Test" }; + using var content = new StringContent(expected.ToJsonString(), Encoding.BigEndianUnicode, MediaTypeNames.Application.Json); + var formatter = new NewtonsoftJsonFormatter { Settings = TestModel.JsonSettings }; + + var result = await formatter.ReadAsync(content, typeof(TestModel)) as TestModel; + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task Write_CreatesJsonContentWithTheFormatterSettings() + { + var settings = TestModel.JsonSettings; + var formatter = new NewtonsoftJsonFormatter { Settings = settings }; + var model = new TestModel { Name = "Test" }; + + using var content = formatter.Write(model, MediaTypeNames.Application.Json); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.TypeOf()); + Assert.That(((NewtonsoftJsonContent)content).Settings, Is.SameAs(settings)); + Assert.That(await content.ReadAsStringAsync(), Is.EqualTo(model.ToJsonString())); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs b/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs new file mode 100644 index 0000000..403cc13 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ArgumentValidationTests.cs @@ -0,0 +1,60 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Utilities; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class ArgumentValidationTests + { + private static readonly KeyValuePair[] FormPayload = [new("key", "value")]; + + private static IEnumerable InvalidCalls() + { + yield return Case("SendAsync with null method", c => c.SendAsync(null!, "/resource")); + yield return Case("SendAsync with null uri", c => c.SendAsync(HttpMethod.Get, null!)); + yield return Case("SendAsync with null method", c => c.SendAsync(null!, "/resource")); + yield return Case("SendAsync with null uri", c => c.SendAsync(HttpMethod.Get, null!)); + yield return Case("HeadAsync with null uri", c => c.HeadAsync(null!)); + yield return Case("OptionsAsync with null uri", c => c.OptionsAsync(null!)); + yield return Case("GetAsync with null uri", c => c.GetAsync(null!)); + yield return Case("GetAsByteArrayAsync with null uri", c => c.GetAsByteArrayAsync(null!)); + yield return Case("GetAsStringAsync with null uri", c => c.GetAsStringAsync(null!)); + yield return Case("GetAsStreamAsync with null uri", c => c.GetAsStreamAsync(null!)); + yield return Case("GetToStreamAsync with null uri", c => c.GetToStreamAsync(null!, Stream.Null)); + yield return Case("GetToStreamAsync with null stream", c => c.GetToStreamAsync("/resource", null!)); + yield return Case("PostAsync with null uri", c => c.PostAsync(null!, null)); + yield return Case("PutAsync with null uri", c => c.PutAsync(null!, null)); + yield return Case("PatchAsync with null uri", c => c.PatchAsync(null!, null)); + yield return Case("DeleteAsync with null uri", c => c.DeleteAsync(null!)); + yield return Case("DownloadAsync with null method", c => c.DownloadAsync(null!, "/resource", null, _ => Stream.Null)); + yield return Case("DownloadAsync with null stream provider", c => c.DownloadAsync(HttpMethod.Get, "/resource", null, null!)); + yield return Case("SendAsFormAsync with null method", c => c.SendAsFormAsync(null!, "/resource", FormPayload)); + yield return Case("SendAsFormAsync with null uri", c => c.SendAsFormAsync(HttpMethod.Post, null!, FormPayload)); + yield return Case("PerformAsync with null action", c => c.WithScope().PerformAsync(null!)); + yield return Case("PerformAsync with null function", c => c.WithScope().PerformAsync(null!)); + + static TestCaseData Case(string name, Func call) => new TestCaseData(call).SetName(name); + } + + [TestCaseSource(nameof(InvalidCalls))] + public void InvalidArgument_ThrowsBeforeReturningTask(Func call) + { + using var client = new HttpRestClient(new HttpClient(), disposeClient: true); + + Assert.Throws(() => call(client)); + } + + [Test] + public void AsyncUpdateThrottle_TryUpdateAsync_WithNullUpdater_ThrowsBeforeReturningTask() + { + using var throttle = new AsyncUpdateThrottle(null); + + Assert.Throws(() => throttle.TryUpdateAsync(null!)); + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs new file mode 100644 index 0000000..5750f16 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Content/Abstracts/HttpContentFormatterTests.cs @@ -0,0 +1,123 @@ +namespace Kampute.HttpClient.Test.Content.Abstracts +{ + using Kampute.HttpClient.Content.Abstracts; + using NUnit.Framework; + using System; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class HttpContentFormatterTests + { + [Test] + public void MediaTypeQueries_HonorTheTypeFiltersAndIgnoreCase() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(string)), Is.EqualTo(new[] { "text/x-read" })); + Assert.That(formatter.GetReadableMediaTypes(typeof(int)), Is.Empty); + Assert.That(formatter.GetWritableMediaTypes(typeof(string)), Is.EqualTo(new[] { "text/x-write" })); + Assert.That(formatter.GetWritableMediaTypes(typeof(int)), Is.Empty); + Assert.That(formatter.CanRead("TEXT/X-READ", typeof(string)), Is.True); + Assert.That(formatter.CanRead("text/x-write", typeof(string)), Is.False); + Assert.That(formatter.CanWrite("TEXT/X-WRITE", typeof(string)), Is.True); + Assert.That(formatter.CanWrite("text/x-read", typeof(string)), Is.False); + Assert.That(formatter.CanWrite("text/x-write", typeof(int)), Is.False); + } + } + + [Test] + public void Write_WithUnsupportedMediaTypeOrPayload_ThrowsNotSupportedException() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.Write("payload", "text/x-read")); + Assert.Throws(() => formatter.Write(42, "text/x-write")); + } + } + + [Test] + public void Write_WithNullArgument_ThrowsArgumentNullException() + { + var formatter = new StringOnlyFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.Write(null!, "text/x-write")); + Assert.Throws(() => formatter.Write("payload", null!)); + } + } + + [Test] + public void ReadAsync_WithNullArgument_ThrowsBeforeReturningTask() + { + var formatter = new StringOnlyFormatter(); + using var content = new StringContent("text"); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.ReadAsync(null!, typeof(string))); + Assert.Throws(() => formatter.ReadAsync(content, null!)); + } + } + + [Test] + public async Task ReadAsyncAndWrite_DelegateToTheOverrides() + { + var formatter = new StringOnlyFormatter(); + + using var content = formatter.Write("payload", "text/x-write"); + var result = await formatter.ReadAsync(content, typeof(string)); + + Assert.That(result, Is.EqualTo("payload")); + } + + [Test] + public void ReadAsyncAndWrite_WithoutOverrides_ThrowNotSupportedException() + { + var formatter = new NoOverridesFormatter(); + using var content = new StringContent("text"); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => formatter.ReadAsync(content, typeof(string))); + Assert.Throws(() => formatter.Write("payload", "text/x-write")); + } + } + + private sealed class StringOnlyFormatter : HttpContentFormatter + { + public StringOnlyFormatter() + : base(["text/x-read"], ["text/x-write"]) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(string); + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(string); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return await content.ReadAsStringAsync(cancellationToken); + } + + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new StringContent((string)payload); + } + } + + private sealed class NoOverridesFormatter : HttpContentFormatter + { + public NoOverridesFormatter() + : base(["text/x-read"], ["text/x-write"]) + { + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs index fb61952..9134801 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/DeflateCompressedContentTests.cs @@ -34,5 +34,27 @@ public async Task DeflateCompressedContent_CompressesDataCorrectly() Assert.That(reader.ReadToEnd(), Is.EqualTo(text)); } + + [Test] + public async Task Constructor_WithOriginalLengthAndHash_DoesNotCopyThem() + { + using var originalContent = new StringContent("Original content"); + originalContent.Headers.ContentMD5 = [1, 2, 3, 4]; + _ = originalContent.Headers.ContentLength; + + using var compressedContent = new DeflateCompressedContent(originalContent, CompressionLevel.Optimal); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + Assert.That(compressedContent.Headers.ContentMD5, Is.Null); + } + + // HttpClient on .NET Framework buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } } } diff --git a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs index 0e3a22f..e33a39a 100644 --- a/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs +++ b/tests/Kampute.HttpClient.Test/Content/Compression/GzipCompressedContentTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Content.Compression; using NUnit.Framework; + using System; using System.IO; using System.IO.Compression; using System.Net.Http; @@ -34,5 +35,37 @@ public async Task GzipCompressedContent_CompressesDataCorrectly() Assert.That(reader.ReadToEnd(), Is.EqualTo(text)); } + + [Test] + public async Task Constructor_WithOriginalLengthAndHash_DoesNotCopyThem() + { + using var originalContent = new StringContent("Original content"); + originalContent.Headers.ContentMD5 = [1, 2, 3, 4]; + _ = originalContent.Headers.ContentLength; + + using var compressedContent = new GzipCompressedContent(originalContent, CompressionLevel.Optimal); + + using (Assert.EnterMultipleScope()) + { + Assert.That(compressedContent.Headers.ContentLength, Is.Null); + Assert.That(compressedContent.Headers.ContentMD5, Is.Null); + } + + // HttpClient on .NET Framework buffers content of unknown length and then sends the length of the buffer. + await compressedContent.LoadIntoBufferAsync(); + var compressedBytes = await compressedContent.ReadAsByteArrayAsync(); + + Assert.That(compressedContent.Headers.ContentLength, Is.EqualTo(compressedBytes.Length)); + } + + [Test] + public void Dispose_DisposesOriginalContent() + { + using var originalContent = new StringContent("Original content"); + + new GzipCompressedContent(originalContent, CompressionLevel.Optimal).Dispose(); + + Assert.ThrowsAsync(() => originalContent.ReadAsStringAsync()); + } } } diff --git a/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs b/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs new file mode 100644 index 0000000..98b4bd8 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Content/NonOwningContentTests.cs @@ -0,0 +1,53 @@ +namespace Kampute.HttpClient.Test.Content +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.TestSupport; + using NUnit.Framework; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class NonOwningContentTests + { + [Test] + public async Task NonOwningContent_SendsOriginalHeadersAndBody() + { + var text = "Original content"; + + using var originalContent = new StringContent(text, Encoding.UTF8, MediaTypeNames.Text.Plain); + using var nonOwningContent = new NonOwningContent(originalContent); + + using (Assert.EnterMultipleScope()) + { + Assert.That(nonOwningContent.Headers.ContentType, Is.EqualTo(originalContent.Headers.ContentType)); + Assert.That(nonOwningContent.Headers.ContentLength, Is.EqualTo(originalContent.Headers.ContentLength)); + Assert.That(await nonOwningContent.ReadAsStringAsync(), Is.EqualTo(text)); + } + } + + [Test] + public async Task Dispose_LeavesOriginalContentUsable() + { + var text = "Original content"; + + using var originalContent = new StringContent(text); + new NonOwningContent(originalContent).Dispose(); + + Assert.That(await originalContent.ReadAsStringAsync(), Is.EqualTo(text)); + } + + [Test] + public void IsReusable_ReflectsOriginalContent() + { + using var reusableContent = new NonOwningContent(new StringContent("Original content")); + using var nonReusableContent = new NonOwningContent(new StreamContent(new TestStream(seekable: false))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reusableContent.IsReusable(), Is.True); + Assert.That(nonReusableContent.IsReusable(), Is.False); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs new file mode 100644 index 0000000..4314079 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/DynamicHttpErrorHandlerTests.cs @@ -0,0 +1,147 @@ +namespace Kampute.HttpClient.Test.ErrorHandlers +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; + using Moq; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Net; + using System.Net.Http; + using System.Net.Sockets; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class DynamicHttpErrorHandlerTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public void OnErrorResponse_InvokesDelegateWithResponseContext() + { + var seenStatusCodes = new List(); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + seenStatusCodes.Add(ctx.Response.StatusCode); + return Task.FromResult(HttpErrorHandlerResult.NoRetry); + })); + + _mockMessageHandler.MockHttpResponse(HttpStatusCode.Conflict); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.Conflict)); + Assert.That(seenStatusCodes, Is.EqualTo(new[] { HttpStatusCode.Conflict })); + } + } + + [Test] + public void OnErrorResponse_WithHandBuiltRetryRequest_KeepsRetryBudget() + { + const int maxAttempts = 10; + var backoff = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); + + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler(async (ctx, ct) => + { + var decision = await ctx.ScheduleRetryAsync(backoff, backoff.CreateSession, ct); + if (decision.RequestToRetry is null) + return decision; + + decision.RequestToRetry.Dispose(); + return HttpErrorHandlerResult.Retry(new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri)); + })); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => Interlocked.Increment(ref attempts) < maxAttempts + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : new HttpResponseMessage(HttpStatusCode.OK)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(3)); + } + } + + [Test] + public async Task OnErrorResponse_WithHandBuiltRetryRequestReusingOriginalContent_SendsOriginalBodyOnLaterRetries() + { + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = ctx.Request.Content }; + return Task.FromResult(HttpErrorHandlerResult.Retry(retryRequest)); + })); + + var sentBodies = MockServiceUnavailableThenConnectionFailureThenSuccess(); + + using var response = await _client.SendAsync(HttpMethod.Post, "/resource", new StringContent("original")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(sentBodies, Is.EqualTo(new[] { "original", "original", "original" })); + } + } + + [Test] + public async Task OnErrorResponse_WithHandBuiltRetryRequestWithNewContent_SendsNewBodyOnLaterRetries() + { + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); + _client.ErrorHandlers.Add(new DynamicHttpErrorHandler((ctx, _) => + { + var retryRequest = new HttpRequestMessage(ctx.Request.Method, ctx.Request.RequestUri) { Content = new StringContent("replacement") }; + return Task.FromResult(HttpErrorHandlerResult.Retry(retryRequest)); + })); + + var sentBodies = MockServiceUnavailableThenConnectionFailureThenSuccess(); + + using var response = await _client.SendAsync(HttpMethod.Post, "/resource", new StringContent("original")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(sentBodies, Is.EqualTo(new[] { "original", "replacement", "replacement" })); + } + } + + private List MockServiceUnavailableThenConnectionFailureThenSuccess() + { + var sentBodies = new List(); + _mockMessageHandler.MockHttpResponse(request => + { + sentBodies.Add(request.Content!.ReadAsStringAsync().Result); + return sentBodies.Count switch + { + 1 => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + 2 => throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)), + _ => new HttpResponseMessage(HttpStatusCode.OK), + }; + }); + return sentBodies; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs index a3d76bf..6e6c44e 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError401HandlerTests.cs @@ -80,6 +80,106 @@ public async Task On401Response_BySuccessfulAuthentication_AuthorizesRequests() } } + [Test] + public async Task On401Response_WhileRequestsAreCreatedConcurrently_AuthorizesAllRequestsWithNewToken() + { + var newAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token"); + var numberOfRequests = 50; + + using var unauthorizeHandler = new HttpError401Handler(async (_, ct) => + { + await Task.Delay(50, ct); + return newAuthorization; + }); + + _client.ErrorHandlers.Add(unauthorizeHandler); + + _mockMessageHandler.MockHttpResponse(request => + { + if (request.Headers.Authorization?.Scheme == newAuthorization.Scheme && request.Headers.Authorization?.Parameter == newAuthorization.Parameter) + return new HttpResponseMessage(HttpStatusCode.OK); + + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + }); + + using var stopLoops = new CancellationTokenSource(); + var loops = Enumerable.Range(1, Environment.ProcessorCount).Select(i => Task.Run(async () => + { + while (!stopLoops.IsCancellationRequested) + { + using var response = await _client.SendAsync(HttpMethod.Get, $"/protected/loop{i}"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + })).ToArray(); + + var requests = Enumerable.Range(1, numberOfRequests).Select(i => _client.SendAsync(HttpMethod.Get, $"/protected/resource{i}")).ToArray(); + try + { + await Task.WhenAll(requests); + } + finally + { + stopLoops.Cancel(); + await Task.WhenAll(loops); + } + + using (Assert.EnterMultipleScope()) + { + foreach (var request in requests) + Assert.That(request.Result.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + Assert.That(_client.DefaultRequestHeaders.Authorization, Is.EqualTo(newAuthorization)); + } + } + + [Test] + public async Task On401Response_ArrivingAfterRefreshCompleted_DoesNotAuthenticateAgain() + { + var oldAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "old-token"); + var newAuthorization = new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token"); + var delayedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var numberOfInvokes = 0; + + using var testHandler = new TestHttpMessageHandler + { + ResponseFactory = async (request, _) => + { + if (newAuthorization.Equals(request.Headers.Authorization)) + return new HttpResponseMessage(HttpStatusCode.OK); + + if (request.RequestUri!.AbsolutePath == "/delayed") + await delayedResponse.Task; + + return new HttpResponseMessage(HttpStatusCode.Unauthorized); + } + }; + using var httpClient = new HttpClient(testHandler, false); + using var client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + client.DefaultRequestHeaders.Authorization = oldAuthorization; + + using var unauthorizeHandler = new HttpError401Handler((_, _) => + { + Interlocked.Increment(ref numberOfInvokes); + return Task.FromResult(newAuthorization); + }); + client.ErrorHandlers.Add(unauthorizeHandler); + + var delayedRequest = client.SendAsync(HttpMethod.Get, "/delayed"); + using var immediateResponse = await client.SendAsync(HttpMethod.Get, "/immediate"); + delayedResponse.SetResult(true); + using var delayedRequestResponse = await delayedRequest; + + using (Assert.EnterMultipleScope()) + { + Assert.That(numberOfInvokes, Is.EqualTo(1)); + Assert.That(immediateResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(delayedRequestResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + } + [Test] public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpError() { @@ -102,5 +202,32 @@ public async Task On401Response_ByFailedAuthentication_ThrowsUnauthorizedHttpErr Assert.That(caughtException, Is.Not.Null); Assert.That(caughtException.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } + + [Test] + public void On401Response_WithNonReusableContent_ThrowsUnauthorizedHttpErrorWithoutAuthenticating() + { + var numberOfInvokes = 0; + + using var unauthorizeHandler = new HttpError401Handler((_, _) => + { + Interlocked.Increment(ref numberOfInvokes); + return Task.FromResult(new AuthenticationHeaderValue(AuthSchemes.Bearer, "new-token")); + }); + + _client.ErrorHandlers.Add(unauthorizeHandler); + + _mockMessageHandler.MockHttpResponse(request => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + using var content = new StreamContent(new TestStream(seekable: false)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Post, "/protected/resource", content)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + Assert.That(numberOfInvokes, Is.Zero); + Assert.That(_client.DefaultRequestHeaders.Authorization, Is.Null); + } + } } } diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs index 8beefd8..ab54aa6 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError429HandlerTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -39,10 +40,10 @@ public async Task On429Response_WithRateLimitResetHeader_RetriesRequestAfterSpec var actualResetTime = default(DateTimeOffset?); var tooManyRequestsHandler = new HttpError429Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualResetTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(tooManyRequestsHandler); @@ -86,11 +87,36 @@ public async Task On429Response_WithoutRateLimitResetHeader_DoesNotRetry() } [Test] - public async Task On429Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public void On429Response_WithOutOfRangeRateLimitResetHeader_ThrowsHttpResponseException() + { + var tooManyRequestsHandler = new HttpError429Handler(); + _client.ErrorHandlers.Add(tooManyRequestsHandler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + attempts++; + + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("x-ratelimit-reset", "1727000000000"); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.TooManyRequests)); + Assert.That(attempts, Is.EqualTo(1)); + } + } + + [Test] + public async Task On429Response_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var tooManyRequestsHandler = new HttpError429Handler { - OnBackoffStrategy = (ctx, resetTime) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, resetTime) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(tooManyRequestsHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs index 58fcd66..c853725 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/HttpError503HandlerTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -40,10 +41,10 @@ public async Task On503Response_WithRetryAfterHeader_AsDate_RetriesRequestAfterS var actualRetryTime = default(DateTimeOffset?); var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -74,10 +75,10 @@ public async Task On503Response_WithRetryAfterHeader_AsDelta_RetriesRequestAfter var actualRetryTime = default(DateTimeOffset?); var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(serviceUnavailableHandler); @@ -106,7 +107,7 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul { var serviceUnavailableHandler = new HttpError503Handler(); _client.ErrorHandlers.Add(serviceUnavailableHandler); - _client.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -122,11 +123,36 @@ public async Task On503Response_WithoutRetryAfterHeader_RetriesAccordingToDefaul } [Test] - public async Task On503Response_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public void On503Response_WithOutOfRangeRetryAfterDate_ThrowsHttpResponseException() + { + var serviceUnavailableHandler = new HttpError503Handler(); + _client.ErrorHandlers.Add(serviceUnavailableHandler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + attempts++; + + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(new DateTimeOffset(9999, 12, 31, 0, 0, 0, TimeSpan.Zero)); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(1)); + } + } + + [Test] + public async Task On503Response_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var serviceUnavailableHandler = new HttpError503Handler { - OnBackoffStrategy = (ctx, retryAfter) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(serviceUnavailableHandler); diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs new file mode 100644 index 0000000..6f3d4d6 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/RetryableHttpErrorHandlerTests.cs @@ -0,0 +1,275 @@ +namespace Kampute.HttpClient.Test.ErrorHandlers +{ + using Kampute.HttpClient.ErrorHandlers; + using Kampute.HttpClient.ErrorHandlers.Abstracts; + using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; + using Moq; + using NUnit.Framework; + using System; + using System.Diagnostics; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Net.Sockets; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class RetryableHttpErrorHandlerTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public void MaxRetryDelay_DefaultsToFiveMinutes() + { + var handler = new HttpError503Handler(); + + Assert.That(handler.MaxRetryDelay, Is.EqualTo(TimeSpan.FromMinutes(5))); + } + + [Test] + public void MaxRetryDelay_WhenNegative_ThrowsArgumentOutOfRangeException() + { + var handler = new HttpError503Handler(); + + Assert.Throws(() => handler.MaxRetryDelay = TimeSpan.FromSeconds(-1)); + } + + [Test] + public void OnSuggestedDelayAboveMaxRetryDelay_DoesNotRetry() + { + var strategyRequested = false; + var handler = new HttpError503Handler + { + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnRetryPolicy = (_, _) => + { + strategyRequested = true; + return RetryTestHelpers.MockRetryPolicy(1, out _).Object; + } + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromHours(1)); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts(), Is.EqualTo(1)); + Assert.That(strategyRequested, Is.False); + } + } + + [Test] + public async Task OnSuggestedDelayBelowMaxRetryDelay_Retries() + { + var handler = new HttpError503Handler + { + MaxRetryDelay = TimeSpan.FromMinutes(1), + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromSeconds(30)); + + await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); + + Assert.That(attempts(), Is.EqualTo(2)); + } + + [Test] + public async Task OnLongSuggestedDelay_WithoutMaxRetryDelay_Retries() + { + var handler = new HttpError503Handler + { + MaxRetryDelay = null, + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = MockServiceUnavailable(TimeSpan.FromHours(1)); + + await Assert.ThatAsync(() => _client.SendAsync(HttpMethod.Get, "/unavailable/resource"), Throws.TypeOf()); + + Assert.That(attempts(), Is.EqualTo(2)); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task OnLongSuggestedDelay_AfterRetrySessionCreated_RespectsMaxRetryDelay(bool useRateLimitReset, bool unlimitedDelay) + { + RetryableHttpErrorHandler handler = useRateLimitReset ? new HttpError429Handler() : new HttpError503Handler(); + handler.MaxRetryDelay = unlimitedDelay ? null : TimeSpan.FromMinutes(1); + var policyRequests = 0; + handler.OnRetryPolicy = (_, _) => + { + ++policyRequests; + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); + }; + _client.ErrorHandlers.Add(handler); + + var statusCode = useRateLimitReset ? HttpStatusCode.TooManyRequests : HttpStatusCode.ServiceUnavailable; + var attempts = 0; + _mockMessageHandler.MockHttpResponse(_ => + { + if (++attempts > 2) + return new HttpResponseMessage(HttpStatusCode.OK); + + var response = new HttpResponseMessage(statusCode); + if (attempts == 2) + { + if (useRateLimitReset) + response.Headers.Add("x-rate-limit-reset", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()); + else + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromHours(1)); + } + return response; + }); + + if (unlimitedDelay) + { + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + } + else + { + var exception = Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/resource")); + Assert.That(exception.StatusCode, Is.EqualTo(statusCode)); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(attempts, Is.EqualTo(unlimitedDelay ? 3 : 2)); + Assert.That(policyRequests, Is.EqualTo(1)); + } + } + + [Test] + public void OnRateLimitResetAboveMaxRetryDelay_DoesNotRetry() + { + var handler = new HttpError429Handler + { + OnRetryPolicy = (_, _) => RetryTestHelpers.MockRetryPolicy(1, out _).Object + }; + _client.ErrorHandlers.Add(handler); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + Interlocked.Increment(ref attempts); + + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("x-rate-limit-reset", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()); + return response; + }); + + Assert.ThrowsAsync(() => _client.SendAsync(HttpMethod.Get, "/rate-limited/resource")); + + Assert.That(attempts, Is.EqualTo(1)); + } + + [Test] + public async Task OnConnectionFailureThenRateLimit_RetriesAtSuggestedTime() + { + var suggestedDelay = TimeSpan.FromSeconds(1); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); + _client.ErrorHandlers.Add(new HttpError429Handler()); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + switch (Interlocked.Increment(ref attempts)) + { + case 1: + throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + case 2: + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.RetryAfter = new RetryConditionHeaderValue(suggestedDelay); + return response; + default: + return new HttpResponseMessage(HttpStatusCode.OK); + } + }); + + var timer = Stopwatch.StartNew(); + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + timer.Stop(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(attempts, Is.EqualTo(3)); + Assert.That(timer.Elapsed, Is.GreaterThanOrEqualTo(suggestedDelay - TimeSpan.FromMilliseconds(100))); + } + } + + [Test] + public async Task OnServiceUnavailableThenConnectionFailure_RetriesWithRetryPolicy() + { + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); + _client.RetryPolicy = mockRetryPolicy.Object; + _client.ErrorHandlers.Add(new HttpError503Handler()); + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + switch (Interlocked.Increment(ref attempts)) + { + case 1: + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero); + return response; + case 2: + throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable)); + default: + return new HttpResponseMessage(HttpStatusCode.OK); + } + }); + + using var response = await _client.SendAsync(HttpMethod.Get, "/resource"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(attempts, Is.EqualTo(3)); + } + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); + } + + private Func MockServiceUnavailable(TimeSpan retryAfter) + { + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => + { + Interlocked.Increment(ref attempts); + + var response = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter); + return response; + }); + return () => attempts; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs index 6d520ad..7abb62a 100644 --- a/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs +++ b/tests/Kampute.HttpClient.Test/ErrorHandlers/TransientHttpErrorHandlerTests.cs @@ -7,6 +7,7 @@ namespace Kampute.HttpClient.Test.ErrorHandlers { using Kampute.HttpClient.ErrorHandlers; using Kampute.HttpClient.TestSupport; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -91,10 +92,10 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDate_RetriesReques var actualRetryTime = default(DateTimeOffset?); var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -125,10 +126,10 @@ public async Task OnTransientHttpError_WithRetryAfterHeader_AsDelta_RetriesReque var actualRetryTime = default(DateTimeOffset?); var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => + OnRetryPolicy = (ctx, retryAfter) => { actualRetryTime = retryAfter; - return BackoffStrategies.Uniform(1, TimeSpan.Zero); + return RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(1).ToHttpRetryPolicy(); } }; _client.ErrorHandlers.Add(transientHandler); @@ -157,7 +158,7 @@ public async Task OnTransientHttpError_WithoutRetryAfterHeader_RetriesAccordingT { var transientHandler = new TransientHttpErrorHandler(); _client.ErrorHandlers.Add(transientHandler); - _client.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _client.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -173,11 +174,11 @@ public async Task OnTransientHttpError_WithoutRetryAfterHeader_RetriesAccordingT } [Test] - public async Task OnTransientHttpError_WithCustomBackoffStrategy_RetriesAccordingToCustomStrategy() + public async Task OnTransientHttpError_WithCustomRetryPolicy_RetriesAccordingToCustomStrategy() { var transientHandler = new TransientHttpErrorHandler { - OnBackoffStrategy = (ctx, retryAfter) => BackoffStrategies.Uniform(2, TimeSpan.Zero) + OnRetryPolicy = (ctx, retryAfter) => RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy() }; _client.ErrorHandlers.Add(transientHandler); diff --git a/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs b/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs new file mode 100644 index 0000000..466d700 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/ExternalFormatTests.cs @@ -0,0 +1,215 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content.Abstracts; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.IO; + using System.Net; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Shows that a format the library does not ship, here a binary encoding of a point, can be added using only public API, in the three styles + /// that consumers use. + /// + [TestFixture] + public class ExternalFormatTests + { + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task ContentClassWithOwnHelper_SendsPayload() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + var received = default(Point?); + _mockMessageHandler.MockHttpResponse(request => + { + received = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new TestContent("accepted") }; + }); + + var result = await _client.PostAsPointContentAsync("/points", new Point(3, -4)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(received, Is.EqualTo(new Point(3, -4))); + Assert.That(result, Is.EqualTo("accepted")); + } + } + + [Test] + public async Task ReceiveOnlyFormatter_ReadsResponseAndFeedsAcceptHeader() + { + _client.ContentFormatters.Add(new PointReader()); + var acceptedMediaTypes = default(string); + _mockMessageHandler.MockHttpResponse(request => + { + acceptedMediaTypes = request.Headers.Accept.ToString(); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new PointContent(new Point(7, 8)) }; + }); + + var result = await _client.GetAsync("/points/1"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.EqualTo(new Point(7, 8))); + Assert.That(acceptedMediaTypes, Is.EqualTo(PointEncoding.MediaType)); + } + } + + [Test] + public async Task TwoWayFormatterWithOneLineHelpers_SendsAndReadsPayload() + { + _client.UsePoints(); + _mockMessageHandler.MockHttpResponse(request => + { + var point = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new PointContent(new Point(point.Y, point.X)) }; + }); + + var result = await _client.PostAsPointAsync("/points/swap", new Point(1, 2)); + + Assert.That(result, Is.EqualTo(new Point(2, 1))); + } + + [Test] + public async Task TwoWayFormatterWithOneLineHelpers_WithoutRegistration_SendsPayload() + { + var received = default(Point?); + _mockMessageHandler.MockHttpResponse(request => + { + received = PointEncoding.Decode(request.Content!.ReadAsByteArrayAsync().Result); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + + await _client.PostAsPointAsync("/points", new Point(5, 6)); + + Assert.That(received, Is.EqualTo(new Point(5, 6))); + } + } + + public readonly record struct Point(int X, int Y); + + internal static class PointEncoding + { + public const string MediaType = "application/x-point"; + + public static byte[] Encode(Point point) + { + var bytes = new byte[8]; + BitConverter.GetBytes(point.X).CopyTo(bytes, 0); + BitConverter.GetBytes(point.Y).CopyTo(bytes, 4); + return bytes; + } + + public static Point Decode(byte[] bytes) => new(BitConverter.ToInt32(bytes, 0), BitConverter.ToInt32(bytes, 4)); + } + + // Style 1: a content class and a helper built on PostAsync(uri, HttpContent), as custom request formats are written before 3.0. + + public sealed class PointContent : HttpContent + { + private readonly byte[] _bytes; + + public PointContent(Point point) + { + _bytes = PointEncoding.Encode(point); + Headers.ContentType = new MediaTypeHeaderValue(PointEncoding.MediaType); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(_bytes, 0, _bytes.Length); + + protected override bool TryComputeLength(out long length) + { + length = _bytes.Length; + return true; + } + } + + public static class PointContentExtensions + { + public static Task PostAsPointContentAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + { + return client.PostAsync(uri, new PointContent(point), cancellationToken); + } + } + + // Style 2: a receive-only formatter, the migration of a custom response deserializer. + + public sealed class PointReader : HttpContentFormatter + { + public PointReader() + : base([PointEncoding.MediaType], []) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(Point); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return PointEncoding.Decode(await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false)); + } + } + + // Style 3: a two-way formatter with one-line helpers built on SendObjectAsync. + + public sealed class PointFormatter : HttpContentFormatter + { + public PointFormatter() + : base([PointEncoding.MediaType], [PointEncoding.MediaType]) + { + } + + protected override bool CanReadType(Type modelType) => modelType == typeof(Point); + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(Point); + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, CancellationToken cancellationToken) + { + return PointEncoding.Decode(await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false)); + } + + protected override HttpContent CreateContent(object payload, string mediaType) => new PointContent((Point)payload); + } + + public static class PointFormatterExtensions + { + public static PointFormatter UsePoints(this HttpRestClient client) + { + var formatter = client.ContentFormatters.Find(); + if (formatter is null) + { + formatter = new PointFormatter(); + client.ContentFormatters.Add(formatter); + } + return formatter; + } + + public static Task PostAsPointAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + => client.SendObjectAsync(HttpVerb.Post, uri, point, client.ContentFormatters.FindOrDefault(), cancellationToken); + + public static Task PostAsPointAsync(this HttpRestClient client, string uri, Point point, CancellationToken cancellationToken = default) + => client.SendObjectAsync(HttpVerb.Post, uri, point, client.ContentFormatters.FindOrDefault(), cancellationToken); + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs deleted file mode 100644 index 59777a6..0000000 --- a/tests/Kampute.HttpClient.Test/HttpContentDeserializerCollectionTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -namespace Kampute.HttpClient.Test -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.TestSupport; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class HttpContentDeserializerCollectionTests - { - [Test] - public void GetDeserializerFor_MediaTypeAndModelType_ReturnsCorrectDeserializers() - { - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var result = collection.GetDeserializerFor(Constants.TestMediaType, typeof(string)); - - Assert.That(result, Is.Not.Null); - Assert.That(result, Is.TypeOf()); - } - - [Test] - public void GetAcceptableMediaTypes_ModelType_ReturnsCorrectMediaTypeHeaderValues() - { - var modelType = typeof(string); - var expectedMediaTypes = new[] - { - Constants.TestMediaType, - }; - - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var result = collection.GetAcceptableMediaTypes(modelType); - - Assert.That(result, Is.EqualTo(expectedMediaTypes)); - } - - [Test] - public void GetAcceptableMediaTypes_ModelTypeAndErrorType_ReturnsCorrectMediaTypeHeaderValus() - { - var modelType = typeof(string); - var errorType = typeof(object); - var expectedMediaTypes = new[] - { - Constants.TestMediaType, - MediaTypeNames.Application.Json, - }; - - var deserializerMock = new Mock(); - deserializerMock.Setup(deserializer => deserializer.GetSupportedMediaTypes(It.IsAny())) - .Returns((Type type) => type == typeof(string) ? [] : [MediaTypeNames.Application.Json]); - - var collection = new HttpContentDeserializerCollection - { - deserializerMock.Object, - new TestContentDeserializer(), - }; - - var result = collection.GetAcceptableMediaTypes(modelType, errorType); - - Assert.That(result, Is.EqualTo(expectedMediaTypes)); - } - - [Test] - public void Add_Deserializer_AddsDeserializer() - { - var collection = new HttpContentDeserializerCollection(); - - collection.Add(new TestContentDeserializer()); - - Assert.That(collection, Has.Count.EqualTo(1)); - } - - [Test] - public void Add_SameTypeDeserializer_ThrowsArgumentException() - { - var collection = new HttpContentDeserializerCollection - { - new TestContentDeserializer() - }; - - var deserializerSameType = new TestContentDeserializer(); - - Assert.Throws(() => collection.Add(deserializerSameType)); - } - - [Test] - public void Remove_WithExistingDeserializer_RemovesDeserializerAndReturnsTrue() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Remove(deserializer); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(collection, Is.Empty); - } - } - - [Test] - public void Remove_WithNonExistingDeserializer_ReturnsFalse() - { - var collection = new HttpContentDeserializerCollection(); - var deserializer = new TestContentDeserializer(); - - var result = collection.Remove(deserializer); - - Assert.That(result, Is.False); - } - - [Test] - public void Contains_WithExistingDeserializer_ReturnsTrue() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Contains(deserializer); - - Assert.That(result, Is.True); - } - - [Test] - public void Contains_WithNonExistingDeserializer_ReturnsFalse() - { - var collection = new HttpContentDeserializerCollection(); - var deserializer = new TestContentDeserializer(); - - var result = collection.Contains(deserializer); - - Assert.That(result, Is.False); - } - - [Test] - public void Clear_ResetsCollection() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - collection.Clear(); - - Assert.That(collection, Is.Empty); - } - - [Test] - public void Find_WithExistingDeserializerType_ReturnsDeserializer() - { - var deserializer = new TestContentDeserializer(); - var collection = new HttpContentDeserializerCollection { deserializer }; - - var result = collection.Find(); - - Assert.That(result, Is.EqualTo(deserializer)); - } - - [Test] - public void Find_WithNonExistingDeserializerType_ReturnsNull() - { - var collection = new HttpContentDeserializerCollection(); - - var result = collection.Find(); - - Assert.That(result, Is.Null); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs b/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs new file mode 100644 index 0000000..a2e1b26 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpContentFormatterCollectionTests.cs @@ -0,0 +1,377 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.Interfaces; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class HttpContentFormatterCollectionTests + { + [Test] + public void GetReaderFor_MediaTypeAndModelType_ReturnsCorrectFormatters() + { + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var result = collection.GetReaderFor(Constants.TestMediaType, typeof(string)); + + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.TypeOf()); + } + + [Test] + public void GetReaderFor_WhenFormatterMatches_CachesTheMatch() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + var first = collection.GetReaderFor("application/test", typeof(string)); + var second = collection.GetReaderFor("application/test", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.SameAs(mockFormatter.Object)); + Assert.That(second, Is.SameAs(mockFormatter.Object)); + } + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetReaderFor_WithMediaTypesDifferingOnlyInCase_SharesOneCacheEntry() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(true); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + collection.GetReaderFor("application/test", typeof(string)); + var result = collection.GetReaderFor("Application/TEST", typeof(string)); + + Assert.That(result, Is.SameAs(mockFormatter.Object)); + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetReaderFor_WhenNoFormatterMatches_DoesNotCacheTheMiss() + { + var mockFormatter = new Mock(); + mockFormatter.Setup(d => d.CanRead(It.IsAny(), It.IsAny())).Returns(false); + var collection = new HttpContentFormatterCollection + { + mockFormatter.Object + }; + + var first = collection.GetReaderFor("application/unknown", typeof(string)); + var second = collection.GetReaderFor("application/unknown", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + } + mockFormatter.Verify(d => d.CanRead(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void GetAcceptableMediaTypes_ModelType_ReturnsCorrectMediaTypeHeaderValues() + { + var modelType = typeof(string); + var expectedMediaTypes = new[] + { + Constants.TestMediaType, + }; + + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var result = collection.GetAcceptableMediaTypes(modelType); + + Assert.That(result, Is.EqualTo(expectedMediaTypes)); + } + + [Test] + public void GetAcceptableMediaTypes_ModelTypeAndErrorType_ReturnsCorrectMediaTypeHeaderValus() + { + var modelType = typeof(string); + var errorType = typeof(object); + var expectedMediaTypes = new[] + { + Constants.TestMediaType, + MediaTypeNames.Application.Json, + }; + + var formatterMock = new Mock(); + formatterMock.Setup(formatter => formatter.GetReadableMediaTypes(It.IsAny())) + .Returns((Type type) => type == typeof(string) ? [] : [MediaTypeNames.Application.Json]); + + var collection = new HttpContentFormatterCollection + { + formatterMock.Object, + new TestContentFormatter(), + }; + + var result = collection.GetAcceptableMediaTypes(modelType, errorType); + + Assert.That(result, Is.EqualTo(expectedMediaTypes)); + } + + [Test] + public void Add_Formatter_AddsFormatter() + { + var collection = new HttpContentFormatterCollection(); + + collection.Add(new TestContentFormatter()); + + Assert.That(collection, Has.Count.EqualTo(1)); + } + + [Test] + public void Add_SameTypeFormatter_ThrowsArgumentException() + { + var collection = new HttpContentFormatterCollection + { + new TestContentFormatter() + }; + + var formatterSameType = new TestContentFormatter(); + + Assert.Throws(() => collection.Add(formatterSameType)); + } + + [Test] + public void Remove_WithExistingFormatter_RemovesFormatterAndReturnsTrue() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Remove(formatter); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(collection, Is.Empty); + } + } + + [Test] + public void Remove_WithNonExistingFormatter_ReturnsFalse() + { + var collection = new HttpContentFormatterCollection(); + var formatter = new TestContentFormatter(); + + var result = collection.Remove(formatter); + + Assert.That(result, Is.False); + } + + [Test] + public void Contains_WithExistingFormatter_ReturnsTrue() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Contains(formatter); + + Assert.That(result, Is.True); + } + + [Test] + public void Contains_WithNonExistingFormatter_ReturnsFalse() + { + var collection = new HttpContentFormatterCollection(); + var formatter = new TestContentFormatter(); + + var result = collection.Contains(formatter); + + Assert.That(result, Is.False); + } + + [Test] + public void Clear_ResetsCollection() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + collection.Clear(); + + Assert.That(collection, Is.Empty); + } + + [Test] + public void Find_WithExistingFormatterType_ReturnsFormatter() + { + var formatter = new TestContentFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.Find(); + + Assert.That(result, Is.EqualTo(formatter)); + } + + [Test] + public void Find_WithNonExistingFormatterType_ReturnsNull() + { + var collection = new HttpContentFormatterCollection(); + + var result = collection.Find(); + + Assert.That(result, Is.Null); + } + + [Test] + public void FindOrDefault_WithExistingFormatterType_ReturnsRegisteredInstance() + { + var formatter = new FormUrlEncodedFormatter(); + var collection = new HttpContentFormatterCollection { formatter }; + + var result = collection.FindOrDefault(); + + Assert.That(result, Is.SameAs(formatter)); + } + + [Test] + public void FindOrDefault_WithNonExistingFormatterType_ReturnsNewUnregisteredInstanceEachTime() + { + var collection = new HttpContentFormatterCollection(); + + var first = collection.FindOrDefault(); + var second = collection.FindOrDefault(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(collection, Is.Empty); + } + } + + [Test] + public void GetWriterFor_WithMediaTypeInDifferentCase_ReturnsMatchingFormatter() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter() }; + + var result = collection.GetWriterFor("Application/X-WWW-Form-UrlEncoded", typeof(Dictionary)); + + Assert.That(result, Is.TypeOf()); + } + + [Test] + public void GetWriterFor_WithSeveralMatchingFormatters_ReturnsFirstRegistered() + { + var mockWriter = MockWriter(true).Object; + var testFormatter = new TestContentFormatter(); + var mockFirst = new HttpContentFormatterCollection { mockWriter, testFormatter }; + var testFirst = new HttpContentFormatterCollection { testFormatter, mockWriter }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(mockFirst.GetWriterFor(Constants.TestMediaType, typeof(string)), Is.SameAs(mockWriter)); + Assert.That(testFirst.GetWriterFor(Constants.TestMediaType, typeof(string)), Is.SameAs(testFormatter)); + } + } + + [Test] + public void GetWriterFor_WhenFormatterMatches_CachesTheMatchIgnoringCase() + { + var writer = MockWriter(true); + var collection = new HttpContentFormatterCollection { writer.Object }; + + var first = collection.GetWriterFor("application/known", typeof(string)); + var second = collection.GetWriterFor("APPLICATION/KNOWN", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.SameAs(writer.Object)); + Assert.That(second, Is.SameAs(writer.Object)); + } + writer.Verify(f => f.CanWrite(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public void GetWriterFor_WhenNoFormatterMatches_DoesNotCacheTheMiss() + { + var writer = MockWriter(false); + var collection = new HttpContentFormatterCollection { writer.Object }; + + var first = collection.GetWriterFor("application/unknown", typeof(string)); + var second = collection.GetWriterFor("application/unknown", typeof(string)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Null); + Assert.That(second, Is.Null); + } + writer.Verify(f => f.CanWrite(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void GetWriterFor_DoesNotUseReadingSide() + { + var collection = new HttpContentFormatterCollection { new TestContentFormatter() }; + + var reader = collection.GetReaderFor(Constants.TestMediaType, typeof(string)); + var writer = collection.GetWriterFor(MediaTypeNames.Application.FormUrlEncoded, typeof(Dictionary)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(reader, Is.Not.Null); + Assert.That(writer, Is.Null); + } + } + + [Test] + public void GetReaderFor_WithSendOnlyFormatter_ReturnsNull() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter() }; + + var result = collection.GetReaderFor(MediaTypeNames.Application.FormUrlEncoded, typeof(Dictionary)); + + Assert.That(result, Is.Null); + } + + [Test] + public void GetAcceptableMediaTypes_WithSendOnlyFormatter_AddsNothing() + { + var collection = new HttpContentFormatterCollection { new FormUrlEncodedFormatter(), new TestContentFormatter() }; + + var result = collection.GetAcceptableMediaTypes(typeof(string)); + + Assert.That(result, Is.EqualTo(new[] { Constants.TestMediaType })); + } + + [Test] + public void GetReaderFor_WithNullArgument_ThrowsArgumentNullException() + { + var collection = new HttpContentFormatterCollection(); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => collection.GetReaderFor(null!, typeof(string))); + Assert.Throws(() => collection.GetReaderFor(Constants.TestMediaType, null!)); + Assert.Throws(() => collection.GetWriterFor(null!, typeof(string))); + Assert.Throws(() => collection.GetWriterFor(Constants.TestMediaType, null!)); + } + } + + private static Mock MockWriter(bool canWrite) + { + var writer = new Mock(); + writer.Setup(f => f.CanWrite(It.IsAny(), It.IsAny())).Returns(canWrite); + return writer; + } + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs b/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs new file mode 100644 index 0000000..c1f6b21 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpResponseExceptionTests.cs @@ -0,0 +1,56 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + [TestFixture] + public class HttpResponseExceptionTests + { + private static TestCaseData[] Constructors() => + [ + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound)).SetName("Constructor with status code"), + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound, "Not found")).SetName("Constructor with message"), + new TestCaseData(new HttpResponseException(HttpStatusCode.NotFound, "Not found", new InvalidOperationException())).SetName("Constructor with inner exception"), + ]; + + [TestCaseSource(nameof(Constructors))] + public void BaseStatusCode_MatchesStatusCode(HttpResponseException exception) + { + HttpRequestException baseException = exception; + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + Assert.That(baseException.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + } + + [Test] + public async Task ExceptionFilterOnBaseStatusCode_MatchesErrorResponse() + { + var handler = new Mock(); + handler.MockHttpResponse(HttpStatusCode.NotFound); + using var client = new HttpRestClient(new HttpClient(handler.Object)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + var matched = false; + try + { + using var _ = await client.SendAsync(HttpMethod.Get, "/missing"); + } + catch (HttpRequestException error) when (error.StatusCode == HttpStatusCode.NotFound) + { + matched = true; + } + + Assert.That(matched, Is.True); + } + } +} diff --git a/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs index 8b2e5ab..303e129 100644 --- a/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpResponseHeadersExtensionsTests.cs @@ -73,5 +73,50 @@ public void TryExtractRateLimitResetTime_WithValidUnixTimestampHeader_ReturnsTru Assert.That(resetTime, Is.EqualTo(expectedTime).Within(TimeSpan.FromSeconds(1))); } } + + [Test] + public void TryExtractRateLimitResetTime_WithOneDayHeader_ReadsSecondsFromNow() + { + var expectedTime = DateTimeOffset.UtcNow.AddDays(1); + _headers.Add("x-ratelimit-reset", "86400"); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(resetTime, Is.EqualTo(expectedTime).Within(TimeSpan.FromSeconds(1))); + } + } + + [Test] + public void TryExtractRateLimitResetTime_WithValueAboveOneDay_ReadsUnixTime() + { + _headers.Add("x-ratelimit-reset", "86401"); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(resetTime, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(86401))); + } + } + + [TestCase("1727000000000")] + [TestCase("9223372036854775807")] + [TestCase("-1")] + public void TryExtractRateLimitResetTime_WithOutOfRangeHeader_ReturnsFalse(string headerValue) + { + _headers.Add("x-ratelimit-reset", headerValue); + + var result = _headers.TryExtractRateLimitResetTime(out var resetTime); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(resetTime, Is.Null); + } + } } } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs index 9a9ad2d..2b80601 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs @@ -14,7 +14,7 @@ [TestFixture] public class HttpRestClientExtensionsTests { - private readonly TestContentDeserializer _testContentFormatter = new(); + private readonly TestContentFormatter _testContentFormatter = new(); private readonly Mock _mockMessageHandler = new(); private HttpRestClient _client; @@ -33,7 +33,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com"), }; - _client.ResponseDeserializers.Add(_testContentFormatter); + _client.ContentFormatters.Add(_testContentFormatter); } [TearDown] diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs index 75be1cc..1425ac2 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientFormExtensionsTests.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; + using System.Net.Sockets; using System.Threading.Tasks; [TestFixture] @@ -95,5 +96,46 @@ public async Task PatchAsFormAsync_InvokesHttpClientCorrectly() await _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")]); } + + [Test] + public void PostAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PutAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PutAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PatchAsFormAsync_OnErrorResponse_ThrowsHttpResponseException() + { + _mockMessageHandler.MockHttpResponse(HttpStatusCode.InternalServerError); + + var exception = Assert.ThrowsAsync(() => _restClient.PatchAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + } + + [Test] + public void PostAsFormAsync_WithNullPayload_ThrowsArgumentNullExceptionSynchronously() + { + Assert.Throws(() => _restClient.PostAsFormAsync("/resource", null!)); + } + + [Test] + public void PostAsFormAsync_OnConnectionFailure_ThrowsHttpRequestException() + { + _mockMessageHandler.MockHttpResponse(request => throw new HttpRequestException("Connection failure", new SocketException((int)SocketError.HostUnreachable))); + + Assert.ThrowsAsync(() => _restClient.PostAsFormAsync("/resource", [KeyValuePair.Create("name", "value")])); + } } } diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 6710f68..a084321 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -4,6 +4,7 @@ using Kampute.HttpClient.Interfaces; using Kampute.HttpClient.TestSupport; using Kampute.HttpClient.Utilities; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; @@ -24,7 +25,7 @@ public class HttpRestClientTests { private static readonly HttpMethod TestHttpMethod = new("TEST"); - private readonly TestContentDeserializer _testContentFormatter = new(); + private readonly TestContentFormatter _testContentFormatter = new(); private readonly Mock _mockMessageHandler = new(); private HttpRestClient _client; @@ -40,7 +41,7 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com"), }; - _client.ResponseDeserializers.Add(_testContentFormatter); + _client.ContentFormatters.Add(_testContentFormatter); } [TearDown] @@ -153,6 +154,27 @@ public void OnUnsuccessfulStatusCode_WithoutResponseErrorType_ThrowsStandardRest } } + [Test] + public void OnUnsuccessfulStatusCode_ResponseMessageKeepsHeadersButContentIsDisposed() + { + _mockMessageHandler.MockHttpResponse(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Error details") }; + response.Headers.Add("X-Error-Id", "42"); + return response; + }); + + var exception = Assert.ThrowsAsync(() => _client.SendAsync(TestHttpMethod, "/resource")); + + Assert.That(exception.ResponseMessage, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.ResponseMessage.StatusCode, Is.EqualTo(HttpStatusCode.InternalServerError)); + Assert.That(exception.ResponseMessage.Headers.GetValues("X-Error-Id"), Is.EqualTo(new[] { "42" })); + Assert.ThrowsAsync(() => exception.ResponseMessage.Content.ReadAsStringAsync()); + } + } + [Test] public void OnUnsuccessfulStatusCode_WithResponseErrorType_ThrowsCustomizedRestException() { @@ -222,13 +244,13 @@ public async Task OnUnsuccessfulStatusCode_WithErrorHandler_RetriesRequest() } [Test] - public async Task OnConnectionFailure_UsesBackoffStrategy() + public async Task OnConnectionFailure_UsesRetryPolicy() { var maxRetries = 2; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(maxRetries, out var mockRetrySession); - _client.BackoffStrategy = mockBackoffStrategy.Object; + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -244,19 +266,19 @@ public async Task OnConnectionFailure_UsesBackoffStrategy() await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy(string encoding) + public async Task OnConnectionFailure_WithCompressedContent_UsesRetryPolicy(string encoding) { var maxRetries = 2; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(maxRetries, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(maxRetries, out var mockRetrySession); - _client.BackoffStrategy = mockBackoffStrategy.Object; + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; _mockMessageHandler.MockHttpResponse(request => @@ -291,14 +313,14 @@ public async Task OnConnectionFailure_WithCompressedContent_UsesBackoffStrategy( await _client.SendAsync(TestHttpMethod, "/test", compressedPayload); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Exactly(maxRetries)); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Exactly(maxRetries)); Assert.That(attempts, Is.EqualTo(maxRetries + 1)); } [Test] - public async Task OnTimeoutCancellation_UsesBackoffStrategy() + public async Task OnTimeoutCancellation_UsesRetryPolicy() { - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -325,12 +347,12 @@ public async Task OnTimeoutCancellation_UsesBackoffStrategy() { BaseAddress = new Uri("http://api.test.com"), }; - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.RetryPolicy = mockRetryPolicy.Object; using var response = await timedOutClient.SendAsync(TestHttpMethod, "/test", new StringContent("test")); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); @@ -339,10 +361,51 @@ public async Task OnTimeoutCancellation_UsesBackoffStrategy() } [Test] - public void OnCallerCancellation_DoesNotUseBackoffStrategy() + public void OnUnsuccessfulStatusCode_WithOverriddenDecideOnRetry_KeepsRetryBudgetAcrossRetries() + { + const int maxAttempts = 10; + + var attempts = 0; + _mockMessageHandler.MockHttpResponse(request => Interlocked.Increment(ref attempts) < maxAttempts + ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + : new HttpResponseMessage(HttpStatusCode.OK)); + + using var httpClient = new HttpClient(_mockMessageHandler.Object, false); + using var client = new RetryOnAnyErrorClient(httpClient, RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy()) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + var exception = Assert.ThrowsAsync(() => client.SendAsync(HttpMethod.Get, "/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable)); + Assert.That(attempts, Is.EqualTo(3)); + } + } + + private sealed class RetryOnAnyErrorClient(HttpClient httpClient, IHttpRetryPolicy backoff) : HttpRestClient(httpClient) + { + protected override Task DecideOnRetryAsync + ( + HttpResponseException error, + HttpRequestMessage request, + HttpResponseMessage response, + HttpRetryState retryState, + CancellationToken cancellationToken + ) + { + var ctx = new HttpResponseErrorContext(this, request, response, error, retryState); + return ctx.ScheduleRetryAsync(this, backoff.CreateSession, cancellationToken); + } + } + + [Test] + public void OnCallerCancellation_DoesNotUseRetryPolicy() { - var mockBackoffStrategy = new Mock(); - _client.BackoffStrategy = mockBackoffStrategy.Object; + var mockRetryPolicy = new Mock(); + _client.RetryPolicy = mockRetryPolicy.Object; var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); @@ -356,10 +419,10 @@ public void OnCallerCancellation_DoesNotUseBackoffStrategy() Assert.ThrowsAsync ( - async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationTokenSource.Token) + async () => await _client.SendAsync(TestHttpMethod, "/test", new StringContent("test"), cancellationToken: cancellationTokenSource.Token) ); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Never); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Never); Assert.That(attempts, Is.EqualTo(1)); } diff --git a/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs new file mode 100644 index 0000000..98fc465 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/HttpRetryPolicyTests.cs @@ -0,0 +1,81 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.Resilience; + using Moq; + using NUnit.Framework; + using System; + using System.Net.Http; + + [TestFixture] + public class HttpRetryPolicyTests + { + private static HttpRequestErrorContext MockHttpRequestErrorContext() + { + var mockClient = new Mock(new HttpClient(), true); + var mockRequest = new Mock(); + var mockError = new Mock(); + + return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object, new HttpRetryState()); + } + + [Test] + public void CreateSession_StartsSessionForTheStrategy() + { + var mockRetryStrategy = new Mock(); + var policy = mockRetryStrategy.Object.ToHttpRetryPolicy(); + + var first = policy.CreateSession(MockHttpRequestErrorContext()) as RetrySession; + var second = policy.CreateSession(MockHttpRequestErrorContext()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first, Is.Not.Null); + Assert.That(first?.Strategy, Is.SameAs(mockRetryStrategy.Object)); + Assert.That(second, Is.Not.SameAs(first)); + } + } + + [Test] + public void None_NeverRetries() + { + var session = HttpRetryPolicy.None.CreateSession(MockHttpRequestErrorContext()); + + Assert.That(session.WaitToRetryAsync(default).Result, Is.False); + } + + [Test] + public void Dynamic_UsingStrategyFactory_CreatesSessionForTheStrategy() + { + var mockRetryStrategy = new Mock(); + var policy = HttpRetryPolicy.Dynamic(ctx => mockRetryStrategy.Object); + + var session = policy.CreateSession(MockHttpRequestErrorContext()) as RetrySession; + + Assert.That(session?.Strategy, Is.SameAs(mockRetryStrategy.Object)); + } + + [Test] + public void Dynamic_UsingSessionFactory_ReturnsTheSession() + { + var mockRetrySession = new Mock(); + var policy = HttpRetryPolicy.Dynamic(ctx => mockRetrySession.Object); + + var session = policy.CreateSession(MockHttpRequestErrorContext()); + + Assert.That(session, Is.SameAs(mockRetrySession.Object)); + } + + [Test] + public void Dynamic_WhenFactoryReturnsNull_ThrowsInvalidOperationException() + { + var strategyPolicy = HttpRetryPolicy.Dynamic(ctx => (IRetryStrategy)null!); + var sessionPolicy = HttpRetryPolicy.Dynamic(ctx => (IRetrySession)null!); + + using (Assert.EnterMultipleScope()) + { + Assert.Throws(() => strategyPolicy.CreateSession(MockHttpRequestErrorContext())); + Assert.Throws(() => sessionPolicy.CreateSession(MockHttpRequestErrorContext())); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs b/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs new file mode 100644 index 0000000..bfed071 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/RequestPropertyStorageTests.cs @@ -0,0 +1,81 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// The client stores its request properties through on modern .NET. These tests show that the values + /// are also visible through the obsolete HttpRequestMessage.Properties, which code written for earlier versions still reads. + /// + [TestFixture] + public class RequestPropertyStorageTests + { + [Test] + public async Task ClientProperties_AreVisibleThroughOptionsAndProperties() + { + var handler = new Mock(); + var fromOptions = default(Guid); + var fromProperties = default(object); + var scopedFromProperties = default(object); + handler.MockHttpResponse(request => + { + request.Options.TryGetValue(new HttpRequestOptionsKey(HttpRequestMessagePropertyKeys.TransactionId), out fromOptions); +#pragma warning disable CS0618 // Properties is obsolete; reading it is the point of the test. + request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.TransactionId, out fromProperties); + request.Properties.TryGetValue("Scoped", out scopedFromProperties); +#pragma warning restore CS0618 + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var client = new HttpRestClient(new HttpClient(handler.Object)) + { + BaseAddress = new Uri("http://api.test.com"), + }; + + using (client.BeginPropertyScope([new("Scoped", "value")])) + { + using var _ = await client.SendAsync(HttpMethod.Get, "/resource"); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(fromOptions, Is.Not.EqualTo(Guid.Empty)); + Assert.That(fromProperties, Is.EqualTo(fromOptions)); + Assert.That(scopedFromProperties, Is.EqualTo("value")); + } + } + + [Test] + public void ValueSetThroughProperties_IsVisibleThroughOptions() + { + using var request = new HttpRequestMessage(); + +#pragma warning disable CS0618 // Properties is obsolete; writing it is the point of the test. + request.Properties["key"] = 42; +#pragma warning restore CS0618 + + Assert.That(request.Options.TryGetValue(new HttpRequestOptionsKey("key"), out var value) && value == 42, Is.True); + } + + [Test] + public void CloneOfRequest_CopiesOptionsAndCountsGenerations() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "http://api.test.com/resource"); + request.Options.Set(new HttpRequestOptionsKey("key"), "value"); + + using var clone = request.Clone(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(clone.Options.TryGetValue(new HttpRequestOptionsKey("key"), out var value) ? value : null, Is.EqualTo("value")); + Assert.That(clone.GetCloneGeneration(), Is.EqualTo(1)); + Assert.That(clone.IsCloned(), Is.True); + Assert.That(request.IsCloned(), Is.False); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs deleted file mode 100644 index b60f67c..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/DynamicRetrySchedulerFactoryTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Moq; - using NUnit.Framework; - using System.Net.Http; - - [TestFixture] - public class DynamicRetrySchedulerFactoryTests - { - private static HttpRequestErrorContext MockHttpRequestErrorContext() - { - var mockClient = new Mock(new HttpClient(), true); - var mockRequest = new Mock(); - var mockError = new Mock(); - - return new HttpRequestErrorContext(mockClient.Object, mockRequest.Object, mockError.Object); - } - - [Test] - public void CreateScheduler_UsingStrategyFactory_ReturnsCorrectScheduler() - { - var mockRetryStrategy = new Mock(); - var factory = new DynamicBackoffStrategy(ctx => mockRetryStrategy.Object); - var context = MockHttpRequestErrorContext(); - - var scheduler = factory.CreateScheduler(context) as RetryScheduler; - - Assert.That(scheduler, Is.Not.Null); - Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); - } - - [Test] - public void CreateScheduler_UsingSchedulerFactory_ReturnsCorrectScheduler() - { - var mockRetryScheduler = new Mock(); - var factory = new DynamicBackoffStrategy(ctx => mockRetryScheduler.Object); - var context = MockHttpRequestErrorContext(); - - var scheduler = factory.CreateScheduler(context); - - Assert.That(scheduler, Is.SameAs(mockRetryScheduler.Object)); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs deleted file mode 100644 index a2450e5..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerFactoryTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Moq; - using NUnit.Framework; - - [TestFixture] - public class RetrySchedulerFactoryTests - { - [Test] - public void CreatesSchedulerWithCorrectStrategy() - { - var mockRetryStrategy = new Mock(); - var factory = new BackoffStrategy(mockRetryStrategy.Object); - - var scheduler = factory.CreateScheduler() as RetryScheduler; - - Assert.That(scheduler, Is.Not.Null); - Assert.That(scheduler.Strategy, Is.SameAs(mockRetryStrategy.Object)); - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs deleted file mode 100644 index 3e125a7..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/RetrySchedulerTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement; - using Moq; - using NUnit.Framework; - using System; - using System.Diagnostics; - using System.Threading; - using System.Threading.Tasks; - - [TestFixture] - public class RetrySchedulerTests - { - [Test] - public void Constructor_SetsStrategy_ToProvidedStrategy() - { - var mockStrategy = new Mock(); - - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.That(scheduler.Strategy, Is.SameAs(mockStrategy.Object)); - } - - [Test] - public void Attempts_InitiallyReturnsZero() - { - var mockStrategy = new Mock(); - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.That(scheduler.Attempts, Is.Zero); - } - - [Test] - public async Task WaitAsync_WaitsAccordingToStrategy() - { - var expectedDelay = TimeSpan.FromMilliseconds(50); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out expectedDelay)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var timer = Stopwatch.StartNew(); - var result = await scheduler.WaitAsync(CancellationToken.None); - timer.Stop(); - - Assert.That(timer.Elapsed, Is.EqualTo(expectedDelay).Within(TimeSpan.FromMilliseconds(100))); - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsAdvisable_ReturnsTrue() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(scheduler.Attempts, Is.EqualTo(1u)); - } - } - - [Test] - public async Task WaitAsync_WhenStrategyIndicatesRetryIsNotAdvisable_ReturnsFalse() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var scheduler = new RetryScheduler(mockStrategy.Object); - - var result = await scheduler.WaitAsync(CancellationToken.None); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - - [Test] - public void WaitAsync_WhenCanceled_ThrowsOperationCanceledException() - { - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - Assert.ThrowsAsync(() => scheduler.WaitAsync(cancellationTokenSource.Token)); - } - - [Test] - public async Task Reset_ResetsInternalState() - { - var mockStrategy = new Mock(); - mockStrategy.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(true); - var scheduler = new RetryScheduler(mockStrategy.Object); - - await scheduler.WaitAsync(CancellationToken.None); - scheduler.Reset(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(scheduler.Elapsed, Is.LessThanOrEqualTo(TimeSpan.FromMilliseconds(10))); - Assert.That(scheduler.Attempts, Is.Zero); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs deleted file mode 100644 index f45f231..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/ExponentialStrategyTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies -{ - using Kampute.HttpClient.RetryManagement.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class ExponentialStrategyTests - { - [Test] - public void Constructor_WhenRateIsLessThanOne_ThrowsArgumentOutOfRangeException() - { - var initialDelay = TimeSpan.FromSeconds(1); - var rate = 0.5; - - var ex = Assert.Throws(() => new ExponentialStrategy(initialDelay, rate)); - - Assert.That(ex.ParamName, Is.EqualTo("rate")); - } - - [TestCase(0u, 0, 2.0, 0)] - [TestCase(1u, 0, 2.0, 0)] - [TestCase(0u, 1000, 2.0, 1000)] - [TestCase(1u, 1000, 2.0, 2000)] - [TestCase(3u, 1000, 2.0, 8000)] - [TestCase(0u, 1000, 3.0, 1000)] - [TestCase(1u, 1000, 3.0, 3000)] - [TestCase(2u, 1000, 3.0, 9000)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, double rate, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new ExponentialStrategy(initialDelay, rate); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 2.0, 0)] - [TestCase(1u, 0, 2.0, 0)] - [TestCase(0u, 1000, 2.0, 1000)] - [TestCase(1u, 1000, 2.0, 2000)] - [TestCase(3u, 1000, 2.0, 8000)] - [TestCase(0u, 1000, 3.0, 1000)] - [TestCase(1u, 1000, 3.0, 3000)] - [TestCase(2u, 1000, 3.0, 9000)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, double rate, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new ExponentialStrategy(initialDelay, rate); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs deleted file mode 100644 index 4d98e2d..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/FibonacciStrategyTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies -{ - using Kampute.HttpClient.RetryManagement.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class FibonacciStrategyTests - { - [TestCase(0, 0)] - [TestCase(100, 10)] - [TestCase(500, 50)] - public void Constructor_WithInitialDelayAndDelayStep_SetsPropertiesCorrectly(int initialDelayMs, int delayStepMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(delayStep)); - } - } - - [TestCase(0)] - [TestCase(100)] - [TestCase(500)] - public void Constructor_WithInitialDelayOnly_SetsInitialDelayAndDelayStep(int initialDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - - var strategy = new FibonacciStrategy(initialDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(initialDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1200)] - [TestCase(6u, 1000, 100, 1800)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1200)] - [TestCase(6u, 1000, 100, 1800)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new FibonacciStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs deleted file mode 100644 index ed4f8e7..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/LinearStrategyTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies -{ - using Kampute.HttpClient.RetryManagement.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class LinearStrategyTests - { - [TestCase(0, 0)] - [TestCase(100, 10)] - [TestCase(500, 50)] - public void Constructor_WithInitialDelayAndDelayStep_SetsPropertiesCorrectly(int initialDelayMs, int delayStepMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - - var strategy = new LinearStrategy(initialDelay, delayStep); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(delayStep)); - } - } - - [TestCase(0)] - [TestCase(100)] - [TestCase(500)] - public void Constructor_WithInitialDelayOnly_SetsInitialDelayAndDelayStep(int initialDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - - var strategy = new LinearStrategy(initialDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(strategy.InitialDelay, Is.EqualTo(initialDelay)); - Assert.That(strategy.DelayStep, Is.EqualTo(initialDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1300)] - [TestCase(6u, 1000, 100, 1600)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new LinearStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u, 0, 0, 0)] - [TestCase(0u, 0, 100, 0)] - [TestCase(0u, 1000, 100, 1000)] - [TestCase(1u, 0, 0, 0)] - [TestCase(1u, 0, 100, 100)] - [TestCase(1u, 1000, 100, 1100)] - [TestCase(3u, 1000, 100, 1300)] - [TestCase(6u, 1000, 100, 1600)] - public void TryGetRetryDelay_IgnoresElapsed(uint attempts, int initialDelayMs, int delayStepMs, int expectedDelayMs) - { - var initialDelay = TimeSpan.FromMilliseconds(initialDelayMs); - var delayStep = TimeSpan.FromMilliseconds(delayStepMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - var strategy = new LinearStrategy(initialDelay, delayStep); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs deleted file mode 100644 index 381465d..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/JitterStrategyModifierTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class JitterStrategyModifierTests - { - [TestCase(-0.1)] - [TestCase(1.1)] - public void Constructor_WhenJitterFactorIsOutOfRange_ThrowsArgumentOutOfRangeException(double jitterFactor) - { - var mockSource = new Mock(); - - var ex = Assert.Throws(() => new JitterStrategyModifier(mockSource.Object, jitterFactor)); - - Assert.That(ex.ParamName, Is.EqualTo("jitterFactor")); - } - - [TestCase(0.0, 1000)] - [TestCase(0.5, 1000)] - [TestCase(1.0, 1000)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsTrueWithJitteredDelay(double jitterFactor, int baseDelayMs) - { - var baseDelay = TimeSpan.FromMilliseconds(baseDelayMs); - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out baseDelay)).Returns(true); - var strategy = new JitterStrategyModifier(mockSource.Object, jitterFactor); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(baseDelay).Within(jitterFactor * baseDelay)); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var strategy = new JitterStrategyModifier(mockSource.Object, 0.5); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs deleted file mode 100644 index 10e90aa..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedAttemptsStrategyModifierTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class LimitedAttemptsStrategyModifierTests - { - [TestCase(0u, 0u, false)] - [TestCase(1u, 0u, true)] - [TestCase(1u, 1u, false)] - [TestCase(2u, 1u, true)] - [TestCase(2u, 3u, false)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsExpectedResult(uint maxAttempts, uint attempts, bool expectedResult) - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)) - .Returns((TimeSpan elapsed, uint attempts, out TimeSpan delay) => - { - delay = TimeSpan.FromSeconds(1); - return true; - }); - var strategy = new LimitedAttemptsStrategyModifier(mockSource.Object, maxAttempts); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.EqualTo(expectedResult)); - Assert.That(actualDelay, expectedResult ? Is.Not.Default : Is.Default); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - - var strategy = new LimitedAttemptsStrategyModifier(mockSource.Object, 3); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs deleted file mode 100644 index 47dba62..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/Modifiers/LimitedDurationStrategyModifierTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies.Modifiers -{ - using Kampute.HttpClient.Interfaces; - using Kampute.HttpClient.RetryManagement.Strategies.Modifiers; - using Moq; - using NUnit.Framework; - using System; - - [TestFixture] - public class LimitedDurationStrategyModifierTests - { - [TestCase(1000, 0, 100, 100, true)] - [TestCase(1000, 500, 100, 100, true)] - [TestCase(1000, 950, 100, 50, true)] - [TestCase(1000, 1000, 100, 0, false)] - [TestCase(1000, 1500, 100, 0, false)] - public void TryGetRetryDelay_WhenSourceReturnsTrue_ReturnsExpectedResult(int timeoutMs, int elapsedMs, int baseDelayMs, int expectedDelayMs, bool expectedResult) - { - var timeout = TimeSpan.FromMilliseconds(timeoutMs); - var elapsed = TimeSpan.FromMilliseconds(elapsedMs); - var baseDelay = TimeSpan.FromMilliseconds(baseDelayMs); - var expectedDelay = TimeSpan.FromMilliseconds(expectedDelayMs); - - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)) - .Returns((TimeSpan elapsed, uint attempts, out TimeSpan delay) => - { - delay = expectedDelay; - return true; - }); - var strategy = new LimitedDurationStrategyModifier(mockSource.Object, timeout); - - var result = strategy.TryGetRetryDelay(elapsed, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.EqualTo(expectedResult)); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [Test] - public void TryGetRetryDelay_WhenSourceReturnsFalse_ReturnsFalse() - { - var mockSource = new Mock(); - mockSource.Setup(s => s.TryGetRetryDelay(It.IsAny(), It.IsAny(), out It.Ref.IsAny)).Returns(false); - var strategy = new LimitedDurationStrategyModifier(mockSource.Object, TimeSpan.FromSeconds(10)); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, 0, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(actualDelay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs deleted file mode 100644 index 37e48e8..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/NoneStrategyTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies -{ - using Kampute.HttpClient.RetryManagement.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class NoneStrategyTests - { - [Test] - public void Instance_IsNotNull() - { - var instance = NoneStrategy.Instance; - - Assert.That(instance, Is.Not.Null); - } - - [Test] - public void Instance_IsSingleton() - { - var instance1 = NoneStrategy.Instance; - var instance2 = NoneStrategy.Instance; - - Assert.That(instance1, Is.SameAs(instance2)); - } - - [TestCase(0u, 0)] - [TestCase(1u, 5)] - [TestCase(10u, 20)] - public void TryGetRetryDelay_ReturnsFalseAndDefaultDelay(uint attempts, int elapsedMs) - { - var elapsed = TimeSpan.FromMilliseconds(elapsedMs); - - var result = NoneStrategy.Instance.TryGetRetryDelay(elapsed, attempts, out var delay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.False); - Assert.That(delay, Is.Default); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs b/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs deleted file mode 100644 index c1c8a84..0000000 --- a/tests/Kampute.HttpClient.Test/RetryManagement/Strategies/UniformStrategyTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Kampute.HttpClient.Test.RetryManagement.Strategies -{ - using Kampute.HttpClient.RetryManagement.Strategies; - using NUnit.Framework; - using System; - - [TestFixture] - public class UniformStrategyTests - { - [Test] - public void Constructor_SetsDelayProperty() - { - var expectedDelay = TimeSpan.FromSeconds(5); - - var strategy = new UniformStrategy(expectedDelay); - - Assert.That(strategy.Delay, Is.EqualTo(expectedDelay)); - } - - [TestCase(0u, 0)] - [TestCase(5u, 10)] - [TestCase(100u, 1000)] - public void TryGetRetryDelay_ReturnsExpectedDelay(uint attempts, int delayMilliseconds) - { - var expectedDelay = TimeSpan.FromMilliseconds(delayMilliseconds); - var strategy = new UniformStrategy(expectedDelay); - - var result = strategy.TryGetRetryDelay(TimeSpan.Zero, attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - - [TestCase(0u)] - [TestCase(5u)] - [TestCase(100u)] - public void TryGetRetryDelay_IgnoresElapsedAndAttempts(uint attempts) - { - var expectedDelay = TimeSpan.FromSeconds(10); - var strategy = new UniformStrategy(expectedDelay); - - var result = strategy.TryGetRetryDelay(TimeSpan.FromHours(1), attempts, out var actualDelay); - - using (Assert.EnterMultipleScope()) - { - Assert.That(result, Is.True); - Assert.That(actualDelay, Is.EqualTo(expectedDelay)); - } - } - } -} diff --git a/tests/Kampute.HttpClient.Test/SendObjectTests.cs b/tests/Kampute.HttpClient.Test/SendObjectTests.cs new file mode 100644 index 0000000..80e50a1 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/SendObjectTests.cs @@ -0,0 +1,172 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient.Content; + using Kampute.HttpClient.Content.Abstracts; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class SendObjectTests + { + private const string ReadOnlyMediaType = "application/x-read-only"; + + private readonly Mock _mockMessageHandler = new(); + private readonly List<(HttpRequestMessage Request, string? Body)> _sentRequests = []; + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + _sentRequests.Clear(); + _mockMessageHandler.MockHttpResponse(request => + { + _sentRequests.Add((request, request.Content?.ReadAsStringAsync().Result)); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new TestContent("done") }; + }); + + var httpClient = new HttpClient(_mockMessageHandler.Object, disposeHandler: false); + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task SendObjectAsync_WithMediaType_WritesPayloadWithRegisteredFormatterIgnoringCase() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + var result = await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", Constants.TestMediaType.ToUpperInvariant()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.EqualTo("done")); + Assert.That(_sentRequests, Has.Count.EqualTo(1)); + Assert.That(_sentRequests[0].Request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(Constants.TestMediaType)); + Assert.That(_sentRequests[0].Body, Is.EqualTo("payload")); + } + } + + [Test] + public async Task SendObjectAsync_WithSeveralMatchingFormatters_UsesFirstRegistered() + { + _client.ContentFormatters.Add(new MarkingFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", Constants.TestMediaType); + + Assert.That(_sentRequests.Single().Body, Is.EqualTo("marked:payload")); + } + + [Test] + public async Task SendObjectAsync_WithFormatter_IgnoresRegisteredFormatters() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", new MarkingFormatter()); + + Assert.That(_sentRequests.Single().Body, Is.EqualTo("marked:payload")); + } + + [Test] + public void SendObjectAsync_WhenNoFormatterWritesMediaType_ThrowsBeforeSending() + { + _client.ContentFormatters.Add(new TestContentFormatter()); + + var exception = Assert.Throws(() => _client.SendObjectAsync(HttpMethod.Post, "/resource", "payload", "application/x-unknown")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(exception.Message, Does.Contain("application/x-unknown")); + Assert.That(_sentRequests, Is.Empty); + } + } + + [Test] + public void SendObjectAsync_WhenFormatterCannotWritePayloadType_ThrowsBeforeSending() + { + Assert.Throws(() => _client.SendObjectAsync(HttpMethod.Post, "/resource", new object(), new MarkingFormatter())); + Assert.That(_sentRequests, Is.Empty); + } + + [Test] + public async Task SendObjectAsync_WithHttpContentPayload_SendsContentAsItIs() + { + var firstContent = new StringContent("first", Encoding.UTF8, "text/plain"); + var secondContent = new StringContent("second", Encoding.UTF8, "text/plain"); + + await _client.SendObjectAsync(HttpMethod.Post, "/resource", firstContent, "application/x-unknown"); + await _client.SendObjectAsync(HttpMethod.Post, "/resource", secondContent, new MarkingFormatter()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(_sentRequests.Select(sent => sent.Request.Content), Is.EqualTo(new HttpContent[] { firstContent, secondContent })); + Assert.That(_sentRequests.Select(sent => sent.Body), Is.EqualTo(new[] { "first", "second" })); + } + } + + [Test] + public async Task Request_WithSendOnlyFormatter_AddsNothingToAcceptHeader() + { + _client.ContentFormatters.Add(new FormUrlEncodedFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.GetAsync("/resource"); + + Assert.That(_sentRequests.Single().Request.Headers.Accept.Select(accept => accept.MediaType), Is.EqualTo(new[] { Constants.TestMediaType })); + } + + [Test] + public async Task Request_WithReceiveOnlyFormatter_AddsItsMediaTypesToAcceptHeader() + { + _client.ContentFormatters.Add(new ReadOnlyFormatter()); + _client.ContentFormatters.Add(new TestContentFormatter()); + + await _client.GetAsync("/resource"); + + Assert.That(_sentRequests.Single().Request.Headers.Accept.Select(accept => accept.MediaType), Is.EqualTo(new[] { ReadOnlyMediaType, Constants.TestMediaType })); + } + + private sealed class MarkingFormatter : HttpContentFormatter + { + public MarkingFormatter() + : base([], [Constants.TestMediaType]) + { + } + + protected override bool CanWriteType(Type payloadType) => payloadType == typeof(string); + + protected override HttpContent CreateContent(object payload, string mediaType) + { + return new StringContent($"marked:{payload}", Encoding.UTF8, mediaType); + } + } + + private sealed class ReadOnlyFormatter : HttpContentFormatter + { + public ReadOnlyFormatter() + : base([ReadOnlyMediaType], []) + { + } + + protected override async Task ReadContentAsync(HttpContent content, Type modelType, System.Threading.CancellationToken cancellationToken) + { + return await content.ReadAsStringAsync(cancellationToken); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs new file mode 100644 index 0000000..12672ab --- /dev/null +++ b/tests/Kampute.HttpClient.Test/StreamingResponseTests.cs @@ -0,0 +1,204 @@ +namespace Kampute.HttpClient.Test +{ + using Kampute.HttpClient; + using Kampute.HttpClient.TestSupport; + using Moq; + using NUnit.Framework; + using System; + using System.IO; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class StreamingResponseTests + { + private const int BufferLimit = 16; + + private static readonly byte[] LargeBody = [.. Enumerable.Range(0, 1024).Select(i => (byte)i)]; + + private readonly Mock _mockMessageHandler = new(); + private HttpRestClient _client; + + [SetUp] + public void Setup() + { + var httpClient = new HttpClient(_mockMessageHandler.Object, false) + { + MaxResponseContentBufferSize = BufferLimit, + }; + _client = new HttpRestClient(httpClient) + { + BaseAddress = new Uri("http://api.test.com"), + }; + } + + [TearDown] + public void Cleanup() + { + _client.Dispose(); + } + + [Test] + public async Task GetAsStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var bodyStream = await _client.GetAsStreamAsync("/resource"); + using var resultStream = new MemoryStream(); + await bodyStream.CopyToAsync(resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(LargeBody)); + } + + [TestCase(false)] + [TestCase(true)] + public void GetAsStreamAsync_WhenOpeningBodyFails_DisposesResponseContent(bool failSynchronously) + { + var failure = new IOException("Body failed."); + using var content = new FailingContent(failure, failSynchronously); + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + + var exception = Assert.ThrowsAsync(() => _client.GetAsStreamAsync("/resource")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(content.IsDisposed, Is.True); + Assert.That(exception.InnerException, Is.SameAs(failure)); + } + } + + [Test] + public async Task GetToStreamAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var resultStream = new MemoryStream(); + await _client.GetToStreamAsync("/resource", resultStream); + + Assert.That(resultStream.ToArray(), Is.EqualTo(LargeBody)); + } + + [Test] + public async Task DownloadAsync_WithBodyLargerThanBufferLimit_StreamsBody() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(LargeBody) }); + + using var resultStream = await _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => new MemoryStream()); + + Assert.That(((MemoryStream)resultStream).ToArray(), Is.EqualTo(LargeBody)); + } + + [Test] + public void GetToStreamAsync_WhenCanceledDuringCopy_StopsCopying() + { + using var cancellationTokenSource = new CancellationTokenSource(); + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new EndlessStream()) }); + + cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(100)); + using var resultStream = new MemoryStream(); + + Assert.That(() => _client.GetToStreamAsync("/resource", resultStream, cancellationTokenSource.Token), Throws.InstanceOf()); + } + + [Test] + public void DownloadAsync_WhenCopyFails_DisposesDestinationStream() + { + _mockMessageHandler.MockHttpResponse(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3, 4]) }); + + var destination = new FailingStream(); + + var exception = Assert.CatchAsync(() => _client.DownloadAsync(HttpMethod.Get, "/resource", null, _ => destination)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(destination.IsDisposed, Is.True); + Assert.That(exception, Is.InstanceOf()); + } + } + + /// + /// A stream that never ends; each read waits until it is canceled. + /// + private sealed class EndlessStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// + /// A writable stream whose writes always fail, and which records whether it was disposed. + /// + private sealed class FailingStream : MemoryStream + { + public bool IsDisposed { get; private set; } + + public override void Write(byte[] buffer, int offset, int count) => throw new IOException("Write failed."); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw new IOException("Write failed."); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => throw new IOException("Write failed."); + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class FailingContent : HttpContent + { + private readonly IOException _failure; + private readonly bool _failSynchronously; + + public FailingContent(IOException failure, bool failSynchronously) + { + _failure = failure; + _failSynchronously = failSynchronously; + } + + public bool IsDisposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + if (_failSynchronously) + throw _failure; + + return Task.FromException(_failure); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs b/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs index 6b3dbe4..3ad851c 100644 --- a/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs +++ b/tests/Kampute.HttpClient.Test/Utilities/AsyncUpdateThrottleTests.cs @@ -2,6 +2,7 @@ { using Kampute.HttpClient.Utilities; using NUnit.Framework; + using System; using System.Threading.Tasks; [TestFixture] @@ -55,5 +56,58 @@ public async Task TryUpdateAsync_DoesNotUpdateIfAnotherUpdateHasCompleted() Assert.That(synchronizer.Value, Is.EqualTo(2)); } } + + [Test] + public async Task TryUpdateAsync_StartedAfterAnotherUpdateCompleted_UpdatesValue() + { + using var synchronizer = new AsyncUpdateThrottle(1); + + var firstResult = await synchronizer.TryUpdateAsync(() => Task.FromResult(2)); + var secondResult = await synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(firstResult, Is.True); + Assert.That(secondResult, Is.True); + Assert.That(synchronizer.Value, Is.EqualTo(3)); + } + } + + [Test] + public async Task TryUpdateAsync_StartedWhileAnotherUpdateIsRunning_DoesNotUpdateValue() + { + using var synchronizer = new AsyncUpdateThrottle(1); + var firstUpdateGate = new TaskCompletionSource(); + + var firstUpdate = synchronizer.TryUpdateAsync(async () => + { + await firstUpdateGate.Task; + return 2; + }); + var secondUpdate = synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + firstUpdateGate.SetResult(true); + + var results = await Task.WhenAll(firstUpdate, secondUpdate); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0], Is.True); + Assert.That(results[1], Is.False); + Assert.That(synchronizer.Value, Is.EqualTo(2)); + } + } + + [Test] + public async Task LastUpdateTime_KeepsFullPrecision() + { + using var synchronizer = new AsyncUpdateThrottle(1); + await synchronizer.TryUpdateAsync(() => Task.FromResult(2)); + + var before = DateTimeOffset.UtcNow; + await synchronizer.TryUpdateAsync(() => Task.FromResult(3)); + var after = DateTimeOffset.UtcNow; + + Assert.That(synchronizer.LastUpdateTime, Is.InRange(before, after)); + } } } diff --git a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs similarity index 56% rename from tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs rename to tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs index d10ae41..e3a0dda 100644 --- a/tests/Kampute.HttpClient.Xml.Test/HttpRestClientXmlExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/Xml/HttpRestClientXmlExtensionsTests.cs @@ -1,10 +1,13 @@ -namespace Kampute.HttpClient.Xml.Test +namespace Kampute.HttpClient.Test.Xml { using Kampute.HttpClient; using Kampute.HttpClient.TestSupport; + using Kampute.HttpClient.Xml; + using Kampute.Resilience; using Moq; using NUnit.Framework; using System; + using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -34,7 +37,6 @@ public void Setup() { BaseAddress = new Uri("http://api.test.com/xml"), }; - _restClient.AcceptXml(); } [TearDown] @@ -44,84 +46,86 @@ public void Cleanup() } [Test] - public async Task PostAsXmlAsync_InvokesHttpClientCorrectly() + public void UseXml_RegistersOneFormatterAndConfiguresIt() { - var payload = new TestModel { Name = "XML Test" }; + var first = _restClient.UseXml(); + var second = _restClient.UseXml(formatter => formatter.Serializer = XmlSerializerKind.DataContractSerializer); - _mockMessageHandler.MockHttpResponse(request => + using (Assert.EnterMultipleScope()) { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; - }); - - var result = await _restClient.PostAsXmlAsync("/echo", payload); - - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + Assert.That(second, Is.SameAs(first)); + Assert.That(second.Serializer, Is.EqualTo(XmlSerializerKind.DataContractSerializer)); + Assert.That(_restClient.ContentFormatters.OfType().Count(), Is.EqualTo(1)); + } } [Test] - public async Task PutAsXmlAsync_InvokesHttpClientCorrectly() + public async Task UseXml_AdvertisesXmlInAcceptHeader() { - var payload = new TestModel { Name = "XML Test" }; - + _restClient.UseXml(); + var accepted = default(string); _mockMessageHandler.MockHttpResponse(request => { - using (Assert.EnterMultipleScope()) + accepted = request.Headers.Accept.ToString(); + return new HttpResponseMessage(HttpStatusCode.OK) { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Put)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, + Content = new StringContent(new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), Encoding.UTF8, MediaTypeNames.Application.Xml), }; }); - var result = await _restClient.PutAsXmlAsync("/echo", payload); + var result = await _restClient.GetAsync("/model"); - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + using (Assert.EnterMultipleScope()) + { + Assert.That(accepted, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(result, Is.EqualTo(new PlainModel { Name = "Test" })); + } } - [Test] - public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PostAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) { - var payload = new TestModel { Name = "XML Test" }; + await AssertEchoed(HttpMethod.Post, serializer, (uri, payload) => _restClient.PostAsXmlAsync(uri, payload)); + } + + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PutAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) + { + await AssertEchoed(HttpMethod.Put, serializer, (uri, payload) => _restClient.PutAsXmlAsync(uri, payload)); + } + + [TestCase(XmlSerializerKind.XmlSerializer)] + [TestCase(XmlSerializerKind.DataContractSerializer)] + public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly(XmlSerializerKind serializer) + { + await AssertEchoed(HttpMethod.Patch, serializer, (uri, payload) => _restClient.PatchAsXmlAsync(uri, payload)); + } + [Test] + public async Task PostAsXmlAsync_WithoutRegistration_SendsWithDefaultSettings() + { + var sentBody = default(string); _mockMessageHandler.MockHttpResponse(request => { - using (Assert.EnterMultipleScope()) - { - Assert.That(request.Method, Is.EqualTo(HttpMethod.Patch)); - Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); - Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - } - - return new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = request.Content, - }; + sentBody = request.Content!.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.NoContent); }); - var result = await _restClient.PatchAsXmlAsync("/echo", payload); + await _restClient.PostAsXmlAsync("/models", new ContractModel { Name = "Test" }); - Assert.That(result, Is.Not.SameAs(payload)); - Assert.That(result, Is.EqualTo(payload)); + using (Assert.EnterMultipleScope()) + { + Assert.That(sentBody, Is.EqualTo(new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + Assert.That(_restClient.ContentFormatters, Is.Empty); + } + } + + [Test] + public void PostAsXmlAsync_WithNullPayload_ThrowsBeforeReturningTask() + { + Assert.Throws(() => _restClient.PostAsXmlAsync("/models", null!)); } [TestCase("gzip", SocketError.HostUnreachable)] @@ -130,11 +134,11 @@ public async Task PatchAsXmlAsync_InvokesHttpClientCorrectly() [TestCase("deflate", SocketError.TimedOut)] public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_RetriesSerializedPayload(string encoding, SocketError socketError) { - var payload = new TestModel { Name = "XML Test" }; + var payload = new PlainModel { Name = "XML Test" }; var maxRetries = 2; var attempts = 0; - _restClient.BackoffStrategy = BackoffStrategies.Uniform((uint)maxRetries, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries((uint)maxRetries).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse(request => { @@ -145,7 +149,7 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries Assert.That(request.Content, Is.Not.Null); Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } if (attempts <= maxRetries) @@ -166,11 +170,11 @@ public async Task SendAsync_OnConnectionFailure_WithCompressedXmlContent_Retries [TestCase("deflate")] public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry(string encoding) { - var payload = new TestModel { Name = "XML Test" }; + var payload = new PlainModel { Name = "XML Test" }; var attempts = 0; using var cancellationTokenSource = new CancellationTokenSource(); - _restClient.BackoffStrategy = BackoffStrategies.Uniform(2, TimeSpan.Zero); + _restClient.RetryPolicy = RetryStrategies.Constant(TimeSpan.Zero).WithMaxRetries(2).ToHttpRetryPolicy(); _mockMessageHandler.MockHttpResponse((request, cancellationToken) => { @@ -180,7 +184,7 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry { Assert.That(request.Content, Is.Not.Null); Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } cancellationTokenSource.Cancel(); @@ -193,17 +197,17 @@ public void SendAsync_OnCallerCancellation_WithCompressedXmlContent_DoesNotRetry Assert.ThrowsAsync ( Is.InstanceOf(), - async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationTokenSource.Token) + async () => await _restClient.SendAsync(HttpMethod.Post, "/resource", compressedContent, cancellationToken: cancellationTokenSource.Token) ); Assert.That(attempts, Is.EqualTo(1)); } [TestCase("gzip")] [TestCase("deflate")] - public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesBackoffStrategy(string encoding) + public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesRetryPolicy(string encoding) { - var payload = new TestModel { Name = "XML Test" }; - var mockBackoffStrategy = RetryTestHelpers.MockBackoffStrategy(1, out var mockRetryScheduler); + var payload = new PlainModel { Name = "XML Test" }; + var mockRetryPolicy = RetryTestHelpers.MockRetryPolicy(1, out var mockRetrySession); var attempts = 0; using var testHandler = new TestHttpMessageHandler @@ -216,7 +220,7 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB { Assert.That(request.Content, Is.Not.Null); Assert.That(request.Content?.Headers.ContentEncoding, Contains.Item(encoding)); - Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlString(Encoding.UTF8))); + Assert.That(ReadCompressedContent(request.Content!), Is.EqualTo(payload.ToXmlSerializerString(Encoding.UTF8))); } if (attempts == 1) @@ -234,21 +238,55 @@ public async Task SendAsync_OnTimeoutCancellation_WithCompressedXmlContent_UsesB { BaseAddress = new Uri("http://api.test.com/xml"), }; - timedOutClient.AcceptXml(); - timedOutClient.BackoffStrategy = mockBackoffStrategy.Object; + timedOutClient.UseXml(); + timedOutClient.RetryPolicy = mockRetryPolicy.Object; using var content = new XmlContent(payload); using var compressedContent = CompressContent(content, encoding); using var response = await timedOutClient.SendAsync(HttpMethod.Post, "/resource", compressedContent); - mockBackoffStrategy.Verify(strategy => strategy.CreateScheduler(It.IsAny()), Times.Once); - mockRetryScheduler.Verify(scheduler => scheduler.WaitAsync(It.IsAny()), Times.Once); + mockRetryPolicy.Verify(strategy => strategy.CreateSession(It.IsAny()), Times.Once); + mockRetrySession.Verify(session => session.WaitToRetryAsync(It.IsAny()), Times.Once); using (Assert.EnterMultipleScope()) { Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); Assert.That(attempts, Is.EqualTo(2)); } } + + private async Task AssertEchoed(HttpMethod method, XmlSerializerKind serializer, Func> send) + { + _restClient.UseXml(formatter => formatter.Serializer = serializer); + var payload = new ContractModel { Name = "XML Test" }; + var expectedBody = serializer == XmlSerializerKind.XmlSerializer + ? payload.ToXmlSerializerString(Encoding.UTF8) + : payload.ToDataContractString(Encoding.UTF8); + + _mockMessageHandler.MockHttpResponse(request => + { + using (Assert.EnterMultipleScope()) + { + Assert.That(request.Method, Is.EqualTo(method)); + Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/echo"))); + Assert.That(request.Content?.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(request.Content?.ReadAsStringAsync().Result, Is.EqualTo(expectedBody)); + } + + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = request.Content, + }; + }); + + var result = await send("/echo", payload); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.Not.SameAs(payload)); + Assert.That(result, Is.EqualTo(payload)); + } + } } } diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs new file mode 100644 index 0000000..bad6442 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlContentTests.cs @@ -0,0 +1,80 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using Kampute.HttpClient.Xml; + using NUnit.Framework; + using System.Text; + using System.Threading.Tasks; + + [TestFixture] + public class XmlContentTests + { + [Test] + public async Task WithXmlSerializer_AndDefaultEncoding_SetsContentCorrectly() + { + var model = new PlainModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model) { Serializer = XmlSerializerKind.XmlSerializer }; + + await AssertContent(xmlContent, model.ToXmlSerializerString(Encoding.UTF8), Encoding.UTF8); + } + + [Test] + public async Task WithXmlSerializer_AndCustomEncoding_SetsContentCorrectly() + { + var encoding = Encoding.BigEndianUnicode; + var model = new PlainModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model, encoding) { Serializer = XmlSerializerKind.XmlSerializer }; + + await AssertContent(xmlContent, model.ToXmlSerializerString(encoding), encoding); + } + + [Test] + public async Task WithDataContractSerializer_AndDefaultEncoding_SetsContentCorrectly() + { + var model = new ContractModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model) { Serializer = XmlSerializerKind.DataContractSerializer }; + + await AssertContent(xmlContent, model.ToDataContractString(Encoding.UTF8), Encoding.UTF8); + } + + [Test] + public async Task WithDataContractSerializer_AndCustomEncoding_SetsContentCorrectly() + { + var encoding = Encoding.BigEndianUnicode; + var model = new ContractModel { Name = "Test" }; + + using var xmlContent = new XmlContent(model, encoding) { Serializer = XmlSerializerKind.DataContractSerializer }; + + await AssertContent(xmlContent, model.ToDataContractString(encoding), encoding); + } + + [Test] + public async Task WithAuto_ChoosesSerializerByPayloadType() + { + using var plainContent = new XmlContent(new PlainModel { Name = "Test" }); + using var contractContent = new XmlContent(new ContractModel { Name = "Test" }); + using var dualContent = new XmlContent(new DualModel { Name = "Test" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await plainContent.ReadAsStringAsync(), Does.Not.Contain(XmlStrings.DataContractNamespacePrefix)); + Assert.That(await contractContent.ReadAsStringAsync(), Does.Contain(XmlStrings.DataContractNamespacePrefix)); + Assert.That(await dualContent.ReadAsStringAsync(), Does.Contain(XmlStrings.DataContractNamespacePrefix)); + } + } + + private static async Task AssertContent(XmlContent xmlContent, string expectedString, Encoding encoding) + { + var xmlString = await xmlContent.ReadAsStringAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(xmlString, Is.EqualTo(expectedString)); + Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); + Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); + } + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs new file mode 100644 index 0000000..116e323 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlFormatterTests.cs @@ -0,0 +1,176 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using Kampute.HttpClient.Xml; + using NUnit.Framework; + using System; + using System.Net.Http; + using System.Runtime.Serialization; + using System.Text; + using System.Threading.Tasks; + using System.Xml; + + [TestFixture] + public class XmlFormatterTests + { + [Test] + public void Serializer_DefaultsToAuto() + { + Assert.That(new XmlFormatter().Serializer, Is.EqualTo(XmlSerializerKind.Auto)); + } + + [Test] + public void MediaTypes_AreApplicationXmlInBothDirections() + { + var formatter = new XmlFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(formatter.GetReadableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); + Assert.That(formatter.GetWritableMediaTypes(typeof(PlainModel)), Is.EqualTo(new[] { MediaTypeNames.Application.Xml })); + Assert.That(formatter.CanRead(MediaTypeNames.Application.Xml, typeof(ContractModel)), Is.True); + Assert.That(formatter.CanRead("Application/XML", typeof(PlainModel)), Is.True); + Assert.That(formatter.CanRead(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); + Assert.That(formatter.CanWrite("Application/XML", typeof(ContractModel)), Is.True); + Assert.That(formatter.CanWrite(MediaTypeNames.Application.Json, typeof(PlainModel)), Is.False); + } + } + + [TestCase("utf-8")] + [TestCase("utf-16BE")] + public async Task ReadAsync_WithXmlSerializer_ReadsObject(string encodingName) + { + var encoding = Encoding.GetEncoding(encodingName); + var expected = new PlainModel { Name = "Test" }; + using var content = new StringContent(expected.ToXmlSerializerString(encoding), encoding, MediaTypeNames.Application.Xml); + var formatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + + var result = await formatter.ReadAsync(content, typeof(PlainModel)); + + Assert.That(result, Is.EqualTo(expected)); + } + + [TestCase("utf-8")] + [TestCase("utf-16BE")] + public async Task ReadAsync_WithDataContractSerializer_ReadsObject(string encodingName) + { + var encoding = Encoding.GetEncoding(encodingName); + var expected = new ContractModel { Name = "Test" }; + using var content = new StringContent(expected.ToDataContractString(encoding), encoding, MediaTypeNames.Application.Xml); + var formatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + var result = await formatter.ReadAsync(content, typeof(ContractModel)); + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public async Task ReadAsync_WithAuto_UsesDataContractSerializerForDataContractTypes() + { + var formatter = new XmlFormatter(); + + var contract = await Read(formatter, new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(ContractModel)); + var dual = await Read(formatter, new DualModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(DualModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(contract, Is.EqualTo(new ContractModel { Name = "Test" })); + Assert.That(dual, Is.EqualTo(new DualModel { Name = "Test" })); + } + } + + [Test] + public async Task ReadAsync_WithAuto_UsesXmlSerializerForOtherTypes() + { + var formatter = new XmlFormatter(); + + var result = await Read(formatter, new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), typeof(PlainModel)); + + Assert.That(result, Is.EqualTo(new PlainModel { Name = "Test" })); + } + + [Test] + public async Task ReadAsync_WithFixedSerializer_IgnoresTheAttributes() + { + var xmlSerializerFormatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + var dataContractFormatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + var contract = await Read(xmlSerializerFormatter, new ContractModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8), typeof(ContractModel)); + var plain = await Read(dataContractFormatter, new PlainModel { Name = "Test" }.ToDataContractString(Encoding.UTF8), typeof(PlainModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(contract, Is.EqualTo(new ContractModel { Name = "Test" })); + Assert.That(plain, Is.EqualTo(new PlainModel { Name = "Test" })); + } + } + + [Test] + public async Task Write_WithAuto_ChoosesSerializerByPayloadType() + { + var formatter = new XmlFormatter(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await Write(formatter, new PlainModel { Name = "Test" }), Is.EqualTo(new PlainModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8))); + Assert.That(await Write(formatter, new ContractModel { Name = "Test" }), Is.EqualTo(new ContractModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + Assert.That(await Write(formatter, new DualModel { Name = "Test" }), Is.EqualTo(new DualModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + } + } + + [Test] + public async Task Write_WithFixedSerializer_IgnoresTheAttributes() + { + var xmlSerializerFormatter = new XmlFormatter { Serializer = XmlSerializerKind.XmlSerializer }; + var dataContractFormatter = new XmlFormatter { Serializer = XmlSerializerKind.DataContractSerializer }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(await Write(xmlSerializerFormatter, new ContractModel { Name = "Test" }), Is.EqualTo(new ContractModel { Name = "Test" }.ToXmlSerializerString(Encoding.UTF8))); + Assert.That(await Write(dataContractFormatter, new PlainModel { Name = "Test" }), Is.EqualTo(new PlainModel { Name = "Test" }.ToDataContractString(Encoding.UTF8))); + } + } + + [Test] + public async Task DataContractSettings_ApplyInBothDirections() + { + var dictionary = new XmlDictionary(); + var formatter = new XmlFormatter + { + DataContractSettings = new DataContractSerializerSettings + { + RootName = dictionary.Add("Renamed"), + RootNamespace = dictionary.Add("urn:test"), + }, + }; + var model = new ContractModel { Name = "Test" }; + + var written = await Write(formatter, model); + var read = await Read(formatter, written, typeof(ContractModel)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(written, Does.Contain("(() => Read(new XmlFormatter(), written, typeof(ContractModel))); + } + } + + [Test] + public void Write_WithUnsupportedMediaType_ThrowsNotSupportedException() + { + Assert.Throws(() => new XmlFormatter().Write(new PlainModel(), MediaTypeNames.Application.Json)); + } + + private static async Task Read(XmlFormatter formatter, string xml, Type modelType) + { + using var content = new StringContent(xml, Encoding.UTF8, MediaTypeNames.Application.Xml); + return await formatter.ReadAsync(content, modelType); + } + + private static async Task Write(XmlFormatter formatter, object payload) + { + using var content = formatter.Write(payload, MediaTypeNames.Application.Xml); + return await content.ReadAsStringAsync(); + } + } +} diff --git a/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs b/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs new file mode 100644 index 0000000..418ca74 --- /dev/null +++ b/tests/Kampute.HttpClient.Test/Xml/XmlTestModels.cs @@ -0,0 +1,72 @@ +namespace Kampute.HttpClient.Test.Xml +{ + using System; + using System.Net; + using System.Runtime.Serialization; + using System.Text; + using System.Xml.Serialization; + + public class PlainModel + { + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is PlainModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToXmlSerializerString(Encoding encoding) => XmlStrings.XmlSerializerFormat(encoding, nameof(PlainModel), Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(PlainModel), Name); + } + + [DataContract] + public class ContractModel + { + [DataMember] + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is ContractModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToXmlSerializerString(Encoding encoding) => XmlStrings.XmlSerializerFormat(encoding, nameof(ContractModel), Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(ContractModel), Name); + } + + [DataContract] + [XmlRoot(nameof(DualModel))] + public class DualModel + { + [DataMember] + [XmlElement] + public string? Name { get; set; } + + public override bool Equals(object? obj) => obj is DualModel other && Name == other.Name; + + public override int GetHashCode() => HashCode.Combine(Name); + + public string ToDataContractString(Encoding encoding) => XmlStrings.DataContractFormat(encoding, nameof(DualModel), Name); + } + + public static class XmlStrings + { + public const string DataContractNamespacePrefix = "http://schemas.datacontract.org/2004/07/"; + + public static string XmlSerializerFormat(Encoding encoding, string rootName, string? name) + { + return $"" + + $"<{rootName} xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">" + + $"{WebUtility.HtmlEncode(name)}" + + $""; + } + + public static string DataContractFormat(Encoding encoding, string rootName, string? name) + { + return $"" + + $"<{rootName} xmlns:i=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"{DataContractNamespacePrefix}{typeof(XmlStrings).Namespace}\">" + + $"{WebUtility.HtmlEncode(name)}" + + $""; + } + } +} diff --git a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs index 240a825..2b0b8ae 100644 --- a/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs +++ b/tests/Kampute.HttpClient.TestSupport/RetryTestHelpers.cs @@ -1,25 +1,26 @@ namespace Kampute.HttpClient.TestSupport { using Kampute.HttpClient.Interfaces; + using Kampute.Resilience; using Moq; using System.Threading; public static class RetryTestHelpers { - public static Mock MockBackoffStrategy(int retriesToAllow, out Mock mockRetryScheduler) + public static Mock MockRetryPolicy(int retriesToAllow, out Mock mockRetrySession) { - mockRetryScheduler = new Mock(); + mockRetrySession = new Mock(); var retries = 0; - mockRetryScheduler.Setup(scheduler => scheduler.WaitAsync(It.IsAny())) + mockRetrySession.Setup(session => session.WaitToRetryAsync(It.IsAny())) .ReturnsAsync(() => retries < retriesToAllow) .Callback(() => ++retries); - var mockBackoffStrategy = new Mock(); - mockBackoffStrategy.Setup(strategy => strategy.CreateScheduler(It.IsAny())) - .Returns(mockRetryScheduler.Object); + var mockRetryPolicy = new Mock(); + mockRetryPolicy.Setup(strategy => strategy.CreateSession(It.IsAny())) + .Returns(mockRetrySession.Object); - return mockBackoffStrategy; + return mockRetryPolicy; } } } diff --git a/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs b/tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs similarity index 53% rename from tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs rename to tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs index ddc5bdc..af65668 100644 --- a/tests/Kampute.HttpClient.TestSupport/TestContentDeserializer.cs +++ b/tests/Kampute.HttpClient.TestSupport/TestContentFormatter.cs @@ -8,21 +8,21 @@ namespace Kampute.HttpClient.TestSupport using System.Threading; using System.Threading.Tasks; - public class TestContentDeserializer : IHttpContentDeserializer + public class TestContentFormatter : IHttpContentFormatter { public IReadOnlyCollection SupportedMediaTypes { get; } = [Constants.TestMediaType]; - public IEnumerable GetSupportedMediaTypes(Type? modelType) + public IEnumerable GetReadableMediaTypes(Type? modelType) { return modelType is not null && CanParse(modelType) ? SupportedMediaTypes : []; } - public bool CanDeserialize(string mediaType, Type? modelType) + public bool CanRead(string mediaType, Type? modelType) { - return SupportedMediaTypes.Contains(mediaType) && modelType is not null && CanParse(modelType); + return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) && modelType is not null && CanParse(modelType); } - public async Task DeserializeAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) + public async Task ReadAsync(HttpContent content, Type modelType, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(modelType); @@ -31,6 +31,24 @@ public bool CanDeserialize(string mediaType, Type? modelType) return (typeof(TestErrorResponse) == modelType) ? new TestErrorResponse(str) : Convert.ChangeType(str, modelType); } + public IEnumerable GetWritableMediaTypes(Type payloadType) + { + return payloadType is not null && CanParse(payloadType) ? SupportedMediaTypes : []; + } + + public bool CanWrite(string mediaType, Type payloadType) + { + return SupportedMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase) && payloadType is not null && CanParse(payloadType); + } + + public HttpContent Write(object payload, string mediaType) + { + ArgumentNullException.ThrowIfNull(payload); + ArgumentNullException.ThrowIfNull(mediaType); + + return new TestContent(payload); + } + private static bool CanParse(Type modelType) { return modelType.IsPrimitive diff --git a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj b/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj deleted file mode 100644 index f7ca54c..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/Kampute.HttpClient.Xml.Test.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - false - true - latest - enable - 1701;1702;IDE0290;IDE0028 - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - diff --git a/tests/Kampute.HttpClient.Xml.Test/TestModel.cs b/tests/Kampute.HttpClient.Xml.Test/TestModel.cs deleted file mode 100644 index 220be4b..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/TestModel.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using System; - using System.Net; - using System.Text; - - public class TestModel - { - public string? Name { get; set; } - - public override bool Equals(object? obj) - { - return obj is TestModel other && Name == other.Name; - } - - public override int GetHashCode() - { - return HashCode.Combine(Name); - } - - public string ToXmlString(Encoding encoding) - { - return $"" - + "" - + Element(nameof(Name), Name) - + ""; - - static string Element(string name, string? value) - { - return value is null - ? $"<{name} xsi:nil=\"true\"/>" - : $"<{name}>{WebUtility.HtmlEncode(value)}"; - } - } - } -} diff --git a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs b/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs deleted file mode 100644 index 0430fa3..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/XmlContentDeserializerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using NUnit.Framework; - using System.Net.Http; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentDeserializerTests - { - [Test] - public void GetSupportedMediaTypes_ReturnsCorrectMediaTypes() - { - var deserializer = new XmlContentDeserializer(); - - var supportedMediaTypes = deserializer.GetSupportedMediaTypes(typeof(TestModel)); - - Assert.That(supportedMediaTypes, Contains.Item(MediaTypeNames.Application.Xml)); - } - - [Test] - public void CanDeserialize_ForSupportedMediaType_ReturnsTrue() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Xml, typeof(TestModel)); - - Assert.That(canDeserialize, Is.True); - } - - [Test] - public void CanDeserialize_ForUnsupportedMediaType_ReturnsFalse() - { - var deserializer = new XmlContentDeserializer(); - - var canDeserialize = deserializer.CanDeserialize(MediaTypeNames.Application.Json, typeof(TestModel)); - - Assert.That(canDeserialize, Is.False); - } - - [Test] - public async Task DeserializeAsync_WithUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.UTF8; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - - [Test] - public async Task DeserializeAsync_WithNonUtf8EncodedXmlContent_ReturnsCorrectObject() - { - var encoding = Encoding.BigEndianUnicode; - var expected = new TestModel { Name = "Test" }; - var content = new StringContent(expected.ToXmlString(encoding), encoding, MediaTypeNames.Application.Xml); - var deserializer = new XmlContentDeserializer(); - - var result = await deserializer.DeserializeAsync(content, typeof(TestModel)) as TestModel; - - Assert.That(result, Is.EqualTo(expected)); - } - } -} diff --git a/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs b/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs deleted file mode 100644 index e26c4c9..0000000 --- a/tests/Kampute.HttpClient.Xml.Test/XmlContentTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace Kampute.HttpClient.Xml.Test -{ - using NUnit.Framework; - using System.Text; - using System.Threading.Tasks; - - [TestFixture] - public class XmlContentTests - { - [Test] - public async Task WithDefaultEncoding_SetsContentCorrectly() - { - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(Encoding.UTF8); - - using var xmlContent = new XmlContent(model); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(Encoding.UTF8.WebName)); - } - } - - [Test] - public async Task WithCustomEncoding_SetsContentCorrectly() - { - var encoding = Encoding.BigEndianUnicode; - var model = new TestModel { Name = "Test" }; - var expectedString = model.ToXmlString(encoding); - - using var xmlContent = new XmlContent(model, encoding); - - var xmlString = await xmlContent.ReadAsStringAsync(); - - using (Assert.EnterMultipleScope()) - { - Assert.That(xmlString, Is.EqualTo(expectedString)); - Assert.That(xmlContent.Headers.ContentType?.MediaType, Is.EqualTo(MediaTypeNames.Application.Xml)); - Assert.That(xmlContent.Headers.ContentType?.CharSet, Is.EqualTo(encoding.WebName)); - } - } - } -}