From 9dccb62865b5afba129136806dee0e0192e62514 Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Sat, 30 Sep 2023 07:07:51 +0300 Subject: [PATCH] Imported Tingle.Extensions.Http (#161) --- README.md | 1 + Tingle.Extensions.sln | 74 +++-- .../AbstractHttpApiClient.cs | 283 ++++++++++++++++++ .../AbstractHttpApiClientOptions.cs | 38 +++ .../HttpApiResponseException.cs | 76 +++++ .../HttpApiResponseProblem.cs | 56 ++++ .../HttpJsonSerializerContext.cs | 7 + .../IServiceCollectionExtensions.cs | 52 ++++ .../KnownHeaderAttribute.cs | 19 ++ src/Tingle.Extensions.Http/MessageStrings.cs | 7 + src/Tingle.Extensions.Http/QueryHelper.cs | 88 ++++++ src/Tingle.Extensions.Http/README.md | 46 +++ .../ResourceResponse.cs | 171 +++++++++++ .../ResourceResponseHeaders.cs | 75 +++++ .../Tingle.Extensions.Http.csproj | 12 + .../TingleJsonContent.cs | 68 +++++ tests/Directory.Build.props | 1 + .../AbstractApiClientTests.cs | 190 ++++++++++++ .../DynamicHttpMessageHandler.cs | 21 ++ .../HttpApiResponseProblemTests.cs | 36 +++ .../ResourceResponseHeadersTests.cs | 30 ++ .../ResourceResponseTests.cs | 208 +++++++++++++ .../Serialization/ToDoActivity.cs | 14 + .../Serialization/ToDoActivityKind.cs | 8 + .../Serialization/ToDoActivityWithKind.cs | 6 + .../Tingle.Extensions.Http.Tests.csproj | 7 + 26 files changed, 1564 insertions(+), 30 deletions(-) create mode 100644 src/Tingle.Extensions.Http/AbstractHttpApiClient.cs create mode 100644 src/Tingle.Extensions.Http/AbstractHttpApiClientOptions.cs create mode 100644 src/Tingle.Extensions.Http/HttpApiResponseException.cs create mode 100644 src/Tingle.Extensions.Http/HttpApiResponseProblem.cs create mode 100644 src/Tingle.Extensions.Http/HttpJsonSerializerContext.cs create mode 100644 src/Tingle.Extensions.Http/IServiceCollectionExtensions.cs create mode 100644 src/Tingle.Extensions.Http/KnownHeaderAttribute.cs create mode 100644 src/Tingle.Extensions.Http/MessageStrings.cs create mode 100644 src/Tingle.Extensions.Http/QueryHelper.cs create mode 100644 src/Tingle.Extensions.Http/README.md create mode 100644 src/Tingle.Extensions.Http/ResourceResponse.cs create mode 100644 src/Tingle.Extensions.Http/ResourceResponseHeaders.cs create mode 100644 src/Tingle.Extensions.Http/Tingle.Extensions.Http.csproj create mode 100644 src/Tingle.Extensions.Http/TingleJsonContent.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/AbstractApiClientTests.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/DynamicHttpMessageHandler.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/HttpApiResponseProblemTests.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/ResourceResponseHeadersTests.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/ResourceResponseTests.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivity.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityKind.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityWithKind.cs create mode 100644 tests/Tingle.Extensions.Http.Tests/Tingle.Extensions.Http.Tests.csproj diff --git a/README.md b/README.md index fd3a6e3..feecb6f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ This repository contains projects/libraries for adding useful functionality to d |`Tingle.AspNetCore.Tokens`|[![NuGet](https://img.shields.io/nuget/v/Tingle.AspNetCore.Tokens.svg)](https://www.nuget.org/packages/Tingle.AspNetCore.Tokens/)|Support for generation of continuation tokens in ASP.NET Core with optional expiry. Useful for pagination, user invite tokens, expiring operation tokens, etc. This is availed through the `ContinuationToken` and `TimedContinuationToken` types. See [docs](./src/Tingle.AspNetCore.Tokens/README.md) and [sample](./samples/TokensSample).| |`Tingle.Extensions.Caching.MongoDB`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Caching.MongoDB.svg)](https://www.nuget.org/packages/Tingle.Extensions.Caching.MongoDB/)|Distributed caching implemented with [MongoDB](https://mongodb.com) on top of `IDistributedCache`, inspired by [CosmosCache](https://github.com/Azure/Microsoft.Extensions.Caching.Cosmos). See [docs](./src/Tingle.Extensions.Caching.MongoDB/README.md)and [sample](./samples/AspNetCoreSessionState)| |`Tingle.Extensions.DataAnnotations`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.DataAnnotations.svg)](https://www.nuget.org/packages/Tingle.Extensions.DataAnnotations/)|Additional data validation attributes in the `System.ComponentModel.DataAnnotations` namespace. Some of this should have been present in the framework but are very specific to some use cases. For example `FiveStarRatingAttribute`. See [docs](./src/Tingle.Extensions.DataAnnotations/README.md).| +|`Tingle.Extensions.Http`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Http.svg)](https://www.nuget.org/packages/Tingle.Extensions.Http/)|Authentication providers for use with HttpClient and includes support for DI via `Microsoft.Extensions.Http`. See [docs](./src/Tingle.Extensions.Http/README.md).| |`Tingle.Extensions.Http.Authentication`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Http.Authentication.svg)](https://www.nuget.org/packages/Tingle.Extensions.Http.Authentication/)|Authentication providers for use with HttpClient and includes support for DI via `Microsoft.Extensions.Http`. See [docs](./src/Tingle.Extensions.Http.Authentication/README.md) and [sample](./samples/HttpAuthenticationSample).| |`Tingle.Extensions.JsonPatch`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.JsonPatch.svg)](https://www.nuget.org/packages/Tingle.Extensions.JsonPatch/)|JSON Patch (RFC 6902) support for .NET to easily generate JSON Patch documents using `System.Text.Json` for client applications. See [docs](./src/Tingle.Extensions.JsonPatch/README.md).| |`Tingle.Extensions.PhoneValidators`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.PhoneValidators.svg)](https://www.nuget.org/packages/Tingle.Extensions.PhoneValidators/)|Convenience for validation of phone numbers either via attributes or resolvable services. See [docs](./src/Tingle.Extensions.PhoneValidators/README.md).| diff --git a/Tingle.Extensions.sln b/Tingle.Extensions.sln index 80f3aaf..98a2475 100644 --- a/Tingle.Extensions.sln +++ b/Tingle.Extensions.sln @@ -22,7 +22,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.M EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.DataAnnotations", "src\Tingle.Extensions.DataAnnotations\Tingle.Extensions.DataAnnotations.csproj", "{51FA6572-8EB6-4291-8D02-BB736057A50E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication.Tests", "tests\Tingle.Extensions.Http.Authentication.Tests\Tingle.Extensions.Http.Authentication.Tests.csproj", "{D0C66D3A-ED1F-486E-AA19-BDBB19025368}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http", "src\Tingle.Extensions.Http\Tingle.Extensions.Http.csproj", "{5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication", "src\Tingle.Extensions.Http.Authentication\Tingle.Extensions.Http.Authentication.csproj", "{47F95938-964A-47FE-A0D6-1EDD0893455B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.JsonPatch", "src\Tingle.Extensions.JsonPatch\Tingle.Extensions.JsonPatch.csproj", "{913C0212-58AC-42B7-B555-F96B8E287E7F}" EndProject @@ -30,7 +32,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing", "src\Tingle.Extensions.Processing\Tingle.Extensions.Processing.csproj", "{A803DE4B-B050-48F2-82A1-8E947D8FB96C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.Serilog", "src\Tingle.Extensions.Serilog\Tingle.Extensions.Serilog.csproj", "{29035EF2-2391-4441-AAC5-85AA43586EEB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog", "src\Tingle.Extensions.Serilog\Tingle.Extensions.Serilog.csproj", "{29035EF2-2391-4441-AAC5-85AA43586EEB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{815F0941-3B70-4705-A583-AF627559595C}" ProjectSection(SolutionItems) = preProject @@ -51,7 +53,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Caching.M EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.DataAnnotations.Tests", "tests\Tingle.Extensions.DataAnnotations.Tests\Tingle.Extensions.DataAnnotations.Tests.csproj", "{8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication", "src\Tingle.Extensions.Http.Authentication\Tingle.Extensions.Http.Authentication.csproj", "{47F95938-964A-47FE-A0D6-1EDD0893455B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Tests", "tests\Tingle.Extensions.Http.Tests\Tingle.Extensions.Http.Tests.csproj", "{41980843-7F99-4AD2-B9E9-B13FC349A150}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Http.Authentication.Tests", "tests\Tingle.Extensions.Http.Authentication.Tests\Tingle.Extensions.Http.Authentication.Tests.csproj", "{D0C66D3A-ED1F-486E-AA19-BDBB19025368}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.JsonPatch.Tests", "tests\Tingle.Extensions.JsonPatch.Tests\Tingle.Extensions.JsonPatch.Tests.csproj", "{B82E2980-E145-4341-BAE0-8FAE1F110D0C}" EndProject @@ -59,7 +63,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing.Tests", "tests\Tingle.Extensions.Processing.Tests\Tingle.Extensions.Processing.Tests.csproj", "{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tingle.Extensions.Serilog.Tests", "tests\Tingle.Extensions.Serilog.Tests\Tingle.Extensions.Serilog.Tests.csproj", "{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog.Tests", "tests\Tingle.Extensions.Serilog.Tests\Tingle.Extensions.Serilog.Tests.csproj", "{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F}" ProjectSection(SolutionItems) = preProject @@ -76,7 +80,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataProtectionMongoDBSample EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpAuthenticationSample", "samples\HttpAuthenticationSample\HttpAuthenticationSample.csproj", "{37ED98F0-3129-49B8-BF60-3BDEBBB34822}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SerilogSample", "samples\SerilogSample\SerilogSample.csproj", "{CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SerilogSample", "samples\SerilogSample\SerilogSample.csproj", "{CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TokensSample", "samples\TokensSample\TokensSample.csproj", "{AC04C113-8F75-43BA-8FEE-987475A87C58}" EndProject @@ -114,10 +118,14 @@ Global {51FA6572-8EB6-4291-8D02-BB736057A50E}.Debug|Any CPU.Build.0 = Debug|Any CPU {51FA6572-8EB6-4291-8D02-BB736057A50E}.Release|Any CPU.ActiveCfg = Release|Any CPU {51FA6572-8EB6-4291-8D02-BB736057A50E}.Release|Any CPU.Build.0 = Release|Any CPU - {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Release|Any CPU.Build.0 = Release|Any CPU + {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E}.Release|Any CPU.Build.0 = Release|Any CPU + {47F95938-964A-47FE-A0D6-1EDD0893455B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47F95938-964A-47FE-A0D6-1EDD0893455B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47F95938-964A-47FE-A0D6-1EDD0893455B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47F95938-964A-47FE-A0D6-1EDD0893455B}.Release|Any CPU.Build.0 = Release|Any CPU {913C0212-58AC-42B7-B555-F96B8E287E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {913C0212-58AC-42B7-B555-F96B8E287E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU {913C0212-58AC-42B7-B555-F96B8E287E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -130,6 +138,10 @@ Global {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.ActiveCfg = Release|Any CPU {A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.Build.0 = Release|Any CPU + {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.Build.0 = Release|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Debug|Any CPU.Build.0 = Debug|Any CPU {A324CC70-36DD-4B38-9EC8-9069F6130FAC}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -158,10 +170,14 @@ Global {8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD}.Release|Any CPU.Build.0 = Release|Any CPU - {47F95938-964A-47FE-A0D6-1EDD0893455B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {47F95938-964A-47FE-A0D6-1EDD0893455B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {47F95938-964A-47FE-A0D6-1EDD0893455B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {47F95938-964A-47FE-A0D6-1EDD0893455B}.Release|Any CPU.Build.0 = Release|Any CPU + {41980843-7F99-4AD2-B9E9-B13FC349A150}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41980843-7F99-4AD2-B9E9-B13FC349A150}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41980843-7F99-4AD2-B9E9-B13FC349A150}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41980843-7F99-4AD2-B9E9-B13FC349A150}.Release|Any CPU.Build.0 = Release|Any CPU + {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0C66D3A-ED1F-486E-AA19-BDBB19025368}.Release|Any CPU.Build.0 = Release|Any CPU {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU {B82E2980-E145-4341-BAE0-8FAE1F110D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -174,6 +190,10 @@ Global {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU {978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.Build.0 = Release|Any CPU + {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.Build.0 = Release|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Debug|Any CPU.Build.0 = Debug|Any CPU {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -194,22 +214,14 @@ Global {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Debug|Any CPU.Build.0 = Debug|Any CPU {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Release|Any CPU.ActiveCfg = Release|Any CPU {37ED98F0-3129-49B8-BF60-3BDEBBB34822}.Release|Any CPU.Build.0 = Release|Any CPU - {AC04C113-8F75-43BA-8FEE-987475A87C58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AC04C113-8F75-43BA-8FEE-987475A87C58}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.Build.0 = Release|Any CPU - {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.Build.0 = Release|Any CPU - {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.Build.0 = Release|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Release|Any CPU.ActiveCfg = Release|Any CPU {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4}.Release|Any CPU.Build.0 = Release|Any CPU + {AC04C113-8F75-43BA-8FEE-987475A87C58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC04C113-8F75-43BA-8FEE-987475A87C58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC04C113-8F75-43BA-8FEE-987475A87C58}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -222,10 +234,12 @@ Global {B545B88C-4BE0-43FB-AE87-47706D479C6B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {0C6BE46B-FFBF-497C-82D9-6148364D24E8} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {51FA6572-8EB6-4291-8D02-BB736057A50E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} - {D0C66D3A-ED1F-486E-AA19-BDBB19025368} = {815F0941-3B70-4705-A583-AF627559595C} + {5BFAD4DB-D6A6-44F4-ACAB-B7B04E5A052E} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {47F95938-964A-47FE-A0D6-1EDD0893455B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {913C0212-58AC-42B7-B555-F96B8E287E7F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {F46ADD04-B716-4E9B-9799-7C47DDDB08FC} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} {A324CC70-36DD-4B38-9EC8-9069F6130FAC} = {815F0941-3B70-4705-A583-AF627559595C} {E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C} {E28F4E8D-148B-4583-A27D-E1DA2CC08167} = {815F0941-3B70-4705-A583-AF627559595C} @@ -233,19 +247,19 @@ Global {FB2F8961-9F8F-4B35-ACAC-CCBEA2A89684} = {815F0941-3B70-4705-A583-AF627559595C} {0EA063C6-9A97-4DE8-9344-5D2BDD301134} = {815F0941-3B70-4705-A583-AF627559595C} {8E3530BB-ED60-4A2C-9BD2-C6879D67B4BD} = {815F0941-3B70-4705-A583-AF627559595C} - {47F95938-964A-47FE-A0D6-1EDD0893455B} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} + {41980843-7F99-4AD2-B9E9-B13FC349A150} = {815F0941-3B70-4705-A583-AF627559595C} + {D0C66D3A-ED1F-486E-AA19-BDBB19025368} = {815F0941-3B70-4705-A583-AF627559595C} {B82E2980-E145-4341-BAE0-8FAE1F110D0C} = {815F0941-3B70-4705-A583-AF627559595C} {526B37AD-256A-445A-9A42-E5C53989B11E} = {815F0941-3B70-4705-A583-AF627559595C} {978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C} + {8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C} {CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {E04EC969-2539-46E9-B918-8C8B7BEB8828} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {B97964EC-658A-4205-AA5A-BB814B191C35} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {ACED6271-2F75-4E3B-BB79-9D40B74D6A51} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} {37ED98F0-3129-49B8-BF60-3BDEBBB34822} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} - {AC04C113-8F75-43BA-8FEE-987475A87C58} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} - {29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72} - {8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C} {CFEE0754-9DAF-4AA6-98F3-477F065D7DC4} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} + {AC04C113-8F75-43BA-8FEE-987475A87C58} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B9323FCA-8E8B-4176-A463-87D202EC4552} diff --git a/src/Tingle.Extensions.Http/AbstractHttpApiClient.cs b/src/Tingle.Extensions.Http/AbstractHttpApiClient.cs new file mode 100644 index 0000000..b0f2301 --- /dev/null +++ b/src/Tingle.Extensions.Http/AbstractHttpApiClient.cs @@ -0,0 +1,283 @@ +using Microsoft.Extensions.Options; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Json; +using System.Text.Json.Serialization.Metadata; +using SC = Tingle.Extensions.Http.HttpJsonSerializerContext; + +namespace Tingle.Extensions.Http; + +/// An abstraction of a HTTP client for accessing HTTP REST APIs. +public abstract class AbstractHttpApiClient where TOptions : AbstractHttpApiClientOptions, new() +{ + /// Creates an instance of . + /// The for making requests. + /// The accessor for the configuration options. + protected AbstractHttpApiClient(HttpClient httpClient, IOptionsSnapshot optionsAccessor) + { + BackChannel = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor)); + } + + /// The client for making HTTP requests. + protected HttpClient BackChannel { get; } + + /// The options configured for this client. + protected TOptions Options { get; private set; } + + /// Send a request and download the response to a stream. + /// The request to make + /// The stream to copy to + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// The token to cancel the request. + /// + protected virtual Task> DownloadToStreamAsync(HttpRequestMessage request, + Stream destination, + int bufferSize = 81920, + CancellationToken cancellationToken = default) + => DownloadToStreamAsync(request, destination, SC.Default.HttpApiResponseProblem, bufferSize, cancellationToken); + + /// Send a request and download the response to a stream. + /// The type of problem to be extracted + /// The request to make + /// The stream to copy to + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// The token to cancel the request. + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual async Task> DownloadToStreamAsync(HttpRequestMessage request, + Stream destination, + int bufferSize = 81920, + CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + var problem = default(TProblem); + + // if the request succeeded write to the supplied stream + if (response.IsSuccessStatusCode) + { + // get a stream reference for the content and copy its contents to the destination + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await stream.CopyToAsync(destination, bufferSize: bufferSize, cancellationToken).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + } + // if the request did not succeed and we can deserialize the contents, do so + else + { + problem = await DeserializeAsync(response.Content, cancellationToken).ConfigureAwait(false); + } + + return new ResourceResponse(response: response, options: Options, resource: null, problem: problem); + } + + /// Send a request and download the response to a stream. + /// The type of problem to be extracted + /// The request to make + /// The stream to copy to + /// Metadata about the to convert. + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// The token to cancel the request. + /// + protected virtual async Task> DownloadToStreamAsync(HttpRequestMessage request, + Stream destination, + JsonTypeInfo problemJsonTypeInfo, + int bufferSize = 81920, + CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + var problem = default(TProblem); + + // if the request succeeded write to the supplied stream + if (response.IsSuccessStatusCode) + { + // get a stream reference for the content and copy its contents to the destination + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await stream.CopyToAsync(destination, bufferSize: bufferSize, cancellationToken).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + } + // if the request did not succeed and we can deserialize the contents, do so + else + { + problem = await DeserializeAsync(response.Content, problemJsonTypeInfo, cancellationToken).ConfigureAwait(false); + } + + return new ResourceResponse(response: response, options: Options, resource: null, problem: problem); + } + + /// Send a request and extract the response. + /// The type or resource to be extracted + /// + /// The token to cancel the request + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual async Task> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + (var resource, var problem) = await ExtractResponseAsync(response, cancellationToken).ConfigureAwait(false); + return new ResourceResponse(options: Options, response: response, resource: resource, problem: problem); + } + + /// Send a request and extract the response. + /// The type or resource to be extracted + /// + /// Metadata about the to convert. + /// The token to cancel the request + /// + protected virtual async Task> SendAsync(HttpRequestMessage request, + JsonTypeInfo jsonTypeInfo, + CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + (var resource, var problem) = await ExtractResponseAsync(response, jsonTypeInfo, SC.Default.HttpApiResponseProblem, cancellationToken).ConfigureAwait(false); + return new ResourceResponse(options: Options, response: response, resource: resource, problem: problem); + } + + /// Send a request and extract the response. + /// The type or resource to be extracted + /// The type of problem to be extracted + /// + /// The token to cancel the request + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual async Task> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + (var resource, var problem) = await ExtractResponseAsync(response, cancellationToken).ConfigureAwait(false); + return new ResourceResponse(response: response, options: Options, resource: resource, problem: problem); + } + + /// Send a request and extract the response. + /// The type or resource to be extracted + /// The type of problem to be extracted + /// + /// Metadata about the to convert. + /// Metadata about the to convert. + /// The token to cancel the request + /// + protected virtual async Task> SendAsync(HttpRequestMessage request, + JsonTypeInfo resourceJsonTypeInfo, + JsonTypeInfo problemJsonTypeInfo, + CancellationToken cancellationToken = default) + { + var response = await BackChannel.SendAsync(request, cancellationToken).ConfigureAwait(false); + (var resource, var problem) = await ExtractResponseAsync(response, resourceJsonTypeInfo, problemJsonTypeInfo, cancellationToken).ConfigureAwait(false); + return new ResourceResponse(response: response, options: Options, resource: resource, problem: problem); + } + + /// + /// Extracts the resource and problem from the response message. The resource is only extracted for successful requests according + /// to , otherwise the problem is extracted. + /// + /// The type or resource to be extracted + /// The type of problem to be extracted + /// The response message to be used for extraction + /// The token to cancel the request + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual async Task<(TResource?, TProblem?)> ExtractResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var resource = default(TResource); + var problem = default(TProblem); + + var content = response.Content; + + // if the response was a success then deserialize the body as TResource otherwise TProblem + if (response.IsSuccessStatusCode) + { + resource = await DeserializeAsync(content, cancellationToken).ConfigureAwait(false); + } + else + { + problem = await DeserializeAsync(content, cancellationToken).ConfigureAwait(false); + } + + return (resource, problem); + } + + /// + /// Extracts the resource and problem from the response message. The resource is only extracted for successful requests according + /// to , otherwise the problem is extracted. + /// + /// The type or resource to be extracted + /// The type of problem to be extracted + /// The response message to be used for extraction + /// Metadata about the to convert. + /// Metadata about the to convert. + /// the token to cancel the request + /// + protected virtual async Task<(TResource?, TProblem?)> ExtractResponseAsync(HttpResponseMessage response, + JsonTypeInfo resourceJsonTypeInfo, + JsonTypeInfo problemJsonTypeInfo, + CancellationToken cancellationToken) + { + var resource = default(TResource); + var problem = default(TProblem); + + var content = response.Content; + + // if the response was a success then deserialize the body as TResource otherwise TProblem + if (response.IsSuccessStatusCode) + { + resource = await DeserializeAsync(content, resourceJsonTypeInfo, cancellationToken).ConfigureAwait(false); + } + else + { + problem = await DeserializeAsync(content, problemJsonTypeInfo, cancellationToken).ConfigureAwait(false); + } + + return (resource, problem); + } + + /// Create with JSON content from the provided . + /// The type of the value to serialize. + /// the object to to write + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual HttpContent MakeJsonContent(TValue value) => JsonContent.Create(value, options: Options.SerializerOptions); + + /// Create with JSON content from the provided . + /// the object to to write + /// The type of the value to serialize. + /// + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual HttpContent MakeJsonContent(object value, Type valueType) => JsonContent.Create(value, valueType, options: Options.SerializerOptions); + + /// Create with JSON content from the provided . + /// The type of the value to serialize. + /// the object to to write + /// Metadata about the type to convert. + /// + protected virtual HttpContent MakeJsonContent(TValue value, JsonTypeInfo jsonTypeInfo) => TingleJsonContent.Create(value, jsonTypeInfo); + + /// Reads the UTF-8 encoded text representing a single JSON value into a . + /// The type to deserialize the JSON value into. + /// The from the response. + /// + /// A representation of the JSON value. + [RequiresUnreferencedCode(MessageStrings.SerializationUnreferencedCodeMessage)] + [RequiresDynamicCode(MessageStrings.SerializationRequiresDynamicCodeMessage)] + protected virtual Task DeserializeAsync(HttpContent content, CancellationToken cancellationToken) + { + return content.Headers.ContentLength == 0 + ? Task.FromResult(default) + : content.ReadFromJsonAsync(Options.SerializerOptions, cancellationToken); + } + + /// Reads the UTF-8 encoded text representing a single JSON value into a . + /// The type to deserialize the JSON value into. + /// The from the response. + /// Metadata about the type to convert. + /// + /// A representation of the JSON value. + protected virtual Task DeserializeAsync(HttpContent content, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken) + { + return content.Headers.ContentLength == 0 + ? Task.FromResult(default) + : content.ReadFromJsonAsync(jsonTypeInfo, cancellationToken); + } +} diff --git a/src/Tingle.Extensions.Http/AbstractHttpApiClientOptions.cs b/src/Tingle.Extensions.Http/AbstractHttpApiClientOptions.cs new file mode 100644 index 0000000..9f0ff9f --- /dev/null +++ b/src/Tingle.Extensions.Http/AbstractHttpApiClientOptions.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace Tingle.Extensions.Http; + +/// +/// Abstract service configuration options for +/// +public abstract class AbstractHttpApiClientOptions +{ + /// The options to use for JSON serialization. + public virtual JsonSerializerOptions SerializerOptions { get; set; } = new JsonSerializerOptions + { + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + WriteIndented = false, // less data used + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault + | System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + /// + /// Determines if the response headers should be included in the message when creating + /// via + /// or . + /// Defaults to false. + /// + public virtual bool IncludeHeadersInExceptionMessage { get; set; } = false; + + /// + /// Determines if the raw body should be included in the message when creating + /// via . + /// Defaults to false. + /// + public virtual bool IncludeRawBodyInExceptionMessage { get; set; } = false; +} diff --git a/src/Tingle.Extensions.Http/HttpApiResponseException.cs b/src/Tingle.Extensions.Http/HttpApiResponseException.cs new file mode 100644 index 0000000..16d99fd --- /dev/null +++ b/src/Tingle.Extensions.Http/HttpApiResponseException.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Runtime.Serialization; + +namespace Tingle.Extensions.Http; + +/// +/// An exception thrown when an API request does not succeed. +/// +[Serializable] +public class HttpApiResponseException : Exception +{ + /// + /// Creates an instance of + /// + public HttpApiResponseException() { } + + /// + /// Creates an instance of + /// + public HttpApiResponseException(string message) : base(message) { } + + /// + /// Creates an instance of + /// + public HttpApiResponseException(string message, Exception inner) : base(message, inner) { } + + /// + /// Creates an instance of + /// + protected HttpApiResponseException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + /// + /// Creates an instance of + /// + public HttpApiResponseException(string message, + HttpResponseMessage response, + object? resource, + object? problem, + IReadOnlyDictionary> headers) : base(message) + { + Response = response; + StatusCode = response.StatusCode; + ResponseCode = (int)response.StatusCode; + Resource = resource; + Problem = problem; + Headers = headers; + } + + /// The actual HTTP response. + public HttpResponseMessage? Response { get; } + + /// + /// The response status code gotten from the Response + /// + public HttpStatusCode StatusCode { get; } + + /// + /// The response status code gotten from the Response + /// + public int ResponseCode { get; } + + /// + /// The resource extracted from the response body, if any. + /// + public object? Resource { get; } + + /// + /// The problem extracted from the response body, if any. + /// + public object? Problem { get; } + + /// + /// The list of response headers as extracted from the Response + /// + public IReadOnlyDictionary>? Headers { get; } +} diff --git a/src/Tingle.Extensions.Http/HttpApiResponseProblem.cs b/src/Tingle.Extensions.Http/HttpApiResponseProblem.cs new file mode 100644 index 0000000..ca9fe02 --- /dev/null +++ b/src/Tingle.Extensions.Http/HttpApiResponseProblem.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Http; + +/// +/// A representation of an error normally used when a is returned. +/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. +/// +public class HttpApiResponseProblem +{ + /// + /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when + /// dereferenced, it provide human-readable documentation for the problem type + /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be + /// "about:blank". + /// + public virtual string? Type { get; set; } + + /// + /// A short, human-readable summary of the problem type.It SHOULD NOT change from occurrence to occurrence + /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; + /// see[RFC7231], Section 3.4). + /// + public virtual string? Title { get; set; } + + /// + /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. + /// + public virtual int? Status { get; set; } + + /// + /// A human-readable explanation specific to this occurrence of the problem. + /// + public virtual string? Detail { get; set; } + + /// + /// A URI reference that identifies the specific occurrence of the problem. + /// It may or may not yield further information if dereferenced. + /// + public virtual string? Instance { get; set; } + + /// + /// Gets the validation errors associated the problem. + /// + public virtual IDictionary? Errors { get; set; } + + /// + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. + /// Extension members appear in the same namespace as other members of a problem type. + /// + /// + [JsonExtensionData] + public virtual IDictionary Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); +} diff --git a/src/Tingle.Extensions.Http/HttpJsonSerializerContext.cs b/src/Tingle.Extensions.Http/HttpJsonSerializerContext.cs new file mode 100644 index 0000000..e336c5f --- /dev/null +++ b/src/Tingle.Extensions.Http/HttpJsonSerializerContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Http; + +[JsonSerializable(typeof(ResourceResponseHeaders))] +[JsonSerializable(typeof(HttpApiResponseProblem))] +internal partial class HttpJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Tingle.Extensions.Http/IServiceCollectionExtensions.cs b/src/Tingle.Extensions.Http/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..d9dccfb --- /dev/null +++ b/src/Tingle.Extensions.Http/IServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using System.Net.Http.Headers; +using Tingle.Extensions.Http; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for relating to +/// +public static class IServiceCollectionExtensions +{ + /// Add a HTTP API client implemented via . + /// the collection to be added to + /// + /// The client type. The type specified will be registered in the service collection as a transient service. + /// + /// An that can be used to configure the client. + public static IHttpClientBuilder AddHttpApiClient(this IServiceCollection services, + Action? configure = null) + where TClient : AbstractHttpApiClient + where TOptions : AbstractHttpApiClientOptions, new() + { + // if we have a configuration action, add it + if (configure != null) services.Configure(configure); + + return services.AddHttpClient() + .ConfigureHttpClient((provider, client) => + { + // populate the User-Agent header + var assemblyName = typeof(TClient).GetType().Assembly.GetName(); + var userAgent = new ProductInfoHeaderValue(productName: assemblyName.Name!, + productVersion: assemblyName.Version!.ToString(3)); + client.DefaultRequestHeaders.UserAgent.Add(userAgent); + }); + } + + /// Add a HTTP API client implemented via . + /// the collection to be added to + /// + /// The client resolution type. + /// The client implementation type. The type specified will be registered in the service collection as a transient service. + /// + /// An that can be used to configure the client. + public static IHttpClientBuilder AddHttpApiClient(this IServiceCollection services, + Action? configure = null) + where TClient : class + where TImplementation : AbstractHttpApiClient, TClient + where TOptions : AbstractHttpApiClientOptions, new() + { + services.AddTransient(p => p.GetRequiredService()); + return services.AddHttpApiClient(configure); + } +} diff --git a/src/Tingle.Extensions.Http/KnownHeaderAttribute.cs b/src/Tingle.Extensions.Http/KnownHeaderAttribute.cs new file mode 100644 index 0000000..afed112 --- /dev/null +++ b/src/Tingle.Extensions.Http/KnownHeaderAttribute.cs @@ -0,0 +1,19 @@ +namespace Tingle.Extensions.Http; + +/// +/// Represents a known header +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +internal sealed class KnownHeaderAttribute : Attribute +{ + /// + /// Creates an instance of + /// + /// the name of the header + public KnownHeaderAttribute(string name) { Name = name; } + + /// + /// The name of the header + /// + public string Name { get; set; } +} diff --git a/src/Tingle.Extensions.Http/MessageStrings.cs b/src/Tingle.Extensions.Http/MessageStrings.cs new file mode 100644 index 0000000..67bbe86 --- /dev/null +++ b/src/Tingle.Extensions.Http/MessageStrings.cs @@ -0,0 +1,7 @@ +namespace Tingle.Extensions.Http; + +internal class MessageStrings +{ + internal const string SerializationUnreferencedCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved."; + internal const string SerializationRequiresDynamicCodeMessage = "JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications."; +} diff --git a/src/Tingle.Extensions.Http/QueryHelper.cs b/src/Tingle.Extensions.Http/QueryHelper.cs new file mode 100644 index 0000000..0d657fc --- /dev/null +++ b/src/Tingle.Extensions.Http/QueryHelper.cs @@ -0,0 +1,88 @@ +using System.Text; +using System.Text.Encodings.Web; + +namespace Tingle.Extensions.Http; + +/// +/// Collection of helper methods for handling queries +/// +public static class QueryHelper +{ + /// + /// Make a query string using the given query key and value. + /// + /// The name of the query key. + /// The query value. + /// The query. + public static string MakeQueryString(string name, string value) => AddQueryString(string.Empty, name, value); + + /// + /// Make a query string using the given keys and values. + /// + /// A collection of name value query pairs to use. + /// The query. + public static string MakeQueryString(IDictionary queryString) => AddQueryString(string.Empty, queryString); + + /// + /// Append the given query key and value to the URI. + /// + /// The base URI. + /// The name of the query key. + /// The query value. + /// The combined result. + public static string AddQueryString(string uri, string name, string value) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + if (name == null) throw new ArgumentNullException(nameof(name)); + if (value == null) throw new ArgumentNullException(nameof(value)); + + return AddQueryString(uri, new[] { new KeyValuePair(name, value) }); + } + + /// + /// Append the given query keys and values to the URI. + /// + /// The base URI. + /// A collection of name value query pairs to append. + /// The combined result. + public static string AddQueryString(string uri, IDictionary queryString) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + if (queryString == null) throw new ArgumentNullException(nameof(queryString)); + + return AddQueryString(uri, (IEnumerable>)queryString); + } + + private static string AddQueryString(string uri, IEnumerable> queryString) + { + if (uri == null) throw new ArgumentNullException(nameof(uri)); + if (queryString == null) throw new ArgumentNullException(nameof(queryString)); + + var anchorIndex = uri.IndexOf('#'); + var uriToBeAppended = uri; + var anchorText = ""; + // If there is an anchor, then the query string must be inserted before its first occurrence. + if (anchorIndex != -1) + { + anchorText = uri[anchorIndex..]; + uriToBeAppended = uri[..anchorIndex]; + } + + var queryIndex = uriToBeAppended.IndexOf('?'); + var hasQuery = queryIndex != -1; + + var sb = new StringBuilder(); + sb.Append(uriToBeAppended); + foreach (var parameter in queryString) + { + sb.Append(hasQuery ? '&' : '?'); + sb.Append(UrlEncoder.Default.Encode(parameter.Key)); + sb.Append('='); + sb.Append(UrlEncoder.Default.Encode(parameter.Value)); + hasQuery = true; + } + + sb.Append(anchorText); + return sb.ToString(); + } +} diff --git a/src/Tingle.Extensions.Http/README.md b/src/Tingle.Extensions.Http/README.md new file mode 100644 index 0000000..97f02c5 --- /dev/null +++ b/src/Tingle.Extensions.Http/README.md @@ -0,0 +1,46 @@ +# Tingle.Extensions.Http + +This provides a light weight abstraction around `HttpClient` which can be used to build custom client with response wrapping semantics. + +The default serialization is done using JSON (`System.Text.Json`) but can be overridden to handle XML, SOAP, or any other formats including just changing the serializer to `Newtonsoft.Json`. + +Below we'll go through some examples of how the `AbstractHttpApiClient`. + +```cs +public class Account +{ + public string? Id { get; set; } + public string? Name { get; set; } +} + +public class MyServiceClient : AbstractApiClient +{ + public MyServiceClient(HttpClient client, IOptions optionsAccessor) : base(client, optionsAccessor){} + + public async Task> GetAccountAsync(string id, CancellationToken cancellationToken = default) + { + var uri = new Uri(BaseAddress, $"/v1/accounts/{id}"); + var request = new HttpRequestMessage(HttpMethod.Get, uri); + return await SendAsync(request, cancellationToken); + } + // ... +} + +public class MyServiceClientOptions : AbstractHttpApiClientOptions { } +``` + +Adding to Services Collection + +In `Program.cs` add the following code snippet: + +```cs +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddHttpApiClient(); + +var host = builder.Build(); +using var scope = host.Services.CreateScope(); +var client = scope.ServiceProvider.GetRequiredService(); +var response = await client.GetAccountAsync("123456789"); +response.EnsureSuccess(); // throws if not successful +response.EnsureHasResource(); // throws if the response body was empty (null resource) +``` diff --git a/src/Tingle.Extensions.Http/ResourceResponse.cs b/src/Tingle.Extensions.Http/ResourceResponse.cs new file mode 100644 index 0000000..3cb2ed3 --- /dev/null +++ b/src/Tingle.Extensions.Http/ResourceResponse.cs @@ -0,0 +1,171 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using SC = Tingle.Extensions.Http.HttpJsonSerializerContext; + +namespace Tingle.Extensions.Http; + +/// Representation of a HTTP response to an API with typed Error and Resource. +/// the type of resource +/// the type of problem +/// +/// There is no need to implement because there are no unmanaged resources in use +/// and there are no resources that the Garbage Collector does not know how to release. +/// The instance of referenced by is automatically disposed +/// once an instance of is no longer in use. +/// +public class ResourceResponse +{ + private readonly AbstractHttpApiClientOptions? options; + + /// + /// Create an instance of from another instance. + /// + /// The to copy from. + public ResourceResponse(ResourceResponse other) + { + Response = other.Response; + options = other.options; + Headers = other.Headers; + Resource = other.Resource; + Problem = other.Problem; + } + + /// Create an instance of . + /// the original HTTP response + /// the client options + /// the extracted resource + /// the extracted problem description + public ResourceResponse(HttpResponseMessage response, + AbstractHttpApiClientOptions? options = default, + TResource? resource = default, + TProblem? problem = default) + { + Response = response; + this.options = options; + Headers = new ResourceResponseHeaders(response); + Resource = resource; + Problem = problem; + } + + /// The original HTTP response. + public HttpResponseMessage Response { get; } + + /// The response status code gotten from . + public HttpStatusCode StatusCode => Response.StatusCode; + + /// The response status code gotten from . + public int ResponseCode => (int)Response.StatusCode; + + /// The list of response headers as extracted from . + public ResourceResponseHeaders Headers { get; } + + /// + /// Determines if the request was successful. + /// Value is true if is in the 200 to 299 range + /// + public virtual bool IsSuccessful => ((int)StatusCode >= 200) && ((int)StatusCode <= 299); + + /// The resource extracted from the response body. + public TResource? Resource { get; } + + /// The problem extracted from the response body. + public TProblem? Problem { get; } + + /// Helper method to ensure the response was successful. + public virtual void EnsureSuccess() + { + // do not bother with successful requests + if (IsSuccessful) return; + + throw CreateException($"The HTTP request failed with code {ResponseCode} ({StatusCode})", + appendHeaders: true, + appendRawBody: true); + } + + /// Helper method to ensure is not null. + [MemberNotNull(nameof(Resource))] + public virtual void EnsureHasResource() + { + if (Resource is not null) return; + + throw CreateException("The HTTP response body was either null or empty.", + appendHeaders: true, + appendRawBody: false); + } + + /// + /// Creates an instance of + /// + protected HttpApiResponseException CreateException(string messagePrefix, bool appendHeaders, bool appendRawBody) + { + var message = messagePrefix; + if (appendHeaders) message = AppendHeaders(message); + if (appendRawBody) message = AppendRawBody(message); + + return new HttpApiResponseException(message: message, + response: Response, + resource: Resource, + problem: Problem, + headers: Headers); + } + + /// The token to use to fetch more data. + public virtual string? ContinuationToken => Headers.ContinuationToken; + + /// + /// Checks if there are more results to retrieve. + /// The result is null when is not assignable from . + /// Otherwise, true when has a value or false when it doesn't have a value. + /// + public virtual bool? HasMoreResults => typeof(IEnumerable).IsAssignableFrom(typeof(TResource)) ? ContinuationToken != null : null; + + /// Append the response headers to an error message. + /// The message to append to. + /// The appended message. + protected string AppendHeaders(string message) + { + string serialize() => System.Text.Json.JsonSerializer.Serialize(Headers, SC.Default.ResourceResponseHeaders); + return AppendIf(message, o => o.IncludeHeadersInExceptionMessage, serialize, "Headers:\n{0}"); + } + + /// Append the raw body if present to an error message. + /// The message to append to. + /// The appended message. + protected string AppendRawBody(string message) + { + string serialize() => Response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return AppendIf(message, o => o.IncludeRawBodyInExceptionMessage, serialize, "Body:\n{0}"); + } + + private string AppendIf(string message, Func evaluator, Func serialize, string format) + { + if (evaluator is null) throw new ArgumentNullException(nameof(evaluator)); + + return options is not null && evaluator(options) ? message + $"\n\n{string.Format(format, serialize())}" : message; + } +} + +/// Model of a HTTP response to an API with typed Resource. +/// the type of resource +public class ResourceResponse : ResourceResponse +{ + /// + /// Create an instance of from another instance. + /// + /// The to copy from. + public ResourceResponse(ResourceResponse other) : base(other) { } + + /// Create an instance of . + /// the original HTTP response + /// the client options + /// the extracted resource + /// the extracted problem description + public ResourceResponse(HttpResponseMessage response, + AbstractHttpApiClientOptions? options = default, + TResource? resource = default, + HttpApiResponseProblem? problem = null) + : base(response: response, options: options, resource: resource, problem: problem) + { + } +} diff --git a/src/Tingle.Extensions.Http/ResourceResponseHeaders.cs b/src/Tingle.Extensions.Http/ResourceResponseHeaders.cs new file mode 100644 index 0000000..c8ec4a1 --- /dev/null +++ b/src/Tingle.Extensions.Http/ResourceResponseHeaders.cs @@ -0,0 +1,75 @@ +using System.Reflection; + +namespace Tingle.Extensions.Http; + +/// +/// HTTP Response headers parsed from a +/// +public class ResourceResponseHeaders : Dictionary> +{ + /// + /// Creates an instance of + /// + /// the original HTTP response + public ResourceResponseHeaders(HttpResponseMessage response) : this(response.Headers.Concat(response.Content.Headers)) { } + + /// + /// Creates an instance of + /// + /// the headers + public ResourceResponseHeaders(IEnumerable>> data) + : this(data.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)) { } + + /// + /// Creates an instance of + /// + /// the data + public ResourceResponseHeaders(IDictionary> data) : base(data, StringComparer.OrdinalIgnoreCase) + { + PopulateKnownHeaders(this); + } + + /// + [KnownHeader("X-Continuation-Token")] + public virtual string? ContinuationToken { get; private set; } + + /// + [KnownHeader("X-Trace-Id")] + public virtual string? TraceId { get; private set; } + + /// + [KnownHeader("Content-Length")] + public virtual string? ContentLength { get; private set; } + + /// + [KnownHeader("Content-Type")] + public virtual string? ContentType { get; private set; } + + /// + [KnownHeader("X-Session-Token")] + public virtual string? SessionToken { get; private set; } + + internal static void PopulateKnownHeaders(ResourceResponseHeaders instance) + { + // get the properties + var type = instance.GetType(); + var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + var properties = type.GetProperties(flags); + + // work on each property + foreach (var prop in properties) + { + // check if the property is annotated + var attr = prop.GetCustomAttribute(false); + if (attr is not null) + { + // set the property value if the header is present + var headerName = attr.Name; + if (instance.TryGetValue(headerName, out var values)) + { + prop.SetValue(instance, values.FirstOrDefault()); + } + } + } + } +} diff --git a/src/Tingle.Extensions.Http/Tingle.Extensions.Http.csproj b/src/Tingle.Extensions.Http/Tingle.Extensions.Http.csproj new file mode 100644 index 0000000..36010a5 --- /dev/null +++ b/src/Tingle.Extensions.Http/Tingle.Extensions.Http.csproj @@ -0,0 +1,12 @@ + + + + Shared layer for API clients of Tingle services or service made by Tingle + + + + + + + + diff --git a/src/Tingle.Extensions.Http/TingleJsonContent.cs b/src/Tingle.Extensions.Http/TingleJsonContent.cs new file mode 100644 index 0000000..a3c0626 --- /dev/null +++ b/src/Tingle.Extensions.Http/TingleJsonContent.cs @@ -0,0 +1,68 @@ +#if !NETCOREAPP +using System.Diagnostics; +#endif +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Tingle.Extensions.Http; + +// heavily inspired by official JsonContent bit it is internal +// https://github.com/dotnet/runtime/blob/e91db04be24aac28fd041425fac014ef04d940b1/src/libraries/System.Net.Http.Json/src/System/Net/Http/Json/JsonContentOfT.cs +// TODO: remove when https://github.com/dotnet/runtime/issues/51544 is resolved + +internal class TingleJsonContent : HttpContent +{ + private readonly JsonTypeInfo jsonTypeInfo; + private readonly TValue inputValue; + + public TingleJsonContent(TValue inputValue, JsonTypeInfo jsonTypeInfo, MediaTypeHeaderValue? mediaType = null) + { + this.jsonTypeInfo = jsonTypeInfo ?? throw new ArgumentNullException(nameof(jsonTypeInfo)); + this.inputValue = inputValue; + Headers.ContentType = mediaType ?? new("application/json") { CharSet = Encoding.UTF8.BodyName }; + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => SerializeToStreamAsyncCore(stream, async: true, CancellationToken.None); + +#if NETCOREAPP + + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) + => SerializeToStreamAsyncCore(stream, async: false, cancellationToken).GetAwaiter().GetResult(); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + => SerializeToStreamAsyncCore(stream, async: true, cancellationToken); + +#endif + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + private async Task SerializeToStreamAsyncCore(Stream targetStream, bool async, CancellationToken cancellationToken) + { + if (async) + { + await JsonSerializer.SerializeAsync(targetStream, inputValue, jsonTypeInfo, cancellationToken).ConfigureAwait(false); + } + else + { +#if NETCOREAPP + JsonSerializer.Serialize(targetStream, inputValue, jsonTypeInfo); +#else + Debug.Fail("Synchronous serialization is only supported since .NET 5.0"); +#endif + } + } +} + +internal static class TingleJsonContent +{ + public static TingleJsonContent Create(TValue inputValue, JsonTypeInfo jsonTypeInfo, MediaTypeHeaderValue? mediaType = null) + => new(inputValue, jsonTypeInfo, mediaType); +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 7f748b7..e29a10d 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -12,6 +12,7 @@ + diff --git a/tests/Tingle.Extensions.Http.Tests/AbstractApiClientTests.cs b/tests/Tingle.Extensions.Http.Tests/AbstractApiClientTests.cs new file mode 100644 index 0000000..eb466df --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/AbstractApiClientTests.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; +using Xunit.Abstractions; + +namespace Tingle.Extensions.Http.Tests; + +public class AbstractApiClientTests +{ + private readonly ITestOutputHelper outputHelper; + + public AbstractApiClientTests(ITestOutputHelper outputHelper) + { + this.outputHelper = outputHelper ?? throw new ArgumentNullException(nameof(outputHelper)); + } + + [Fact] + public async Task ExtractResponseAsync_Get_ApplicationJson_Works() + { + var cancellationToken = CancellationToken.None; + var client = CreateClient(); + + // make first request -> OK with application/json + var response = await client.SendTestOkJsonAppAsync(cancellationToken); + Assert.NotNull(response); + Assert.Null(response.Problem); + var resource = Assert.IsAssignableFrom(response.Resource); + Assert.Equal("who", resource.Name1); + Assert.Equal("me", resource.Name2); + } + + [Fact] + public async Task ExtractResponseAsync_Get_TextJson_Works() + { + var cancellationToken = CancellationToken.None; + var client = CreateClient(); + + // make request -> OK with text/json + var response = await client.SendTestOkTextJsonAsync(cancellationToken); + Assert.NotNull(response); + Assert.Null(response.Problem); + var resource = Assert.IsAssignableFrom(response.Resource); + Assert.Equal("who", resource.Name1); + Assert.Equal("me", resource.Name2); + } + + [Fact] + public async Task ExtractResponseAsync_Post_ApplicationJson_Works() + { + var cancellationToken = CancellationToken.None; + var client = CreateClient(); + + // make (POST) request -> OK with application/json + var rr1 = new TestResource { Name1 = "jane", Name2 = "peters" }; + var response = await client.SendTestOkPostJsonAsync(rr1, cancellationToken); + Assert.NotNull(response); + Assert.Null(response.Problem); + var resource = Assert.IsAssignableFrom(response.Resource); + Assert.Equal("jane", resource.Name1); + Assert.Equal("peters", resource.Name2); + } + + [Fact] + public async Task ExtractResponseAsync_Put_ApplicationJson_Works() + { + var cancellationToken = CancellationToken.None; + var client = CreateClient(); + + // make (PUT) request -> OK with application/json + var rr1 = new TestResource { Name1 = "jane", Name2 = "peters" }; + var response = await client.SendTestOkPutJsonAsync(rr1, cancellationToken); + Assert.NotNull(response); + Assert.Null(response.Problem); + var resource = Assert.IsAssignableFrom(response.Resource); + Assert.Equal("jane", resource.Name1); + Assert.Equal("peters", resource.Name2); + } + + private TestApiClient CreateClient(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddXUnit(outputHelper)); + services.AddHttpApiClient() + .ConfigurePrimaryHttpMessageHandler(() => new TestHttpMessageHandler()) + .ConfigureHttpClient(client => client.BaseAddress = new Uri("http://localhost/")); + + configure?.Invoke(services); + + var provider = services.BuildServiceProvider(validateScopes: true); + var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + return sp.GetRequiredService(); + } + + class TestResource + { + public string? Name1 { get; set; } + public string? Name2 { get; set; } + } + + class TestHttpMessageHandler : HttpMessageHandler + { + private static readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var resource = new TestResource { Name1 = "who", Name2 = "me" }; + var response = new HttpResponseMessage(); + var path = request.RequestUri?.AbsolutePath; + switch (path) + { + case "/test/ok/json-app": + { + response.StatusCode = System.Net.HttpStatusCode.OK; + response.Content = new StringContent(JsonSerializer.Serialize(resource, serializerOptions), + Encoding.UTF8, + "application/json"); + break; + } + case "/test/ok/text-json": + { + response.StatusCode = System.Net.HttpStatusCode.OK; + response.Content = new StringContent(JsonSerializer.Serialize(resource, serializerOptions), + Encoding.UTF8, + "text/json"); + break; + } + case "/test/ok-post/json-app": + { + var js = await request.Content!.ReadAsStringAsync(cancellationToken); + var r = JsonSerializer.Deserialize(js, serializerOptions); + response.StatusCode = System.Net.HttpStatusCode.OK; + response.Content = new StringContent(JsonSerializer.Serialize(r, serializerOptions), + Encoding.UTF8, + "application/json"); + break; + } + case "/test/ok-put/json-app": + { + var js = await request.Content!.ReadAsStringAsync(cancellationToken); + var r = JsonSerializer.Deserialize(js, serializerOptions); + response.StatusCode = System.Net.HttpStatusCode.OK; + response.Content = new StringContent(JsonSerializer.Serialize(r, serializerOptions), + Encoding.UTF8, + "application/json"); + break; + } + default: throw new NotImplementedException($"'{path}' is not supported"); + } + + return response; + } + } + + class TestApiClient : AbstractHttpApiClient + { + public TestApiClient(HttpClient httpClient, IOptionsSnapshot optionsAccessor) + : base(httpClient, optionsAccessor) { } + + public Task> SendTestOkJsonAppAsync(CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/test/ok/json-app"); + return SendAsync(request, cancellationToken); + } + + public Task> SendTestOkTextJsonAsync(CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/test/ok/text-json"); + return SendAsync(request, cancellationToken); + } + + public async Task> SendTestOkPostJsonAsync(TestResource rr1, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/test/ok-post/json-app") { Content = MakeJsonContent(rr1), }; + return await SendAsync(request, cancellationToken); + } + + public async Task> SendTestOkPutJsonAsync(TestResource rr1, CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Put, "/test/ok-put/json-app") { Content = MakeJsonContent(rr1), }; + return await SendAsync(request, cancellationToken); + } + } + + public class TestApiClientOptions : AbstractHttpApiClientOptions + { + + } +} diff --git a/tests/Tingle.Extensions.Http.Tests/DynamicHttpMessageHandler.cs b/tests/Tingle.Extensions.Http.Tests/DynamicHttpMessageHandler.cs new file mode 100644 index 0000000..d2d433c --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/DynamicHttpMessageHandler.cs @@ -0,0 +1,21 @@ +namespace Tingle.Extensions.Http.Tests; + +public class DynamicHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> processFunc; + + public DynamicHttpMessageHandler(Func processFunc) + { + this.processFunc = (req, ct) => Task.FromResult(processFunc(req, ct)); + } + + public DynamicHttpMessageHandler(Func> processFunc) + { + this.processFunc = processFunc ?? throw new ArgumentNullException(nameof(processFunc)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return processFunc(request, cancellationToken); + } +} diff --git a/tests/Tingle.Extensions.Http.Tests/HttpApiResponseProblemTests.cs b/tests/Tingle.Extensions.Http.Tests/HttpApiResponseProblemTests.cs new file mode 100644 index 0000000..b41d80e --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/HttpApiResponseProblemTests.cs @@ -0,0 +1,36 @@ +using System.Text.Json; + +namespace Tingle.Extensions.Http.Tests; + +public class HttpApiResponseProblemTests +{ + [Fact] + public void Deserialize_Works() + { + var json = @"{""errors"": {""tenantId"": [""The property at path '/TenantId' is immutable.""]},""type"": ""https://tools.ietf.org/html/rfc7231#section-6.5.1"",""title"": ""One or more validation errors occurred."",""status"": 400,""traceId"": ""0HLVG7T99R7S9""}"; + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var problem = JsonSerializer.Deserialize(json, options); + Assert.NotNull(problem); + Assert.Equal("One or more validation errors occurred.", problem!.Title); + Assert.Null(problem.Detail); + Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.1", problem.Type); + Assert.Equal(400, problem.Status); + Assert.NotNull(problem.Errors); + var errors = Assert.Contains("tenantId", problem.Errors); + var er = Assert.Single(errors); + Assert.Equal("The property at path '/TenantId' is immutable.", er); + } + + [Fact] + public void PrioritizeProblemDetailsOverLegacy() + { + var json = @"{""error_code"":""some_code"",""error_description"":""some description"",""title"":""some_title"",""detail"":""some detail""}"; + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var problem = JsonSerializer.Deserialize(json, options); + Assert.NotNull(problem); + Assert.Equal("some_title", problem!.Title); + Assert.Equal("some detail", problem.Detail); + } +} diff --git a/tests/Tingle.Extensions.Http.Tests/ResourceResponseHeadersTests.cs b/tests/Tingle.Extensions.Http.Tests/ResourceResponseHeadersTests.cs new file mode 100644 index 0000000..0b27694 --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/ResourceResponseHeadersTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace Tingle.Extensions.Http.Tests; + +public class ResourceResponseHeadersTests +{ + [Fact] + public void HeadersParsedFromResponse() + { + var response = new HttpResponseMessage(HttpStatusCode.NoContent); + response.Headers.Date = DateTimeOffset.UtcNow; + response.Headers.Server.Add(new ProductInfoHeaderValue("ServerX", "1.0")); + response.Headers.TryAddWithoutValidation("x-trace-id", Guid.NewGuid().ToString()); + response.Headers.TryAddWithoutValidation("x-continuation-token", Guid.NewGuid().ToString()); + response.Content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("me"))); + response.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/plain;charset=utf-8"); + response.Content.LoadIntoBufferAsync(); // required to populate Content-Length header + + var rr = new ResourceResponse(response); + Assert.NotNull(rr.Headers); + + Assert.NotNull(rr.Headers.ContentLength); + Assert.NotNull(rr.Headers.ContentType); + Assert.NotNull(rr.Headers.ContinuationToken); + Assert.NotNull(rr.Headers.TraceId); + Assert.Null(rr.Headers.SessionToken); + } +} diff --git a/tests/Tingle.Extensions.Http.Tests/ResourceResponseTests.cs b/tests/Tingle.Extensions.Http.Tests/ResourceResponseTests.cs new file mode 100644 index 0000000..c260fef --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/ResourceResponseTests.cs @@ -0,0 +1,208 @@ +using System.Net; + +namespace Tingle.Extensions.Http.Tests; + +public class ResourceResponseTests +{ + [Fact] + public void EnsureSuccess_Works() + { + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = DateTimeOffset.UtcNow; + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + Assert.Equal(response, rr.Response); + + Assert.Equal(HttpStatusCode.NotFound, rr.StatusCode); + Assert.False(rr.IsSuccessful); + + // change to code to test IsSuccessful + response.StatusCode = HttpStatusCode.Accepted; + Assert.Equal(HttpStatusCode.Accepted, rr.StatusCode); + Assert.True(rr.IsSuccessful); + + // change to code to test IsUnauthorized + response.StatusCode = HttpStatusCode.Unauthorized; + Assert.Equal(HttpStatusCode.Unauthorized, rr.StatusCode); + Assert.False(rr.IsSuccessful); + + // change to code to test IsUnavailable + response.StatusCode = HttpStatusCode.ServiceUnavailable; + Assert.Equal(HttpStatusCode.ServiceUnavailable, rr.StatusCode); + Assert.False(rr.IsSuccessful); + } + + [Fact] + public void EnsureSuccess_DoesNot_Throw_Exception() + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted); + response.Headers.Date = DateTimeOffset.UtcNow; + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + rr.EnsureSuccess(); + } + + [Fact] + public void EnsureSuccess_Throws_Exception() + { + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var options = new DummyHttpApiClientOptions + { + IncludeHeadersInExceptionMessage = true, + IncludeRawBodyInExceptionMessage = true, + }; + + var rr = new ResourceResponse(response, options); + Assert.Equal(HttpStatusCode.NotFound, rr.StatusCode); + + var message = "The HTTP request failed with code 404 (NotFound)\n" + + $"\nHeaders:\n{{\"Date\":[\"{response.Headers.Date:r}\"],\"Content-Type\":[\"text/plain; charset=utf-8\"]}}\n" + + "\nBody:\n"; + var ex = Assert.Throws(rr.EnsureSuccess); + Assert.Equal(message, ex.Message); + + Assert.NotNull(ex.Response); + Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode); + Assert.Equal(404, ex.ResponseCode); + Assert.Null(ex.Resource); + Assert.Null(ex.Problem); + Assert.NotNull(ex.Headers); + Assert.NotEmpty(ex.Headers); + var h1 = ex.Headers!.First(); + Assert.Equal("Date", h1.Key); + var date_str = Assert.Single(h1.Value); + var date = DateTimeOffset.Parse(date_str); + Assert.Equal(response.Headers.Date, date); + } + + [Fact] + public void EnsureHasResource_DoesNot_Throw_Exception() + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted); + response.Headers.Date = DateTimeOffset.UtcNow; + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response, resource: new()); + rr.EnsureHasResource(); + } + + [Fact] + public void EnsureHasResource_Throws_Exception() + { + var response = new HttpResponseMessage(HttpStatusCode.Accepted); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + + var ex = Assert.Throws(rr.EnsureHasResource); + Assert.Equal("The HTTP response body was either null or empty.", ex.Message); + + Assert.NotNull(ex.Response); + Assert.Equal(HttpStatusCode.Accepted, ex.StatusCode); + Assert.Equal(202, ex.ResponseCode); + Assert.Null(ex.Resource); + Assert.Null(ex.Problem); + Assert.NotNull(ex.Headers); + Assert.NotEmpty(ex.Headers); + var h1 = ex.Headers!.First(); + Assert.Equal("Date", h1.Key); + var date_str = Assert.Single(h1.Value); + var date = DateTimeOffset.Parse(date_str); + Assert.Equal(response.Headers.Date, date); + } + + [Fact] + public void ContinuationToken_MustNotBe_Null() + { + var ct = Guid.NewGuid().ToString(); + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Headers.TryAddWithoutValidation("x-continuation-token", ct); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + Assert.Equal(ct, rr.ContinuationToken); + } + + [Fact] + public void ContinuationToken_MustBe_Null() + { + // first try when the header is not there + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + Assert.Null(rr.ContinuationToken); + + + // now try with wrongly spelled headers + var ct = Guid.NewGuid().ToString(); + response.Headers.TryAddWithoutValidation("x-ms-continuation", ct); // for Microsoft + response.Headers.TryAddWithoutValidation("x-ts-continuation2", ct); // mumbled + + rr = new ResourceResponse(response); + Assert.Null(rr.ContinuationToken); + } + + [Fact] + public void HasMoreResults_MustBe_Null() + { + // first try when the header is not there + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse(response); + Assert.Null(rr.HasMoreResults); + } + + [Fact] + public void HasMoreResults_MustNotBe_Null() + { + // first try when the header is not there + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse>(response); + Assert.NotNull(rr.HasMoreResults); + } + + [Fact] + public void HasMoreResults_Returns_False() + { + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Content = new StringContent(string.Empty); + + var rr = new ResourceResponse>(response); + Assert.False(rr.HasMoreResults); + } + + [Fact] + public void HasMoreResults_Returns_True() + { + var response = new HttpResponseMessage(HttpStatusCode.NotFound); + response.Headers.Date = new DateTimeOffset(DateTimeOffset.UtcNow.Date, TimeSpan.Zero); + response.Headers.TryAddWithoutValidation("x-continuation-token", Guid.NewGuid().ToString()); + response.Content = new StringContent(string.Empty); + + var rr1 = new ResourceResponse>(response); + Assert.True(rr1.HasMoreResults); + + var rr2 = new ResourceResponse>(response); + Assert.True(rr2.HasMoreResults); + + var rr3 = new ResourceResponse>(response); + Assert.True(rr3.HasMoreResults); + } + + private class DummyHttpApiClientOptions : AbstractHttpApiClientOptions { } +} diff --git a/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivity.cs b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivity.cs new file mode 100644 index 0000000..c10a5a3 --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivity.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Tingle.Extensions.Http.Tests.Serialization; + +public class ToDoActivity +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + public int TaskNum { get; set; } + public double Cost { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } +} diff --git a/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityKind.cs b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityKind.cs new file mode 100644 index 0000000..1959d7a --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityKind.cs @@ -0,0 +1,8 @@ +namespace Tingle.Extensions.Http.Tests.Serialization; + +public enum ToDoActivityKind +{ + Driving, + Walking, + Running, +} diff --git a/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityWithKind.cs b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityWithKind.cs new file mode 100644 index 0000000..1580c74 --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/Serialization/ToDoActivityWithKind.cs @@ -0,0 +1,6 @@ +namespace Tingle.Extensions.Http.Tests.Serialization; + +public class ToDoActivityWithKind : ToDoActivity +{ + public ToDoActivityKind Kind { get; set; } +} diff --git a/tests/Tingle.Extensions.Http.Tests/Tingle.Extensions.Http.Tests.csproj b/tests/Tingle.Extensions.Http.Tests/Tingle.Extensions.Http.Tests.csproj new file mode 100644 index 0000000..299782e --- /dev/null +++ b/tests/Tingle.Extensions.Http.Tests/Tingle.Extensions.Http.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + +