From 62e8b44e53c24cefaaee09fe0bb8dcc47175c985 Mon Sep 17 00:00:00 2001 From: helto4real Date: Wed, 6 May 2020 21:12:46 +0200 Subject: [PATCH] Http features --- src/App/NetDaemon.App/Common/INetDaemon.cs | 57 ++++++- src/App/NetDaemon.App/Common/NetDaemonApp.cs | 10 +- .../NetDaemon.Daemon/Daemon/HttpHandler.cs | 88 ++++++++++ .../NetDaemon.Daemon/Daemon/NetDaemonHost.cs | 19 ++- .../NetDaemon.Daemon/NetDaemon.Daemon.csproj | 1 + .../DaemonRunner/DaemonRunner.csproj | 1 + .../DaemonRunner/Service/RunnerService.cs | 14 +- .../DaemonHostTestBase.cs | 14 +- .../Demon/HttpHandlerMock.cs | 73 +++++++++ .../NetDaemon.Daemon.Tests/Demon/HttpTests.cs | 151 ++++++++++++++++++ 10 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 src/Daemon/NetDaemon.Daemon/Daemon/HttpHandler.cs create mode 100644 tests/NetDaemon.Daemon.Tests/Demon/HttpHandlerMock.cs create mode 100644 tests/NetDaemon.Daemon.Tests/Demon/HttpTests.cs diff --git a/src/App/NetDaemon.App/Common/INetDaemon.cs b/src/App/NetDaemon.App/Common/INetDaemon.cs index bc1c1afb1..4b3bfb6d8 100644 --- a/src/App/NetDaemon.App/Common/INetDaemon.cs +++ b/src/App/NetDaemon.App/Common/INetDaemon.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using System.Collections.Concurrent; +using System.Net.Http; +using System.Net; +using System.Text.Json; namespace JoySoftware.HomeAssistant.NetDaemon.Common { @@ -348,6 +350,11 @@ public interface INetDaemonBase /// Unique Id of the data /// The data persistent or null if not exists ValueTask GetDataAsync(string id); + + /// + /// Http features of NetDaemon is exposed through the Http property + /// + IHttpHandler Http { get; } } @@ -471,4 +478,52 @@ public interface INetDaemon : INetDaemonBase /// The unique id:s of the script IScript RunScript(INetDaemonApp app, params string[] entityIds); } + + /// + /// Implements all Http features of NetDaemon + /// + public interface IHttpHandler + { + /// + /// Returns a http client to use with http calls + /// + /// Logical name of the client to create + /// + /// This method uses the HttpClientFactory in the background for + /// more resource friendly usage of http client. You can cache the client + /// or dispose the client each usage in a using block. + /// Callers are also free to mutate the returned HttpClient + /// instance's public properties as desired. + /// + HttpClient CreateHttpClient(string? name = null); + + + /// + /// Gets a json resopose deserialized + /// + /// Url + /// Serialization options to use when serializing + /// name and value tuple of request headers, allowed values are string and IEnumerable of string + /// The type to use when deserializing + Task GetJson(string url, JsonSerializerOptions? options = null, params (string, object)[] headers); + + /// + /// Post a object that are serialized to a json request and returns a deserializes json response + /// + /// Url + /// The object to use as request + /// Serialization options to use when serializing + /// name and value tuple of request headers, allowed values are string and IEnumerable of string + /// The type to use when deserializing + Task PostJson(string url, object request, JsonSerializerOptions? options = null, params (string, object)[] headers); + + /// + /// Post a object that are serialized to a json request + /// + /// Url + /// The object to use as request + /// Serialization options to use when serializing + /// name and value tuple of request headers, allowed values are string and IEnumerable of string + Task PostJson(string url, object request, JsonSerializerOptions? options = null, params (string, object)[] headers); + } } \ No newline at end of file diff --git a/src/App/NetDaemon.App/Common/NetDaemonApp.cs b/src/App/NetDaemon.App/Common/NetDaemonApp.cs index 7816650fc..e786ef9e0 100644 --- a/src/App/NetDaemon.App/Common/NetDaemonApp.cs +++ b/src/App/NetDaemon.App/Common/NetDaemonApp.cs @@ -64,6 +64,15 @@ public abstract class NetDaemonApp : INetDaemonApp, INetDaemonBase /// public ConcurrentDictionary Global => _global; + /// + public IHttpHandler Http + { + get + { + _ = _daemon as INetDaemon ?? throw new NullReferenceException($"{nameof(_daemon)} cant be null!"); + return _daemon!.Http; + } + } /// public Task CallService(string domain, string service, dynamic? data = null, bool waitForResponse = false) { @@ -448,7 +457,6 @@ public IDelayResult DelayUntilStateChange(IEnumerable entityIds, Func /// Implements the IEqualit.Equals method /// diff --git a/src/Daemon/NetDaemon.Daemon/Daemon/HttpHandler.cs b/src/Daemon/NetDaemon.Daemon/Daemon/HttpHandler.cs new file mode 100644 index 000000000..16feafb94 --- /dev/null +++ b/src/Daemon/NetDaemon.Daemon/Daemon/HttpHandler.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using JoySoftware.HomeAssistant.NetDaemon.Common; + +namespace JoySoftware.HomeAssistant.NetDaemon.Daemon +{ + public class HttpHandler : IHttpHandler + { + private readonly IHttpClientFactory? _httpClientFactory; + + public HttpHandler(IHttpClientFactory? httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + public HttpClient CreateHttpClient(string? name = null) + { + _ = _httpClientFactory ?? throw new NullReferenceException("No IHttpClientFactory provided, please add AddHttpClient() in configure services!"); + return _httpClientFactory.CreateClient(name); + } + + public async Task GetJson(string url, JsonSerializerOptions? options = null, params (string, object)[] headers) + { + _ = _httpClientFactory ?? throw new NullReferenceException("No IHttpClientFactory provided, please add AddHttpClient() in configure services!"); + + var httpClient = _httpClientFactory.CreateClient(); + + AddHeaders(httpClient, headers); + + var streamTask = httpClient.GetStreamAsync(url); + + return await JsonSerializer.DeserializeAsync(await streamTask.ConfigureAwait(false), options); + } + + public async Task PostJson(string url, object request, JsonSerializerOptions? options = null, params (string, object)[] headers) + { + _ = _httpClientFactory ?? throw new NullReferenceException("No IHttpClientFactory provided, please add AddHttpClient() in configure services!"); + + var httpClient = _httpClientFactory.CreateClient(); + + AddHeaders(httpClient, headers); + + var bytesToPost = JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), options); + + var response = await httpClient.PostAsync(url, new ByteArrayContent(bytesToPost)); + + response.EnsureSuccessStatusCode(); + + var streamTask = response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(await streamTask.ConfigureAwait(false)); + } + + public async Task PostJson(string url, object request, JsonSerializerOptions? options = null, params (string, object)[] headers) + { + _ = _httpClientFactory ?? throw new NullReferenceException("No IHttpClientFactory provided, please add AddHttpClient() in configure services!"); + + var httpClient = _httpClientFactory.CreateClient(); + + AddHeaders(httpClient, headers); + + var bytesToPost = JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), options); + + var response = await httpClient.PostAsync(url, new ByteArrayContent(bytesToPost)); + + response.EnsureSuccessStatusCode(); + } + + private void AddHeaders(HttpClient httpClient, (string, object)[] headers) + { + if (headers is object && headers.Length > 0) + { + httpClient.DefaultRequestHeaders.Clear(); + foreach (var (name, header) in headers) + { + if (header is string) + httpClient.DefaultRequestHeaders.Add(name, (string)header); + else if (header is IEnumerable) + httpClient.DefaultRequestHeaders.Add(name, (IEnumerable)header); + else + throw new ApplicationException($"Unsupported header, expected string or IEnumerable for {name}"); + } + } + } + } +} \ No newline at end of file diff --git a/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs b/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs index 103eca4d8..fabeb751a 100644 --- a/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs +++ b/src/Daemon/NetDaemon.Daemon/Daemon/NetDaemonHost.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Dynamic; using System.Linq; +using System.Net.Http; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; @@ -40,6 +41,7 @@ public class NetDaemonHost : INetDaemonHost, IAsyncDisposable private readonly Scheduler _scheduler; private readonly IDataRepository? _repository; + private readonly IHttpHandler? _httpHandler; // Used for testing internal ConcurrentDictionary action)> InternalStateActions => _stateActions; @@ -69,9 +71,14 @@ public class NetDaemonHost : INetDaemonHost, IAsyncDisposable private readonly List<(string, string, Func)> _companionServiceCallFunctionList = new List<(string, string, Func)>(); - public NetDaemonHost(IHassClient? hassClient, IDataRepository? repository, ILoggerFactory? loggerFactory = null) + public NetDaemonHost( + IHassClient? hassClient, + IDataRepository? repository, + ILoggerFactory? loggerFactory = null, + IHttpHandler? httpHandler = null) { loggerFactory ??= DefaultLoggerFactory; + _httpHandler = httpHandler; Logger = loggerFactory.CreateLogger(); _hassClient = hassClient ?? throw new ArgumentNullException("HassClient can't be null!"); _scheduler = new Scheduler(loggerFactory: loggerFactory); @@ -93,6 +100,15 @@ public NetDaemonHost(IHassClient? hassClient, IDataRepository? repository, ILogg .AddConsole(); }); + public IHttpHandler Http + { + get + { + _ = _httpHandler ?? throw new NullReferenceException("HttpHandler can not be null!"); + return _httpHandler; + } + } + public Task CallService(string domain, string service, dynamic? data = null, bool waitForResponse = false) => _hassClient.CallService(domain, service, data, false); public IEntity Entities(INetDaemonApp app, Func func) @@ -743,6 +759,7 @@ public async ValueTask DisposeAsync() await Stop().ConfigureAwait(false); } } + public class DelayResult : IDelayResult { private readonly TaskCompletionSource _delayTaskCompletionSource; diff --git a/src/Daemon/NetDaemon.Daemon/NetDaemon.Daemon.csproj b/src/Daemon/NetDaemon.Daemon/NetDaemon.Daemon.csproj index bfa1abdb6..7f2647d76 100644 --- a/src/Daemon/NetDaemon.Daemon/NetDaemon.Daemon.csproj +++ b/src/Daemon/NetDaemon.Daemon/NetDaemon.Daemon.csproj @@ -22,6 +22,7 @@ + diff --git a/src/DaemonRunner/DaemonRunner/DaemonRunner.csproj b/src/DaemonRunner/DaemonRunner/DaemonRunner.csproj index b19ea8b5a..8f29c5189 100644 --- a/src/DaemonRunner/DaemonRunner/DaemonRunner.csproj +++ b/src/DaemonRunner/DaemonRunner/DaemonRunner.csproj @@ -24,6 +24,7 @@ + diff --git a/src/DaemonRunner/DaemonRunner/Service/RunnerService.cs b/src/DaemonRunner/DaemonRunner/Service/RunnerService.cs index 076a18ee8..5746d0a61 100644 --- a/src/DaemonRunner/DaemonRunner/Service/RunnerService.cs +++ b/src/DaemonRunner/DaemonRunner/Service/RunnerService.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -18,6 +17,7 @@ using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; using Microsoft.Extensions.DependencyInjection; +using System.Net.Http; namespace JoySoftware.HomeAssistant.NetDaemon.DaemonRunner.Service { @@ -96,7 +96,11 @@ public static async Task Run(string[] args) public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseSerilog() - .ConfigureServices(services => { services.AddHostedService(); }); + .ConfigureServices(services => + { + services.AddHttpClient(); + services.AddHostedService(); + }); } public class RunnerService : BackgroundService @@ -104,6 +108,7 @@ public class RunnerService : BackgroundService const string _version = "dev"; + private readonly IHttpClientFactory _httpClientFactory; // private NetDaemonHost? _daemonHost; private readonly ILogger _logger; @@ -114,8 +119,9 @@ public class RunnerService : BackgroundService /// private const int _reconnectIntervall = 40000; - public RunnerService(ILoggerFactory loggerFactory) + public RunnerService(ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory) { + _httpClientFactory = httpClientFactory; _logger = loggerFactory.CreateLogger(); _loggerFactory = loggerFactory; } @@ -165,7 +171,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await Task.Delay(_reconnectIntervall, stoppingToken).ConfigureAwait(false); // Wait x seconds } - await using var _daemonHost = new NetDaemonHost(new HassClient(_loggerFactory), new DataRepository(storageFolder), _loggerFactory); + await using var _daemonHost = new NetDaemonHost(new HassClient(_loggerFactory), new DataRepository(storageFolder), _loggerFactory, new HttpHandler(_httpClientFactory)); var daemonHostTask = _daemonHost.Run(config.Host, config.Port, config.Ssl, config.Token, stoppingToken); diff --git a/tests/NetDaemon.Daemon.Tests/DaemonHostTestBase.cs b/tests/NetDaemon.Daemon.Tests/DaemonHostTestBase.cs index 567c31221..aaf1ef5a2 100644 --- a/tests/NetDaemon.Daemon.Tests/DaemonHostTestBase.cs +++ b/tests/NetDaemon.Daemon.Tests/DaemonHostTestBase.cs @@ -2,9 +2,11 @@ using JoySoftware.HomeAssistant.NetDaemon.Daemon; using JoySoftware.HomeAssistant.NetDaemon.Daemon.Storage; using Moq; +using NetDaemon.Daemon.Tests.Daemon; using System.Collections.Generic; using System.Diagnostics; using System.Dynamic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -17,6 +19,7 @@ public partial class DaemonHostTestBase private readonly LoggerMock _loggerMock; private readonly HassClientMock _defaultHassClientMock; private readonly Mock _defaultDataRepositoryMock; + private readonly HttpHandlerMock _defaultHttpHandlerMock; private readonly NetDaemonHost _defaultDaemonHost; private readonly NetDaemonHost _notConnectedDaemonHost; @@ -26,7 +29,14 @@ internal DaemonHostTestBase() _loggerMock = new LoggerMock(); _defaultHassClientMock = HassClientMock.DefaultMock; _defaultDataRepositoryMock = new Mock(); - _defaultDaemonHost = new NetDaemonHost(_defaultHassClientMock.Object, _defaultDataRepositoryMock.Object, _loggerMock.LoggerFactory); + + _defaultHttpHandlerMock = new HttpHandlerMock(); + _defaultDaemonHost = new NetDaemonHost( + _defaultHassClientMock.Object, + _defaultDataRepositoryMock.Object, + _loggerMock.LoggerFactory, + _defaultHttpHandlerMock.Object); + _defaultDaemonApp = new BaseTestApp(); _defaultDaemonApp.StartUpAsync(_defaultDaemonHost); @@ -40,6 +50,8 @@ internal DaemonHostTestBase() public HassClientMock DefaultHassClientMock => _defaultHassClientMock; + public HttpHandlerMock DefaultHttpHandlerMock => _defaultHttpHandlerMock; + public LoggerMock LoggerMock => _loggerMock; public string HelloWorldData => "Hello world!"; diff --git a/tests/NetDaemon.Daemon.Tests/Demon/HttpHandlerMock.cs b/tests/NetDaemon.Daemon.Tests/Demon/HttpHandlerMock.cs new file mode 100644 index 000000000..634b2c383 --- /dev/null +++ b/tests/NetDaemon.Daemon.Tests/Demon/HttpHandlerMock.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using JoySoftware.HomeAssistant.NetDaemon.Common; + +namespace NetDaemon.Daemon.Tests.Daemon +{ + public class HttpClientFactoryMock : Mock + { + HttpClient? _httpClient; + MockHttpMessageHandler? _handler; + public MockHttpMessageHandler? MessageHandler => _handler; + public HttpClientFactoryMock() + { + } + + public void SetResponse(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _handler = new MockHttpMessageHandler(response, statusCode); + _httpClient = new HttpClient(_handler); + Setup(x => x.CreateClient(It.IsAny())).Returns(_httpClient!); + } + + } + public class HttpHandlerMock : Mock + { + HttpClient? _httpClient; + MockHttpMessageHandler? _handler; + + public MockHttpMessageHandler? MessageHandler => _handler; + public HttpHandlerMock() + { + + } + + public void SetResponse(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _handler = new MockHttpMessageHandler(response, statusCode); + _httpClient = new HttpClient(_handler); + Setup(x => x.CreateHttpClient(It.IsAny())).Returns(_httpClient!); + } + } + + public class MockHttpMessageHandler : HttpMessageHandler + { + private readonly string _response; + private readonly HttpStatusCode _StatusCode; + + private string? _requestContent; + + public string? RequestContent => _requestContent; + + public MockHttpMessageHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _response = response; + _StatusCode = statusCode; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var responseMessage = new HttpResponseMessage(_StatusCode); + + if (request is object && request.Content is object) + _requestContent = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + + responseMessage.Content = new ByteArrayContent(Encoding.ASCII.GetBytes(_response)); + return responseMessage; + } + } + +} \ No newline at end of file diff --git a/tests/NetDaemon.Daemon.Tests/Demon/HttpTests.cs b/tests/NetDaemon.Daemon.Tests/Demon/HttpTests.cs new file mode 100644 index 000000000..701ac1e0a --- /dev/null +++ b/tests/NetDaemon.Daemon.Tests/Demon/HttpTests.cs @@ -0,0 +1,151 @@ +using JoySoftware.HomeAssistant.NetDaemon.Daemon; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Xunit; + +namespace NetDaemon.Daemon.Tests.Daemon +{ + public class SerializedReturn + { + [JsonPropertyName("json_prop")] public string? Property { get; set; } + } + public class HttpTests : DaemonHostTestBase + { + public HttpTests() : base() + { + } + + [Fact] + public async Task HttpClientShouldReturnCorrectContent() + { + // ARRANGE + var response = "{\"json_prop\", \"hello world\"}"; + DefaultHttpHandlerMock.SetResponse(response); + + // ACT + var client = DefaultDaemonHost.Http.CreateHttpClient("test"); + var httpResponseString = await client.GetStringAsync("http://fake.com").ConfigureAwait(false); + // ASSERT + + Assert.Equal(response, httpResponseString); + } + + [Fact] + public void HttpClientShouldNotReturnContentOnBadStatusCode() + { + // ARRANGE + var response = ""; + DefaultHttpHandlerMock.SetResponse(response, HttpStatusCode.NotFound); + + // ACT + var client = DefaultDaemonHost.Http.CreateHttpClient("test"); + + // ASSERT + var ex = Assert.ThrowsAsync(() => client.GetStringAsync("http://fake.com")); + } + + [Fact] + public async Task HttpClientShouldReturnCorrectStatusCode() + { + // ARRANGE + var response = "{\"json_prop\", \"hello world\"}"; + DefaultHttpHandlerMock.SetResponse(response); + + // ACT + var client = DefaultDaemonHost.Http.CreateHttpClient("test"); + var httpResponse = await client.GetAsync("http://fake.com").ConfigureAwait(false); + // ASSERT + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + } + + [Fact] + public async Task HttpClientShouldReturnCorrectStatusCodeError() + { + // ARRANGE + var response = "{\"json_prop\", \"hello world\"}"; + DefaultHttpHandlerMock.SetResponse(response, HttpStatusCode.Forbidden); + + // ACT + var client = DefaultDaemonHost.Http.CreateHttpClient("test"); + var httpResponse = await client.GetAsync("http://fake.com").ConfigureAwait(false); + // ASSERT + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + [Fact] + public async Task HttpHandlerGetJsonShouldReturnCorrectContent() + { + // ARRANGE + var response = "{\"json_prop\": \"hello world\"}"; + + HttpClientFactoryMock factoryMock = new HttpClientFactoryMock(); + factoryMock.SetResponse(response); + + var httpHandler = new HttpHandler(factoryMock.Object); + + // ACT + var result = await httpHandler.GetJson("http://fake.com").ConfigureAwait(false); + // ASSERT + + Assert.Equal("hello world", result.Property); + + } + + [Fact] + public void HttpHandlerGetJsonBadFormatShouldReturnThrowException() + { + // ARRANGE + var response = "{\"json_prop\": \"hello world\"}"; + + HttpClientFactoryMock factoryMock = new HttpClientFactoryMock(); + factoryMock.SetResponse(response); + + var httpHandler = new HttpHandler(factoryMock.Object); + + // ACT & ASSERT + var result = Assert.ThrowsAsync(() => httpHandler.GetJson("http://fake.com")); + } + + [Fact] + public async Task HttpHandlerPostJsonShouldReturnCorrectContent() + { + // ARRANGE + var response = "{\"json_prop\": \"hello world\"}"; + + HttpClientFactoryMock factoryMock = new HttpClientFactoryMock(); + factoryMock.SetResponse(response); + + var httpHandler = new HttpHandler(factoryMock.Object); + + // ACT + var result = await httpHandler.PostJson("http://fake.com", new { posted = "some value" }).ConfigureAwait(false); + // ASSERT + + Assert.Equal("hello world", result.Property); + Assert.Equal("{\"posted\":\"some value\"}", factoryMock?.MessageHandler?.RequestContent); + } + + [Fact] + public async Task HttpHandlerPostJsonNoReturnShouldReturnCorrectContent() + { + // ARRANGE + var response = "{\"json_prop\": \"hello world\"}"; + + HttpClientFactoryMock factoryMock = new HttpClientFactoryMock(); + factoryMock.SetResponse(response); + + var httpHandler = new HttpHandler(factoryMock.Object); + + // ACT + await httpHandler.PostJson("http://fake.com", new { posted = "some value" }).ConfigureAwait(false); + // ASSERT + + Assert.Equal("{\"posted\":\"some value\"}", factoryMock?.MessageHandler?.RequestContent); + } + } +} \ No newline at end of file