diff --git a/.editorconfig b/.editorconfig index a23aedf..6cb7ab4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,32 +1,41 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = tab +tab_width = 4 + +[{*.yaml,*.yml}] +indent_style = space +indent_size = 2 + [*.cs] # symbols -dotnet_naming_symbols.private_methods.applicable_kinds = method +dotnet_naming_symbols.private_methods.applicable_kinds = method dotnet_naming_symbols.private_methods.applicable_accessibilities = private -dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private # naming styles dotnet_naming_style.camel_case_style.capitalization = camel_case -dotnet_naming_style.underscore_camel_case.capitalization = camel_case -dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case +dotnet_naming_style.underscore_camel_case.required_prefix = _ # naming rules dotnet_naming_rule.private_methods_camel_case.symbols = private_methods -dotnet_naming_rule.private_methods_camel_case.style = camel_case_style +dotnet_naming_rule.private_methods_camel_case.style = camel_case_style dotnet_naming_rule.private_methods_camel_case.severity = suggestion dotnet_naming_rule.private_fields_underscore_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_underscore_camel_case.style = underscore_camel_case +dotnet_naming_rule.private_fields_underscore_camel_case.style = underscore_camel_case dotnet_naming_rule.private_fields_underscore_camel_case.severity = suggestion +# Sonar - -dotnet_diagnostic.IDE0003.severity = suggestion -dotnet_diagnostic.CA1825.severity = none - -dotnet_diagnostic.S1186.severity = none # does not allow empty methods -dotnet_diagnostic.S3011.severity = none # does not allow reflection of private members -dotnet_diagnostic.S3267.severity = none # Loops should be simplified by LINQ +# Bug, S3903:Types should be defined in named namespaces +# Doesn't take into consideration file scoped namespaces +dotnet_diagnostic.S3903.severity = none diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f7c7529 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Auto detect text files and perform LF normalization +* text=auto + +*.cs text diff=csharp +*.cshtml text diff=html +*.csx text diff=csharp +*.sln text eol=crlf +*.csproj text eol=crlf diff --git a/.github/workflows/push-action.yml b/.github/workflows/push-action.yml index d810733..d869268 100644 --- a/.github/workflows/push-action.yml +++ b/.github/workflows/push-action.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x + dotnet-version: 6.0.x - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index bf9b4b3..79b6e50 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -12,7 +12,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x + dotnet-version: 6.0.x - name: Checkout uses: actions/checkout@v2 @@ -33,10 +33,10 @@ jobs: run: | dotnet nuget push ./out/*.nupkg \ -s https://nuget.pkg.github.com/Byteology/index.json -k ${{ secrets.GITHUB_TOKEN }} \ - --skip-duplicate --no-symbols true + --skip-duplicate --no-symbols - name: Push package - NuGet.org run: | dotnet nuget push ./out/*.nupkg \ -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_API_KEY }} \ - --skip-duplicate --no-symbols true + --skip-duplicate --no-symbols diff --git a/.gitignore b/.gitignore index f677870..3aafe5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ bin obj -.vs \ No newline at end of file +.vs +.idea +**.DotSettings +**.DotSettings.user diff --git a/Byteology.TypedHttpClients.Tests/Byteology.TypedHttpClients.Tests.csproj b/Byteology.TypedHttpClients.Tests/Byteology.TypedHttpClients.Tests.csproj index 0c28ee5..afa3a5e 100644 --- a/Byteology.TypedHttpClients.Tests/Byteology.TypedHttpClients.Tests.csproj +++ b/Byteology.TypedHttpClients.Tests/Byteology.TypedHttpClients.Tests.csproj @@ -1,30 +1,32 @@ - - - - net5.0 - - false - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + net6.0 + + false + + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Byteology.TypedHttpClients.Tests/JsonHttpClientTests.cs b/Byteology.TypedHttpClients.Tests/JsonHttpClientTests.cs index d7946bf..e3353f1 100644 --- a/Byteology.TypedHttpClients.Tests/JsonHttpClientTests.cs +++ b/Byteology.TypedHttpClients.Tests/JsonHttpClientTests.cs @@ -1,240 +1,242 @@ -using Byteology.TypedHttpClients.Tests.Mocks; -using Byteology.TypedHttpClients.Tests.TestServices; -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Xunit; - -namespace Byteology.TypedHttpClients.Tests -{ - public class JsonHttpClientTests - { - [Fact] - public void Success() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.NoResultActionAsync(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Success_Generic() - { - // Arrange - TestServiceResult response = new (); - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, new StringContent(response.ToString())); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - TestServiceResult result = service.ActionAsync().Result; - - // Assert - Assert.Equal(response, result); - } - - [Fact] - public void Error() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.Forbidden, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.NoResultActionAsync(); - - // Assert - Assert.ThrowsAny(() => result.Wait()); - } - - [Fact] - public void Error_Generic() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.Forbidden, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.ActionAsync(); - - // Assert - Assert.ThrowsAny(() => result.Wait()); - } - - [Fact] - public void Uri_Simple() - { - // Arrange - string expectedUrl = "https://example.com/simpleuri"; - void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.SimpleUriAsync(); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Uri_Simple_NoDash() - { - // Arrange - static void assertUri(HttpRequestMessage m) => Assert.Equal("https://example.com/simpleuri", m.RequestUri?.ToString()); - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.SimpleUriNoDashAsync(); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Theory] - [InlineData("test")] - [InlineData(5)] - [InlineData(5.8f)] - [InlineData(true)] - [InlineData(null)] - public void Uri_Param(object param) - { - // Arrange - string expectedUrl = $"https://example.com/paramuri/{param}"; - void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.ParamUriAsync(param); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Uri_Query() - { - // Arrange - string expectedUrl = "https://example.com/query?i=5&s=asdf&b=True&f=5.4&n=&a=1&a=2&a=3&a="; - void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.QueryAsync(5, "asdf", true, 5.4f, null, new int?[] { 1, 2, 3, null }); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Body() - { - // Arrange - string expectedUrl = "https://example.com/actionbody"; - TestServiceResult body = new(); - void assertUri(HttpRequestMessage m) - { - Assert.Equal(expectedUrl, m.RequestUri?.ToString()); - Assert.Equal(body.ToString(), m.Content.ReadAsStringAsync().Result); - } - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.BodyActionAsync(body); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Verb() - { - // Arrange - static void assertUri(HttpRequestMessage m) => Assert.Equal("VERB", m.Method.Method); - - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act - Task result = service.VerbAsync(); - result.Wait(); - - // Assert - Assert.True(result.IsCompletedSuccessfully); - } - - [Fact] - public void Invalid_OutParam() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act & Assert - Assert.ThrowsAny(() => service.OutParamAsync(out int p).Wait()); - } - - [Fact] - public void Invalid_MultipleBody() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act & Assert - Assert.ThrowsAny(() => service.MultipleBodyAsync(1, 2).Wait()); - } - - [Fact] - public void Invalid_NotDecorated() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act & Assert - Assert.ThrowsAny(() => service.NotDecoratedAsync().Wait()); - } - - [Fact] - public void Invalid_NotAsync() - { - // Arrange - using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); - ITestService service = new JsonHttpClient(httpClient).Endpoints; - - // Act & Assert - Assert.ThrowsAny(() => service.NotAsync()); - } - - private static HttpClient getHttpClient( - HttpStatusCode statusCode, - HttpContent content, - Action onBeforeSend = null) - { - HttpClient httpClient = MockHttpClientFactory.Create(statusCode, content, onBeforeSend); - httpClient.BaseAddress = new Uri("https://example.com"); - return httpClient; - } - } -} +using Byteology.TypedHttpClients.Tests.Mocks; +using Byteology.TypedHttpClients.Tests.TestServices; +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Byteology.TypedHttpClients.Tests +{ + public class JsonHttpClientTests + { + [Fact] + public void Success() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.NoResultActionAsync(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Success_Generic() + { + // Arrange + TestServiceResult response = new(); + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, new StringContent(response.ToString())); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + TestServiceResult result = service.ActionAsync().Result; + + // Assert + Assert.Equal(response, result); + } + + [Fact] + public void Error() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.Forbidden, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.NoResultActionAsync(); + + // Assert + Assert.ThrowsAny(() => result.Wait()); + } + + [Fact] + public void Error_Generic() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.Forbidden, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.ActionAsync(); + + // Assert + Assert.ThrowsAny(() => result.Wait()); + } + + [Fact] + public void Uri_Simple() + { + // Arrange + string expectedUrl = "https://example.com/simpleUri"; + void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.SimpleUriAsync(); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Uri_Simple_NoDash() + { + // Arrange + static void assertUri(HttpRequestMessage m) => + Assert.Equal("https://example.com/simpleUri", m.RequestUri?.ToString()); + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.SimpleUriNoDashAsync(); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Theory] + [InlineData("test")] + [InlineData(5)] + [InlineData(5.8f)] + [InlineData(true)] + [InlineData(null)] + public void Uri_Param(object param) + { + // Arrange + string expectedUrl = $"https://example.com/paramUri/{param}"; + void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.ParamUriAsync(param); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Uri_Query() + { + // Arrange + string expectedUrl = "https://example.com/query?i=5&s=a&b=True&f=5.4&n=&a=1&a=2&a=3&a="; + void assertUri(HttpRequestMessage m) => Assert.Equal(expectedUrl, m.RequestUri?.ToString()); + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.QueryAsync(5, "a", true, 5.4f, null, new int?[] { 1, 2, 3, null }); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Body() + { + // Arrange + string expectedUrl = "https://example.com/actionBody"; + TestServiceResult body = new(); + + void assertUri(HttpRequestMessage m) + { + Assert.Equal(expectedUrl, m.RequestUri?.ToString()); + Assert.Equal(body.ToString(), m.Content?.ReadAsStringAsync().Result); + } + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.BodyActionAsync(body); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Verb() + { + // Arrange + static void assertUri(HttpRequestMessage m) => Assert.Equal("VERB", m.Method.Method); + + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null, assertUri); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act + Task result = service.VerbAsync(); + result.Wait(); + + // Assert + Assert.True(result.IsCompletedSuccessfully); + } + + [Fact] + public void Invalid_OutParam() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act & Assert + Assert.ThrowsAny(() => service.OutParamAsync(out int p).Wait()); + } + + [Fact] + public void Invalid_MultipleBody() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act & Assert + Assert.ThrowsAny(() => service.MultipleBodyAsync(1, 2).Wait()); + } + + [Fact] + public void Invalid_NotDecorated() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act & Assert + Assert.ThrowsAny(() => service.NotDecoratedAsync().Wait()); + } + + [Fact] + public void Invalid_NotAsync() + { + // Arrange + using HttpClient httpClient = getHttpClient(HttpStatusCode.OK, null); + ITestService service = new JsonHttpClient(httpClient).Endpoints; + + // Act & Assert + Assert.ThrowsAny(() => service.NotAsync()); + } + + private static HttpClient getHttpClient( + HttpStatusCode statusCode, + HttpContent? content, + Action? onBeforeSend = null) + { + HttpClient httpClient = MockHttpClientFactory.Create(statusCode, content, onBeforeSend); + httpClient.BaseAddress = new Uri("https://example.com"); + return httpClient; + } + } +} diff --git a/Byteology.TypedHttpClients.Tests/Mocks/MockHttpClientFactory.cs b/Byteology.TypedHttpClients.Tests/Mocks/MockHttpClientFactory.cs index b53eea1..993a1e5 100644 --- a/Byteology.TypedHttpClients.Tests/Mocks/MockHttpClientFactory.cs +++ b/Byteology.TypedHttpClients.Tests/Mocks/MockHttpClientFactory.cs @@ -1,43 +1,45 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Byteology.TypedHttpClients.Tests.Mocks -{ - internal static class MockHttpClientFactory - { - public static HttpClient Create(HttpStatusCode statusCode, HttpContent content, Action onBeforeSend = null) - { - MessageHandler handler = new (statusCode, content, onBeforeSend); - return new HttpClient(handler, true); - } - - private class MessageHandler : HttpMessageHandler - { - private readonly HttpStatusCode _statusCode; - private readonly HttpContent _content; - private readonly Action _onBeforeSend; - - public MessageHandler(HttpStatusCode statusCode, HttpContent content, Action onBeforeSend = null) - { - _statusCode = statusCode; - _content = content; - _onBeforeSend = onBeforeSend; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - _onBeforeSend?.Invoke(request); - - return Task.FromResult( - new HttpResponseMessage() - { - StatusCode = _statusCode, - Content = _content - }); - } - } - } -} +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Byteology.TypedHttpClients.Tests.Mocks +{ + internal static class MockHttpClientFactory + { + public static HttpClient Create(HttpStatusCode statusCode, HttpContent? content, + Action? onBeforeSend = null) + { + MessageHandler handler = new(statusCode, content, onBeforeSend); + return new HttpClient(handler, true); + } + + private class MessageHandler : HttpMessageHandler + { + private readonly HttpContent? _content; + private readonly Action? _onBeforeSend; + private readonly HttpStatusCode _statusCode; + + public MessageHandler(HttpStatusCode statusCode, HttpContent? content, + Action? onBeforeSend = null) + { + _statusCode = statusCode; + _content = content; + _onBeforeSend = onBeforeSend; + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + _onBeforeSend?.Invoke(request); + + return Task.FromResult(new HttpResponseMessage + { + StatusCode = _statusCode, + Content = _content + }); + } + } + } +} diff --git a/Byteology.TypedHttpClients.Tests/TestServices/ITestService.cs b/Byteology.TypedHttpClients.Tests/TestServices/ITestService.cs index 243fd74..b093275 100644 --- a/Byteology.TypedHttpClients.Tests/TestServices/ITestService.cs +++ b/Byteology.TypedHttpClients.Tests/TestServices/ITestService.cs @@ -1,43 +1,42 @@ -using System.Threading.Tasks; - -namespace Byteology.TypedHttpClients.Tests.TestServices -{ - internal interface ITestService - { - [HttpEndpoint("POST", "/noresultaction")] - Task NoResultActionAsync(); - - [HttpEndpoint("POST", "/action")] - Task ActionAsync(); - - [HttpEndpoint("POST", "/actionbody")] - Task BodyActionAsync([HttpBody]TestServiceResult body); - - [HttpEndpoint("POST", "/simpleuri")] - Task SimpleUriAsync(); - - [HttpEndpoint("POST", "simpleuri")] - Task SimpleUriNoDashAsync(); - - [HttpEndpoint("POST", "/paramuri/{param}")] - Task ParamUriAsync(object param); - - [HttpEndpoint("POST", "/query")] - Task QueryAsync(int i, string s, bool b, float f, object n, int?[] a); - - [HttpEndpoint("VERB", "/verb")] - Task VerbAsync(); - - [HttpEndpoint("POST", "/uri")] - Task OutParamAsync(out int param); - - [HttpEndpoint("POST", "/uri")] - Task MultipleBodyAsync([HttpBody] int param, [HttpBody] int param2); - - Task NotDecoratedAsync(); - - [HttpEndpoint("POST", "/uri")] - int NotAsync(); - } - -} +using System.Threading.Tasks; + +namespace Byteology.TypedHttpClients.Tests.TestServices +{ + internal interface ITestService + { + [HttpEndpoint("POST", "/noResultAction")] + Task NoResultActionAsync(); + + [HttpEndpoint("POST", "/action", Tags = new[] { "tag" })] + Task ActionAsync(); + + [HttpEndpoint("POST", "/actionBody")] + Task BodyActionAsync([HttpBody] TestServiceResult body); + + [HttpEndpoint("POST", "/simpleUri")] + Task SimpleUriAsync(); + + [HttpEndpoint("POST", "simpleUri")] + Task SimpleUriNoDashAsync(); + + [HttpEndpoint("POST", "/paramUri/{param}")] + Task ParamUriAsync(object param); + + [HttpEndpoint("POST", "/query")] + Task QueryAsync(int i, string s, bool b, float f, object? n, int?[] a); + + [HttpEndpoint("VERB", "/verb")] + Task VerbAsync(); + + [HttpEndpoint("POST", "/uri")] + Task OutParamAsync(out int param); + + [HttpEndpoint("POST", "/uri")] + Task MultipleBodyAsync([HttpBody] int param, [HttpBody] int param2); + + Task NotDecoratedAsync(); + + [HttpEndpoint("POST", "/uri")] + int NotAsync(); + } +} diff --git a/Byteology.TypedHttpClients.sln b/Byteology.TypedHttpClients.sln index 7b04846..d057ccc 100644 --- a/Byteology.TypedHttpClients.sln +++ b/Byteology.TypedHttpClients.sln @@ -1,61 +1,62 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31808.319 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Byteology.TypedHttpClients", "Byteology.TypedHttpClients\Byteology.TypedHttpClients.csproj", "{C212C350-4301-4773-9029-162826EF7CD0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Byteology.TypedHttpClients.Tests", "Byteology.TypedHttpClients.Tests\Byteology.TypedHttpClients.Tests.csproj", "{20BB41AD-2786-4550-8D94-182B7D723BA5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{28CC8107-D48A-4EBB-84A4-37D113F3614D}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - Icon.png = Icon.png - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{83E38053-A14F-4FC7-A099-46A611744FCF}" - ProjectSection(SolutionItems) = preProject - .github\workflows\push-action.yml = .github\workflows\push-action.yml - .github\workflows\release-action.yml = .github\workflows\release-action.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{E93839AF-ED4E-4074-B642-DCBB4FE18CC9}" - ProjectSection(SolutionItems) = preProject - .github\CODEOWNERS = .github\CODEOWNERS - .github\README.md = .github\README.md - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".sonar", ".sonar", "{78567D61-B59E-4379-A572-4FF8C4F117AA}" - ProjectSection(SolutionItems) = preProject - .sonar\coverlet.runsettings = .sonar\coverlet.runsettings - .sonar\SonarQube.Analysis.xml = .sonar\SonarQube.Analysis.xml - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C212C350-4301-4773-9029-162826EF7CD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C212C350-4301-4773-9029-162826EF7CD0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C212C350-4301-4773-9029-162826EF7CD0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C212C350-4301-4773-9029-162826EF7CD0}.Release|Any CPU.Build.0 = Release|Any CPU - {20BB41AD-2786-4550-8D94-182B7D723BA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {20BB41AD-2786-4550-8D94-182B7D723BA5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {20BB41AD-2786-4550-8D94-182B7D723BA5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {20BB41AD-2786-4550-8D94-182B7D723BA5}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {83E38053-A14F-4FC7-A099-46A611744FCF} = {E93839AF-ED4E-4074-B642-DCBB4FE18CC9} - {E93839AF-ED4E-4074-B642-DCBB4FE18CC9} = {28CC8107-D48A-4EBB-84A4-37D113F3614D} - {78567D61-B59E-4379-A572-4FF8C4F117AA} = {28CC8107-D48A-4EBB-84A4-37D113F3614D} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {3C5450B1-6E34-4347-9447-4F8814CEECF4} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31808.319 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Byteology.TypedHttpClients", "Byteology.TypedHttpClients\Byteology.TypedHttpClients.csproj", "{C212C350-4301-4773-9029-162826EF7CD0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Byteology.TypedHttpClients.Tests", "Byteology.TypedHttpClients.Tests\Byteology.TypedHttpClients.Tests.csproj", "{20BB41AD-2786-4550-8D94-182B7D723BA5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{28CC8107-D48A-4EBB-84A4-37D113F3614D}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Icon.png = Icon.png + .gitattributes = .gitattributes + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{83E38053-A14F-4FC7-A099-46A611744FCF}" + ProjectSection(SolutionItems) = preProject + .github\workflows\push-action.yml = .github\workflows\push-action.yml + .github\workflows\release-action.yml = .github\workflows\release-action.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{E93839AF-ED4E-4074-B642-DCBB4FE18CC9}" + ProjectSection(SolutionItems) = preProject + .github\CODEOWNERS = .github\CODEOWNERS + .github\README.md = .github\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".sonar", ".sonar", "{78567D61-B59E-4379-A572-4FF8C4F117AA}" + ProjectSection(SolutionItems) = preProject + .sonar\coverlet.runsettings = .sonar\coverlet.runsettings + .sonar\SonarQube.Analysis.xml = .sonar\SonarQube.Analysis.xml + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C212C350-4301-4773-9029-162826EF7CD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C212C350-4301-4773-9029-162826EF7CD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C212C350-4301-4773-9029-162826EF7CD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C212C350-4301-4773-9029-162826EF7CD0}.Release|Any CPU.Build.0 = Release|Any CPU + {20BB41AD-2786-4550-8D94-182B7D723BA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20BB41AD-2786-4550-8D94-182B7D723BA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20BB41AD-2786-4550-8D94-182B7D723BA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20BB41AD-2786-4550-8D94-182B7D723BA5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {83E38053-A14F-4FC7-A099-46A611744FCF} = {E93839AF-ED4E-4074-B642-DCBB4FE18CC9} + {E93839AF-ED4E-4074-B642-DCBB4FE18CC9} = {28CC8107-D48A-4EBB-84A4-37D113F3614D} + {78567D61-B59E-4379-A572-4FF8C4F117AA} = {28CC8107-D48A-4EBB-84A4-37D113F3614D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3C5450B1-6E34-4347-9447-4F8814CEECF4} + EndGlobalSection +EndGlobal diff --git a/Byteology.TypedHttpClients/Byteology.TypedHttpClients.csproj b/Byteology.TypedHttpClients/Byteology.TypedHttpClients.csproj index c9f7bde..166aa20 100644 --- a/Byteology.TypedHttpClients/Byteology.TypedHttpClients.csproj +++ b/Byteology.TypedHttpClients/Byteology.TypedHttpClients.csproj @@ -1,45 +1,46 @@ - - - - net5.0 - True - true - Byteology - Byteology Ltd - https://github.com/Byteology/typed-http-clients.git - Icon.png - Provides classes for creating typed http clients from an interface describing a service. - 1.0.0 - http typed HttpClient HttpClientFactory - MIT - https://github.com/Byteology/typed-http-clients - git - - - - - True - - False - - - - - - True - - False - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + net6.0 + True + true + Byteology + Byteology Ltd + https://github.com/Byteology/typed-http-clients.git + Icon.png + Provides classes for creating typed http clients from an interface describing a service. + 2.0.0 + http typed HttpClient HttpClientFactory + MIT + https://github.com/Byteology/typed-http-clients + git + enable + + + + + True + + False + + + + + + True + + False + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Byteology.TypedHttpClients/Clients/JsonHttpClient.cs b/Byteology.TypedHttpClients/Clients/JsonHttpClient.cs index b891f5f..87ce304 100644 --- a/Byteology.TypedHttpClients/Clients/JsonHttpClient.cs +++ b/Byteology.TypedHttpClients/Clients/JsonHttpClient.cs @@ -1,79 +1,81 @@ -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -namespace Byteology.TypedHttpClients -{ - /// - /// An HTTP clients that implement a contract of a service with a JSON content type. - /// - /// - public class JsonHttpClient : TypedHttpClient - where TServiceContract : class - { - /// - /// Gets the JSON serialization options used to create and parse the HTTP requests and responses. - /// - public JsonSerializerOptions JsonSerializerOptions { get; } = new JsonSerializerOptions(JsonSerializerDefaults.Web); - - /// - /// Initializes a new instance of the class using an - /// that will send the HTTP requests. - /// - /// The HTTP client. - public JsonHttpClient(HttpClient httpClient) : base(httpClient) { } - - /// - /// Builds an HTTP request with a JSON content type. - /// - /// - /// - /// - /// - protected override Task BuildRequestAsync(string verb, string uri, object body, string[] tags) - { - HttpRequestMessage httpRequest = new(new HttpMethod(verb), uri); - httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - if (body != null) - httpRequest.Content = new StringContent(JsonSerializer.Serialize(body, JsonSerializerOptions), - Encoding.UTF8, - "application/json"); - - return Task.FromResult(httpRequest); - } - - /// - /// Throws an exception if the - /// property for the HTTP response is false. - /// - /// - /// - /// - protected override Task ProcessResponse(HttpResponseMessage response, string[] tags) - { - response.EnsureSuccessStatusCode(); - return Task.CompletedTask; - } - - /// - /// Throws an exception if the - /// property for the HTTP response is false. Otherwise converts the response content - /// to a object and returns it. - /// - /// - /// - /// - protected override async Task ProcessResponse(HttpResponseMessage response, string[] tags) - { - response.EnsureSuccessStatusCode(); - - string bodyString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - TResult result = JsonSerializer.Deserialize(bodyString, JsonSerializerOptions); - - return result; - } - } -} +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Byteology.TypedHttpClients +{ + /// + /// An HTTP clients that implement a contract of a service with a JSON content type. + /// + /// + public class JsonHttpClient : TypedHttpClient + where TServiceContract : class + { + /// + /// Initializes a new instance of the class using an + /// that will send the HTTP requests. + /// + /// The HTTP client. + public JsonHttpClient(HttpClient httpClient) : base(httpClient) { } + + /// + /// Gets the JSON serialization options used to create and parse the HTTP requests and responses. + /// + public JsonSerializerOptions JsonSerializerOptions { get; } = + new JsonSerializerOptions(JsonSerializerDefaults.Web); + + /// + /// Builds an HTTP request with a JSON content type. + /// + /// + /// + /// + /// + protected override Task BuildRequestAsync(string verb, string uri, object? body, + string[]? tags) + { + HttpRequestMessage httpRequest = new(new HttpMethod(verb), uri); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (body != null) + httpRequest.Content = new StringContent(JsonSerializer.Serialize(body, JsonSerializerOptions), + Encoding.UTF8, + "application/json"); + + return Task.FromResult(httpRequest); + } + + /// + /// Throws an exception if the + /// property for the HTTP response is false. + /// + /// + /// + /// + protected override Task ProcessResponse(HttpResponseMessage response, string[]? tags) + { + response.EnsureSuccessStatusCode(); + return Task.CompletedTask; + } + + /// + /// Throws an exception if the + /// property for the HTTP response is false. Otherwise converts the response content + /// to a object and returns it. + /// + /// + /// + /// + protected override async Task ProcessResponse(HttpResponseMessage response, string[]? tags) + { + response.EnsureSuccessStatusCode(); + + string bodyString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + TResult result = JsonSerializer.Deserialize(bodyString, JsonSerializerOptions)!; + + return result; + } + } +} diff --git a/Byteology.TypedHttpClients/Clients/TypedHttpClient.cs b/Byteology.TypedHttpClients/Clients/TypedHttpClient.cs index 8979bd5..26a6912 100644 --- a/Byteology.TypedHttpClients/Clients/TypedHttpClient.cs +++ b/Byteology.TypedHttpClients/Clients/TypedHttpClient.cs @@ -1,257 +1,270 @@ -using Byteology.GuardClauses; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using System.Web; - -namespace Byteology.TypedHttpClients -{ - /// - /// Provides a base class for HTTP clients that implement a service contract. - /// - /// The service contract. It should be an interface containing only - /// async methods decorated with and having no output parameters. - /// A single method parameter is allowed to be decorated with the - /// in order to be used as the request's content. All other parameters will be used either as route or query parameters - /// depending on whether their name has a match in the or not. - #pragma warning disable CS0618 // Type or member is obsolete - public abstract class TypedHttpClient : IDispatchHandler - #pragma warning restore CS0618 // Type or member is obsolete - where TServiceContract : class - { - private readonly HttpClient _httpClient; - - /// - /// Gets the endpoints of the service. - /// - public TServiceContract Endpoints { get; } - - /// - /// Initializes a new instance of the - /// class using an - /// that will send the HTTP requests. - /// - /// The HTTP client. - protected TypedHttpClient(HttpClient httpClient) - { - Guard.Argument(httpClient, nameof(httpClient)).NotNull(); - - _httpClient = httpClient; - #pragma warning disable CS0618 // Type or member is obsolete - Endpoints = DispatchProxyDelegator.Create(this); - #pragma warning restore CS0618 // Type or member is obsolete - } - - /// - /// Builds a query string. - /// - /// The query parameters. - /// The tags of the request. These are provided by the - /// in order for this method to be able to recognize requests that require special treatment. - protected virtual string BuildQueryString(IEnumerable queryParameters, string[] tags) - { - StringBuilder builder = new(); - - if (queryParameters != null) - foreach (HttpUriParameter parameter in queryParameters) - { - if (parameter.Value is ICollection collection) - foreach (object item in collection) - builder.Append($"{HttpUtility.UrlEncode(parameter.Name)}={HttpUtility.UrlEncode(item?.ToString())}&"); - else - builder.Append($"{HttpUtility.UrlEncode(parameter.Name)}={HttpUtility.UrlEncode(parameter.Value?.ToString())}&"); - } - - if (builder.Length > 0) - builder.Remove(builder.Length - 1, 1); - - return builder.ToString(); - } - - /// - /// Builds an HTTP request. - /// - /// The HTTP verb of the request. - /// The URI of the request. - /// The content body of the request or if no body should be provided. - /// The tags of the request. These are provided by the - /// in order for this method to be able to recognize requests that require special treatment. - protected abstract Task BuildRequestAsync(string verb, string uri, object body, string[] tags); - - /// - /// Send an HTTP request as an asynchronous operation. - /// - /// The HTTP client to use. - /// The request to send. - /// The tags of the request. These are specified by the - /// in order for this method to be able to recognize requests that require special treatment. - protected virtual async Task SendRequestAsync(HttpClient httpClient, HttpRequestMessage request, string[] tags) - { - Guard.Argument(httpClient, nameof(httpClient)).NotNull(); - Guard.Argument(request, nameof(request)).NotNull(); - - HttpResponseMessage response = await httpClient.SendAsync(request).ConfigureAwait(false); - response.RequestMessage = request; - return response; - } - - /// - /// Processes the response of an HTTP request. - /// - /// The HTTP response. - /// The tags of the request. These are specified by the - /// in order for this method to be able to recognize requests that require special treatment. - protected abstract Task ProcessResponse(HttpResponseMessage response, string[] tags); - /// - /// Processes the response of an HTTP request and converts its body to . - /// - /// The type of the object the response message should be converted to. - /// The HTTP response. - /// The tags of the request. These are specified by the - /// in order for this method to be able to recognize requests that require special treatment. - protected abstract Task ProcessResponse(HttpResponseMessage response, string[] tags); - - object IDispatchHandler.Dispatch(MethodInfo targetMethod, object[] args) - { - if (targetMethod.GetCustomAttribute(true) == null) - throw new InvalidOperationException($"The method should be decorated with the {typeof(HttpEndpointAttribute)}."); - - ParameterInfo[] parameters = targetMethod.GetParameters(); - - if (parameters.Any(p => p.IsOut)) - throw new InvalidOperationException("Output parameters are not allowed."); - - if (parameters.Count(p => p.GetCustomAttribute() != null) > 1) - throw new InvalidOperationException("Maximum of one body parameter is allowed per method."); - - if (returnsTask(targetMethod)) - return sendRequestAndParseResponseAsync(targetMethod, args); - else if (returnsGenericTask(targetMethod)) - { - MethodInfo sendRequestMethod = typeof(TypedHttpClient) - .GetMethod(nameof(sendRequestAndParseGenericResponseAsync),BindingFlags.NonPublic | BindingFlags.Instance); - sendRequestMethod = sendRequestMethod.MakeGenericMethod(targetMethod.ReturnType.GenericTypeArguments.Single()); - return sendRequestMethod.Invoke(this, new object[] { targetMethod, args }); - } - else - throw new InvalidOperationException("Method should return either Task or Task<>."); - } - - private async Task sendRequestAndParseResponseAsync(MethodInfo targetMethod, object[] args) - { - HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true); - string[] tags = endpointAttribute.Tags; - - HttpRequestMessage request = await createRequestAsync(targetMethod, args).ConfigureAwait(false); - HttpResponseMessage response = await SendRequestAsync(_httpClient, request, tags).ConfigureAwait(false); - - await ProcessResponse(response, tags).ConfigureAwait(false); - } - private async Task sendRequestAndParseGenericResponseAsync(MethodInfo targetMethod, object[] args) - { - HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true); - string[] tags = endpointAttribute.Tags; - HttpRequestMessage request = await createRequestAsync(targetMethod, args).ConfigureAwait(false); - HttpResponseMessage response = await SendRequestAsync(_httpClient, request, tags).ConfigureAwait(false); - - return await ProcessResponse(response, tags).ConfigureAwait(false); - } - - private async Task createRequestAsync(MethodInfo targetMethod, object[] args) - { - HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true); - - string verb = endpointAttribute.Verb; - string routeTemplate = endpointAttribute.RouteTemplate; - string[] tags = endpointAttribute.Tags; - - List uriParameters = getParameters(targetMethod, args, out object body); - string uri = getUri(tags, routeTemplate, ref uriParameters); - - HttpRequestMessage request = await BuildRequestAsync(verb, uri, body, tags).ConfigureAwait(false); - return request; - } - private static List getParameters(MethodInfo targetMethod, object[] args, out object body) - { - List result = new(); - body = null; - - ParameterInfo[] parameters = targetMethod.GetParameters(); - - for (int i = 0; i < parameters.Length; i++) - { - ParameterInfo parameter = parameters[i]; - object value = args[i]; - - if (isBodyParameter(parameter)) - body = value; - else - { - HttpUriParameter uriParameter = new(parameter.Name, value); - result.Add(uriParameter); - } - } - - return result; - - static bool isBodyParameter(ParameterInfo p) - { - HttpBodyAttribute bodyAttribute = p.GetCustomAttribute(); - return bodyAttribute != null; - } - } - private string getUri(string[] tags, string routeTemplate, ref List uriParameters) - { - - if (routeTemplate.StartsWith("/")) - routeTemplate = routeTemplate[1..]; - - for (int i = 0; i < uriParameters.Count; i++) - { - HttpUriParameter parameter = uriParameters[i]; - - if (routeTemplate.Contains($"{{{parameter.Name}}}")) - { - routeTemplate = routeTemplate.Replace( - $"{{{parameter.Name}}}", - HttpUtility.UrlEncode(parameter.Value?.ToString() ?? string.Empty)); - - uriParameters.RemoveAt(i); - i--; - } - } - - routeTemplate = addQueryString(uriParameters, routeTemplate, tags); - - return routeTemplate; - - string addQueryString(List uriParameters, string uri, string[] tags) - { - string queryString = BuildQueryString(uriParameters, tags); - if (!string.IsNullOrEmpty(queryString)) - { - if (!queryString.StartsWith("?")) - queryString = "?" + queryString; - - uri += queryString; - } - - return uri; - } - } - - private static bool returnsTask(MethodInfo method) - { - return method.ReturnType == typeof(Task); - } - private static bool returnsGenericTask(MethodInfo method) - { - return method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>); - } - } -} +using Byteology.GuardClauses; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using System.Web; + +namespace Byteology.TypedHttpClients +{ + /// + /// Provides a base class for HTTP clients that implement a service contract. + /// + /// The service contract. It should be an interface containing only + /// async methods decorated with and having no output parameters. + /// A single method parameter is allowed to be decorated with the + /// in order to be used as the request's content. All other parameters will be used either as route or query parameters + /// depending on whether their name has a match in the or not. + #pragma warning disable CS0618 // Type or member is obsolete + public abstract class TypedHttpClient : IDispatchHandler + #pragma warning restore CS0618 // Type or member is obsolete + where TServiceContract : class + { + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the + /// class using an + /// that will send the HTTP requests. + /// + /// The HTTP client. + protected TypedHttpClient(HttpClient httpClient) + { + Guard.Argument(httpClient, nameof(httpClient)).NotNull(); + + _httpClient = httpClient; + #pragma warning disable CS0618 // Type or member is obsolete + Endpoints = DispatchProxyDelegator.Create(this); + #pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Gets the endpoints of the service. + /// + public TServiceContract Endpoints { get; } + + [SuppressMessage("Major Code Smell", + "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields")] + object? IDispatchHandler.Dispatch(MethodInfo targetMethod, object?[]? args) + { + if (targetMethod.GetCustomAttribute(true) == null) + throw new + InvalidOperationException($"The method should be decorated with the {typeof(HttpEndpointAttribute)}."); + + ParameterInfo[] parameters = targetMethod.GetParameters(); + + if (parameters.Any(p => p.IsOut)) + throw new InvalidOperationException("Output parameters are not allowed."); + + if (parameters.Count(p => p.GetCustomAttribute() != null) > 1) + throw new InvalidOperationException("Maximum of one body parameter is allowed per method."); + + if (returnsTask(targetMethod)) + return sendRequestAndParseResponseAsync(targetMethod, args); + else if (returnsGenericTask(targetMethod)) + { + MethodInfo sendRequestMethod = typeof(TypedHttpClient) + .GetMethod(nameof(sendRequestAndParseGenericResponseAsync), + BindingFlags.NonPublic | BindingFlags.Instance)!; + sendRequestMethod = + sendRequestMethod.MakeGenericMethod(targetMethod.ReturnType.GenericTypeArguments.Single()); + return sendRequestMethod.Invoke(this, new object?[] { targetMethod, args }); + } + else + throw new InvalidOperationException("Method should return either Task or Task<>."); + } + + /// + /// Builds a query string. + /// + /// The query parameters. + /// The tags of the request. These are provided by the + /// in order for this method to be able to recognize requests that require special treatment. + protected virtual string BuildQueryString(IEnumerable queryParameters, string[]? tags) + { + StringBuilder builder = new(); + + if (queryParameters != null) + foreach (HttpUriParameter parameter in queryParameters) + { + if (parameter.Value is ICollection collection) + foreach (object item in collection) + builder.Append($"{HttpUtility.UrlEncode(parameter.Name)}={HttpUtility.UrlEncode(item?.ToString())}&"); + else + builder.Append($"{HttpUtility.UrlEncode(parameter.Name)}={HttpUtility.UrlEncode(parameter.Value?.ToString())}&"); + } + + if (builder.Length > 0) + builder.Remove(builder.Length - 1, 1); + + return builder.ToString(); + } + + /// + /// Builds an HTTP request. + /// + /// The HTTP verb of the request. + /// The URI of the request. + /// The content body of the request or if no body should be provided. + /// The tags of the request. These are provided by the + /// in order for this method to be able to recognize requests that require special treatment. + protected abstract Task BuildRequestAsync(string verb, string uri, object? body, + string[]? tags); + + /// + /// Send an HTTP request as an asynchronous operation. + /// + /// The HTTP client to use. + /// The request to send. + /// The tags of the request. These are specified by the + /// in order for this method to be able to recognize requests that require special treatment. + protected virtual async Task SendRequestAsync( + HttpClient httpClient, HttpRequestMessage request, string[]? tags) + { + Guard.Argument(httpClient, nameof(httpClient)).NotNull(); + Guard.Argument(request, nameof(request)).NotNull(); + + HttpResponseMessage response = await httpClient.SendAsync(request).ConfigureAwait(false); + response.RequestMessage = request; + return response; + } + + /// + /// Processes the response of an HTTP request. + /// + /// The HTTP response. + /// The tags of the request. These are specified by the + /// in order for this method to be able to recognize requests that require special treatment. + protected abstract Task ProcessResponse(HttpResponseMessage response, string[]? tags); + + /// + /// Processes the response of an HTTP request and converts its body to . + /// + /// The type of the object the response message should be converted to. + /// The HTTP response. + /// The tags of the request. These are specified by the + /// in order for this method to be able to recognize requests that require special treatment. + protected abstract Task ProcessResponse(HttpResponseMessage response, string[]? tags); + + private async Task sendRequestAndParseResponseAsync(MethodInfo targetMethod, object?[]? args) + { + HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true)!; + string[]? tags = endpointAttribute.Tags; + + HttpRequestMessage request = await createRequestAsync(targetMethod, args).ConfigureAwait(false); + HttpResponseMessage response = await SendRequestAsync(_httpClient, request, tags).ConfigureAwait(false); + + await ProcessResponse(response, tags).ConfigureAwait(false); + } + + private async Task sendRequestAndParseGenericResponseAsync( + MethodInfo targetMethod, object[] args) + { + HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true)!; + string[]? tags = endpointAttribute.Tags; + HttpRequestMessage request = await createRequestAsync(targetMethod, args).ConfigureAwait(false); + HttpResponseMessage response = await SendRequestAsync(_httpClient, request, tags).ConfigureAwait(false); + + return await ProcessResponse(response, tags).ConfigureAwait(false); + } + + private async Task createRequestAsync(MethodInfo targetMethod, object?[]? args) + { + HttpEndpointAttribute endpointAttribute = targetMethod.GetCustomAttribute(true)!; + + string verb = endpointAttribute.Verb; + string routeTemplate = endpointAttribute.RouteTemplate; + string[]? tags = endpointAttribute.Tags; + + List uriParameters = getParameters(targetMethod, args, out object? body); + string uri = getUri(tags, routeTemplate, ref uriParameters); + + HttpRequestMessage request = await BuildRequestAsync(verb, uri, body, tags).ConfigureAwait(false); + return request; + } + + private static List getParameters(MethodInfo targetMethod, object?[]? args, out object? body) + { + List result = new(); + body = null; + + ParameterInfo[] parameters = targetMethod.GetParameters(); + + for (int i = 0; i < parameters.Length; i++) + { + ParameterInfo parameter = parameters[i]; + object? value = args![i]; + + if (isBodyParameter(parameter)) + body = value; + else + { + HttpUriParameter uriParameter = new(parameter.Name!, value); + result.Add(uriParameter); + } + } + + return result; + + static bool isBodyParameter(ParameterInfo p) + { + HttpBodyAttribute? bodyAttribute = p.GetCustomAttribute(); + return bodyAttribute != null; + } + } + + private string getUri(string[]? tags, string routeTemplate, ref List uriParameters) + { + if (routeTemplate.StartsWith("/")) + routeTemplate = routeTemplate[1..]; + + for (int i = 0; i < uriParameters.Count; i++) + { + HttpUriParameter parameter = uriParameters[i]; + + if (routeTemplate.Contains($"{{{parameter.Name}}}")) + { + routeTemplate = routeTemplate.Replace($"{{{parameter.Name}}}", + HttpUtility.UrlEncode(parameter.Value?.ToString() ?? + string.Empty)); + + uriParameters.RemoveAt(i); + i--; + } + } + + routeTemplate = addQueryString(uriParameters, routeTemplate, tags); + + return routeTemplate; + + string addQueryString(List uriParameters, string uri, string[]? tags) + { + string queryString = BuildQueryString(uriParameters, tags); + if (!string.IsNullOrEmpty(queryString)) + { + if (!queryString.StartsWith("?")) + queryString = "?" + queryString; + + uri += queryString; + } + + return uri; + } + } + + private static bool returnsTask(MethodInfo method) + { + return method.ReturnType == typeof(Task); + } + + private static bool returnsGenericTask(MethodInfo method) + { + return method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>); + } + } +} diff --git a/Byteology.TypedHttpClients/Dispatching/DispatchProxyDelegator.cs b/Byteology.TypedHttpClients/Dispatching/DispatchProxyDelegator.cs index d1d9d73..aa4a673 100644 --- a/Byteology.TypedHttpClients/Dispatching/DispatchProxyDelegator.cs +++ b/Byteology.TypedHttpClients/Dispatching/DispatchProxyDelegator.cs @@ -1,47 +1,52 @@ -using System; -using System.ComponentModel; -using System.Reflection; - -namespace Byteology.TypedHttpClients -{ - /// - /// Used to transfer a calls to an . - /// - [Browsable(false)] // This class shouldn't be used externaly but needs to be seen by calling assemblies so we just hide it from intellisense. - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Meant for internal use only.")] - public class DispatchProxyDelegator : DispatchProxy - { - /// - /// Creates an object instance that implements the provided interface type. - /// - /// The type of the interface to be implemented. - /// The object that will handle the method calls. - public static TInterface Create(IDispatchHandler handler) - where TInterface : class - { - TInterface proxy = DispatchProxy.Create(); - MethodInfo initMethod = typeof(DispatchProxyDelegator).GetMethod(nameof(initialize), BindingFlags.NonPublic | BindingFlags.Instance); - initMethod.Invoke(proxy, new object[] { handler }); - return proxy; - } - - private IDispatchHandler _dispatcher; - - private void initialize(IDispatchHandler dispatcher) - { - _dispatcher = dispatcher; - } - - /// - /// Whenever any method on the generated proxy type is called, this method is invoked to dispatch control. - /// - /// The method the caller invoked. - /// The arguments the caller passed to the method. - /// The object to return to the caller, or for void methods. - protected override object Invoke(MethodInfo targetMethod, object[] args) - { - return _dispatcher.Dispatch(targetMethod, args); - } - } -} +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Byteology.TypedHttpClients +{ + /// + /// Used to transfer a calls to an . + /// + [Browsable(false)] // This class shouldn't be used externally but needs to be seen by calling assemblies so we just hide it from intellisense. + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Meant for internal use only.")] + public class DispatchProxyDelegator : DispatchProxy + { + private IDispatchHandler? _dispatcher; + + /// + /// Creates an object instance that implements the provided interface type. + /// + /// The type of the interface to be implemented. + /// The object that will handle the method calls. + [SuppressMessage("Major Code Smell", + "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields")] + public static TInterface Create(IDispatchHandler handler) + where TInterface : class + { + TInterface proxy = DispatchProxy.Create(); + MethodInfo initMethod = + typeof(DispatchProxyDelegator).GetMethod(nameof(initialize), + BindingFlags.NonPublic | BindingFlags.Instance)!; + initMethod.Invoke(proxy, new object[] { handler }); + return proxy; + } + + private void initialize(IDispatchHandler dispatcher) + { + _dispatcher = dispatcher; + } + + /// + /// Whenever any method on the generated proxy type is called, this method is invoked to dispatch control. + /// + /// The method the caller invoked. + /// The arguments the caller passed to the method. + /// The object to return to the caller, or for void methods. + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + return _dispatcher!.Dispatch(targetMethod!, args); + } + } +} diff --git a/Byteology.TypedHttpClients/Dispatching/IDispatchHandler.cs b/Byteology.TypedHttpClients/Dispatching/IDispatchHandler.cs index 3c00671..89b1b50 100644 --- a/Byteology.TypedHttpClients/Dispatching/IDispatchHandler.cs +++ b/Byteology.TypedHttpClients/Dispatching/IDispatchHandler.cs @@ -1,23 +1,23 @@ -using System; -using System.ComponentModel; -using System.Reflection; - -namespace Byteology.TypedHttpClients -{ - /// - /// Provides a functionality for handling method calls. - /// - [Browsable(false)] // This interface shouldn't be used externaly but needs to be seen by calling assemblies so we just hide it from intellisense. - [EditorBrowsable(EditorBrowsableState.Never)] - [Obsolete("Meant for internal use only.")] - public interface IDispatchHandler - { - /// - /// Dispatches a specified method call. - /// - /// The invoked method. - /// The arguments with which the method was invoked. - /// The result of the method execution. - object Dispatch(MethodInfo targetMethod, object[] args); - } -} +using System; +using System.ComponentModel; +using System.Reflection; + +namespace Byteology.TypedHttpClients +{ + /// + /// Provides a functionality for handling method calls. + /// + [Browsable(false)] // This interface shouldn't be used externally but needs to be seen by calling assemblies so we just hide it from intellisense. + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Meant for internal use only.")] + public interface IDispatchHandler + { + /// + /// Dispatches a specified method call. + /// + /// The invoked method. + /// The arguments with which the method was invoked. + /// The result of the method execution. + object? Dispatch(MethodInfo targetMethod, object?[]? args); + } +} diff --git a/Byteology.TypedHttpClients/HttpEndpointAttribute.cs b/Byteology.TypedHttpClients/HttpEndpointAttribute.cs index 8973868..dc83ced 100644 --- a/Byteology.TypedHttpClients/HttpEndpointAttribute.cs +++ b/Byteology.TypedHttpClients/HttpEndpointAttribute.cs @@ -1,51 +1,51 @@ -using Byteology.GuardClauses; -using System; - -namespace Byteology.TypedHttpClients -{ - /// - /// Used to mark a method's signature as a description of an HTTP endpoint. - /// See for more information. - /// - /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] - public class HttpEndpointAttribute : Attribute - { - /// - /// Gets the endpoint route. May contain parameter names surrounded by curly brackets - /// which will be replaced by the passed arguments. - /// - public string RouteTemplate { get; } - - /// - /// Gets the HTTP verb of the endpoint. - /// - public string Verb { get; } - - /// - /// Gets or sets tags for the endpoint. These tags will be - /// passed to all methods connected to building HTTP requests and parsing their responses - /// and can be used to distinguish the endpoints which must be treated differently. - /// - public string[] Tags { get; set; } - - /// - /// Initializes a new instance of the class. - /// It is used to mark a method's signature as a description of an HTTP endpoint. - /// See for more information. - /// - /// The HTTP verb of the endpoint. - /// The endpoint route. - /// May contain parameter names surrounded by curly brackets which will be replaced by the passed argument. - /// - /// - public HttpEndpointAttribute(string verb, string routeTemplate) - { - Guard.Argument(verb, nameof(verb)).NotNullOrWhiteSpace(); - Guard.Argument(routeTemplate, nameof(routeTemplate)).NotNull(); - - Verb = verb; - RouteTemplate = routeTemplate; - } - } -} +using Byteology.GuardClauses; +using System; + +namespace Byteology.TypedHttpClients +{ + /// + /// Used to mark a method's signature as a description of an HTTP endpoint. + /// See for more information. + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class HttpEndpointAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// It is used to mark a method's signature as a description of an HTTP endpoint. + /// See for more information. + /// + /// The HTTP verb of the endpoint. + /// The endpoint route. + /// May contain parameter names surrounded by curly brackets which will be replaced by the passed argument. + /// + /// + public HttpEndpointAttribute(string verb, string routeTemplate) + { + Guard.Argument(verb, nameof(verb)).NotNullOrWhiteSpace(); + Guard.Argument(routeTemplate, nameof(routeTemplate)).NotNull(); + + Verb = verb; + RouteTemplate = routeTemplate; + } + + /// + /// Gets the endpoint route. May contain parameter names surrounded by curly brackets + /// which will be replaced by the passed arguments. + /// + public string RouteTemplate { get; } + + /// + /// Gets the HTTP verb of the endpoint. + /// + public string Verb { get; } + + /// + /// Gets or sets tags for the endpoint. These tags will be + /// passed to all methods connected to building HTTP requests and parsing their responses + /// and can be used to distinguish the endpoints which must be treated differently. + /// + public string[]? Tags { get; set; } + } +} diff --git a/Byteology.TypedHttpClients/HttpUriParameter.cs b/Byteology.TypedHttpClients/HttpUriParameter.cs index 9741a49..ab6a9b3 100644 --- a/Byteology.TypedHttpClients/HttpUriParameter.cs +++ b/Byteology.TypedHttpClients/HttpUriParameter.cs @@ -1,28 +1,29 @@ -namespace Byteology.TypedHttpClients -{ - /// - /// Represents a URI parameter. - /// - public class HttpUriParameter - { - /// - /// Gets the name of the parameter. - /// - public string Name { get; } - /// - /// Gets the value of the parameter. - /// - public object Value { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The name of the parameter. - /// The value of the parameter. - public HttpUriParameter(string name, object value) - { - Name = name; - Value = value; - } - } -} +namespace Byteology.TypedHttpClients +{ + /// + /// Represents a URI parameter. + /// + public class HttpUriParameter + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the parameter. + /// The value of the parameter. + public HttpUriParameter(string name, object? value) + { + Name = name; + Value = value; + } + + /// + /// Gets the name of the parameter. + /// + public string Name { get; } + + /// + /// Gets the value of the parameter. + /// + public object? Value { get; } + } +} diff --git a/Byteology.TypedHttpClients/ServiceCollectionExtensions.cs b/Byteology.TypedHttpClients/ServiceCollectionExtensions.cs index 6b13b59..cfe35f5 100644 --- a/Byteology.TypedHttpClients/ServiceCollectionExtensions.cs +++ b/Byteology.TypedHttpClients/ServiceCollectionExtensions.cs @@ -1,48 +1,48 @@ -using Byteology.GuardClauses; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Net.Http; - -namespace Byteology.TypedHttpClients -{ - /// - /// Contains extensions methods for injecting instances. - /// - public static class ServiceCollectionExtensions - { - /// - public static IServiceCollection AddTypedHttpClient(this IServiceCollection services) - where TClient : TypedHttpClient - where TServiceContract : class - { - return AddTypedHttpClient(services, x => { }); - } - - /// - /// Configures a binding between and using an - /// underlying named . The name will be set to the - /// type name of . - /// - /// - /// The type of the to use. - /// The . - /// A delegate that is used to configure an . - /// A reference to this instance after the operation has completed. - public static IServiceCollection AddTypedHttpClient( - this IServiceCollection services, - Action configureClient) - where TClient : TypedHttpClient - where TServiceContract : class - { - Guard.Argument(configureClient, nameof(configureClient)).NotNull(); - - if (typeof(TClient).IsAbstract) - throw new ArgumentException(typeof(TClient).Name + " must be a concrete implemntation."); - - services.AddHttpClient(typeof(TServiceContract).FullName, configureClient); - services.AddScoped(sp => sp.GetRequiredService().Endpoints); - - return services; - } - } -} +using Byteology.GuardClauses; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Net.Http; + +namespace Byteology.TypedHttpClients +{ + /// + /// Contains extensions methods for injecting instances. + /// + public static class ServiceCollectionExtensions + { + /// + public static IServiceCollection AddTypedHttpClient(this IServiceCollection services) + where TClient : TypedHttpClient + where TServiceContract : class + { + return AddTypedHttpClient(services, _ => { }); + } + + /// + /// Configures a binding between and using an + /// underlying named . The name will be set to the + /// type name of . + /// + /// + /// The type of the to use. + /// The . + /// A delegate that is used to configure an . + /// A reference to this instance after the operation has completed. + public static IServiceCollection AddTypedHttpClient( + this IServiceCollection services, + Action configureClient) + where TClient : TypedHttpClient + where TServiceContract : class + { + Guard.Argument(configureClient, nameof(configureClient)).NotNull(); + + if (typeof(TClient).IsAbstract) + throw new ArgumentException(typeof(TClient).Name + " must be a concrete implementation."); + + services.AddHttpClient(typeof(TServiceContract).FullName, configureClient); + services.AddScoped(sp => sp.GetRequiredService().Endpoints); + + return services; + } + } +} diff --git a/Icon.png b/Icon.png index b702aaa..9566f4a 100644 Binary files a/Icon.png and b/Icon.png differ