diff --git a/MetasysServices.Tests/MetasysClientTests.cs b/MetasysServices.Tests/MetasysClientTests.cs index 095f54f..cde1dfe 100644 --- a/MetasysServices.Tests/MetasysClientTests.cs +++ b/MetasysServices.Tests/MetasysClientTests.cs @@ -2,14 +2,17 @@ using System.Collections.Generic; using System.Net.Http; using System.Linq; -using Flurl.Http.Testing; using NUnit.Framework; using Newtonsoft.Json.Linq; using JohnsonControls.Metasys.BasicServices; using Nito.AsyncEx; using System.Threading.Tasks; -using System.Globalization; using JohnsonControls.Metasys.BasicServices.Enums; +using Flurl.Http; +using System.IO; +using System.Text; +using System.Net; +using System.Net.Http.Json; namespace MetasysServices.Tests { @@ -1547,7 +1550,7 @@ public void TestGetNetworkDevicesWithClassification() "\"items\": [", device, "],", "\"self\": \"https://hostname/api/v2/networkDevices?page=1&pageSize=200&sort=name\"}")); - var devices = client.NetworkDevices.GetAsync(NetworkDeviceClassificationEnum.Controller).GetAwaiter().GetResult(); + var devices = client.NetworkDevices.GetAsync(NetworkDeviceClassificationEnum.Controller).GetAwaiter().GetResult(); httpTest.ShouldHaveCalled($"https://hostname/api/v2/networkDevices") .WithVerb(HttpMethod.Get) @@ -2357,5 +2360,206 @@ public void TestMiscNullTokenValue() } #endregion + + #region Ad-Hoc Calls + [TestCase("v5/networkDevices", "https://hostname/api/v5/networkDevices")] + [TestCase("v5/networkDevices?sort=itemReference", "https://hostname/api/v5/networkDevices?sort=itemReference")] + [TestCase("v5/networkDevices?sort=itemReference#fragment1", "https://hostname/api/v5/networkDevices?sort=itemReference#fragment1")] + [TestCase("/v5/networkDevices", "https://hostname/api/v5/networkDevices")] + [TestCase("/v5/networkDevices?sort=itemReference", "https://hostname/api/v5/networkDevices?sort=itemReference")] + [TestCase("/v5/networkDevices?sort=itemReference#fragment1", "https://hostname/api/v5/networkDevices?sort=itemReference#fragment1")] + [TestCase("https://hostname/api/v5/networkDevices", "https://hostname/api/v5/networkDevices")] + [TestCase("https://hostname/api/v5/networkDevices?sort=itemReference", "https://hostname/api/v5/networkDevices?sort=itemReference")] + [TestCase("https://hostname/api/v5/networkDevices?sort=itemReference#fragment1", "https://hostname/api/v5/networkDevices?sort=itemReference#fragment1")] + public async Task TestSendAsync(string requestUrl, string expectedUrl) + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl); + await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled(expectedUrl) + .WithVerb(HttpMethod.Get) + .Times(1); + } + + [Test] + public async Task TestSendAsyncCheckQueryStringAndFragmentsAreUsed() + { + var requestUrl = "/v5/networkDevices?sort=itemReference#fragment1"; + var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl); + await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled("https://hostname/api/v5/networkDevices?sort=itemReference#fragment1") + .WithVerb(HttpMethod.Get) + .WithQueryParamValue("sort", "itemReference") + .With(call => call.Request.RequestUri.Fragment.Equals("#fragment1")) + .Times(1); + } + + [Test] + public async Task TestSendAsyncCheckRequestHeadersAreUsed() + { + var requestUrl = "/v5/networkDevices?sort=itemReference"; + var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl); + httpRequest.Headers.Add("request-header", "header_value"); + await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled("https://hostname/api/v5/networkDevices?sort=itemReference") + .WithVerb(HttpMethod.Get) + .WithHeader("request-header", "header_value") + .Times(1); + } + + [Test] + public void TestSendAsyncWithInvalidAbsoluteUrlThrowsException() + { + var requestUrl = "https://different-hostname/api/v5/networkDevices"; + var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl); + var e = Assert.Throws(() => + client.SendAsync(httpRequest).GetAwaiter().GetResult()); + + PrintMessage($"TestSendAsyncWithInvalidAbsoluteUrlThrowsException: {e.Message}", true); + } + + [TestCase("v5/networkDevices", "v5/networkDevices")] + [TestCase("/v5/networkDevices", "v5/networkDevices")] + public async Task TestSendAsyncWithCustomPort(string requestUrl, string expectedRelativeUrl) + { + var originalHostname = client.Hostname; + var expectedUrl = $"https://{originalHostname}:8080/api/{expectedRelativeUrl}"; + + client.Hostname = $"{originalHostname}:8080"; + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUrl); + await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled(expectedUrl) + .WithVerb(HttpMethod.Get) + .Times(1); + + client.Hostname = originalHostname; + } + + [Test] + public async Task TestSendAsyncCanGetJson() + { + httpTest.RespondWithJson(new TestData { id = 1, name = "Metasys" }); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getJson"); + var data = await client.SendAsync(httpRequest).ReceiveJson(); + + Assert.AreEqual(1, data.id); + Assert.AreEqual("Metasys", data.name); + } + + [Test] + public async Task TestSendAsyncCanGetJsonDynamic() + { + httpTest.RespondWithJson(new { id = 1, name = "Metasys" }); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getJsonDynamic"); + var data = await client.SendAsync(httpRequest).ReceiveJson(); + + Assert.AreEqual(1, data.id); + Assert.AreEqual("Metasys", data.name); + } + + [Test] + public async Task TestSendAsyncCanGetJsonDynamicList() + { + httpTest.RespondWithJson( + new[] { + new { id = 1, name = "Metasys" }, + new { id = 2, name = "Client" } + } + ); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getJsonDynamicList"); + var data = await client.SendAsync(httpRequest).ReceiveJsonList(); + + Assert.AreEqual(1, data[0].id); + Assert.AreEqual("Metasys", data[0].name); + Assert.AreEqual(2, data[1].id); + Assert.AreEqual("Client", data[1].name); + } + + [Test] + public async Task TestSendAsyncCanGetString() + { + httpTest.RespondWith("Metasys Client"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getString"); + var data = await client.SendAsync(httpRequest).ReceiveString(); + + Assert.AreEqual("Metasys Client", data); + } + + [Test] + public async Task TestSendAsyncCanGetStream() + { + httpTest.RespondWith("Metasys Client"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getStream"); + var data = await client.SendAsync(httpRequest).ReceiveStream(); + + Assert.AreEqual(new MemoryStream(Encoding.UTF8.GetBytes("Metasys Client")), data); + } + + [Test] + public async Task TestSendAsyncCanGetBytes() + { + httpTest.RespondWith("Metasys Client"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://hostname/api/v5/getBytes"); + var data = await client.SendAsync(httpRequest).ReceiveBytes(); + + Assert.AreEqual(Encoding.UTF8.GetBytes("Metasys Client"), data); + } + + [TestCase("PUT", "https://hostname/api/v5/put")] + [TestCase("POST", "https://hostname/api/v5/post")] + [TestCase("PATCH", "https://hostname/api/v5/patch")] + public async Task TestSendAsyncCanMakeRequestWithContent(string httpVerb, string requestUrl) + { + var content = JsonContent.Create(new { id = 1, name = "Metasys" }); + var httpMethod = new HttpMethod(httpVerb); + var httpRequest = new HttpRequestMessage(httpMethod, requestUrl) + { + Content = content + }; + + var response = await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled(requestUrl) + .WithVerb(httpMethod) + .WithContentType("application/json") + .With(call => call.Request.Content.Equals(content)) + .Times(1); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + + [TestCase("DELETE", "https://hostname/api/v5/delete")] + [TestCase("HEAD", "https://hostname/api/v5/head")] + [TestCase("TRACE", "https://hostname/api/v5/trace")] + [TestCase("OPTIONS", "https://hostname/api/v5/options")] + public async Task TestSendAsyncCanSupportsDeleteHeadTraceOptionsRequest(string httpVerb, string requestUrl) + { + var httpMethod = new HttpMethod(httpVerb); + var httpRequest = new HttpRequestMessage(httpMethod, requestUrl); + var response = await client.SendAsync(httpRequest); + + httpTest.ShouldHaveCalled(requestUrl) + .WithVerb(httpMethod) + .Times(1); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + + private class TestData + { + public int id { get; set; } + public string name { get; set; } + } + #endregion } } \ No newline at end of file diff --git a/MetasysServices/Interfaces/IMetasysClient.cs b/MetasysServices/Interfaces/IMetasysClient.cs index bad8010..4f565d2 100644 --- a/MetasysServices/Interfaces/IMetasysClient.cs +++ b/MetasysServices/Interfaces/IMetasysClient.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using JohnsonControls.Metasys.BasicServices; @@ -404,5 +406,17 @@ public interface IMetasysClient :IBasicService /// Task GetServerTimeAsync(); + /// + /// Send an HTTP request as an asynchronous operation. + /// + /// + /// This method currently only supports 1 value per header rather than multiple. In a future revision, this is planned to be addressed. + /// + /// + /// The HTTP request message to send. + /// When the operation should complete (as soon as a response is available or after reading the whole response content). + /// The cancellation token to cancel operation. + /// The task object representing the asynchronous operation. + Task SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default); } } diff --git a/MetasysServices/MetasysClient.cs b/MetasysServices/MetasysClient.cs index 1de7b4f..5d68556 100644 --- a/MetasysServices/MetasysClient.cs +++ b/MetasysServices/MetasysClient.cs @@ -17,6 +17,8 @@ using System.Dynamic; using System.IdentityModel.Tokens.Jwt; using System.Timers; +using System.Net.Http.Headers; +using System.Threading; namespace JohnsonControls.Metasys.BasicServices { @@ -1207,7 +1209,49 @@ private async Task SendCommandRequestAsync(Guid id, string command, IEnumerable< } } + #region "ad-hoc calls" // ========================================================================================================= + // Support Ad-Hoc calls ----------------------------------------------------------------------------------------------------------- + /// + public async Task SendAsync(HttpRequestMessage requestMessage, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default) + { + var response = new HttpResponseMessage(); + try + { + var flurlRequest = Client.Request(); + flurlRequest.Url = GetUrlFromHttpRequest(requestMessage); + + // Flurl.Http 2.4.2 can only work with 1 value per header + // Once upgraded to Flurl 3.0.1, then multiple values can be supported + var headers = requestMessage.Headers.ToDictionary((kvp) => kvp.Key, (kvp) => kvp.Value.First()); + flurlRequest.WithHeaders(headers); + response = await flurlRequest.SendAsync(requestMessage.Method, requestMessage.Content, cancellationToken, completionOption).ConfigureAwait(false); + } + catch (FlurlHttpException e) + { + ThrowHttpException(e); + } + return response; + } + + private Url GetUrlFromHttpRequest(HttpRequestMessage requestMessage) + { + var baseUri = new Uri(Client.BaseUrl); + var requestUri = requestMessage.RequestUri.ToString(); + if (Uri.IsWellFormedUriString(requestUri, UriKind.Absolute)) + { + if (Uri.Compare(baseUri, requestMessage.RequestUri, UriComponents.SchemeAndServer, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) != 0) + { + throw new UriFormatException("HTTP request can not be made. Scheme or Host is invalid."); + } + return new Url(requestUri); + } + else + { + return new Url(Url.Combine(baseUri.GetLeftPart(UriPartial.Authority), "/api/", requestUri)); + } + } + #endregion } } diff --git a/MetasysServicesExampleApp/FeaturesDemo/AdHocCallsDemo.cs b/MetasysServicesExampleApp/FeaturesDemo/AdHocCallsDemo.cs new file mode 100644 index 0000000..e1cee85 --- /dev/null +++ b/MetasysServicesExampleApp/FeaturesDemo/AdHocCallsDemo.cs @@ -0,0 +1,49 @@ +using JohnsonControls.Metasys.BasicServices; +using System; +using System.Net.Http; + +namespace MetasysServicesExampleApp.FeaturesDemo +{ + public class AdHocCallsDemo + { + private MetasysClient client; + private LogInitializer log; + + public AdHocCallsDemo(MetasysClient client) + { + this.client = client; + log = new LogInitializer(typeof(AdHocCallsDemo)); + } + + public void Run() + { + try + { + Console.WriteLine("\nEnter the endpoint you want to make the request to (Example: \"https://hostname/api/v5/networkDevices?sort=itemReference\"): ."); + string endpoint = Console.ReadLine(); + Console.WriteLine($"\nMaking the call to {endpoint}..."); + HttpRequestMessage httpRequest = new HttpRequestMessage(HttpMethod.Get, endpoint); + + var response = client.SendAsync(httpRequest).Result; + var statusCode = ((int)response.StatusCode).ToString(); + if (statusCode.StartsWith("2") || statusCode.StartsWith("3")) + { + Console.WriteLine($"\n \nStatus - {response.StatusCode}"); + Console.WriteLine("\nResponse content - "); + var content = response.Content.ReadAsStringAsync().Result; + Console.WriteLine("\n \n" + content); + } + else + { + Console.WriteLine(string.Format("\n \nRequest failed with - {0}", response.StatusCode)); + } + } + catch (Exception exception) + { + log.Logger.Error(string.Format("An error occured while making the request - {0}", exception.Message)); + Console.WriteLine("\n \nAn Error occurred. Press Enter to return to Main Menu"); + } + Console.ReadLine(); + } + } +} diff --git a/MetasysServicesExampleApp/Program.cs b/MetasysServicesExampleApp/Program.cs index 45133e3..634c259 100644 --- a/MetasysServicesExampleApp/Program.cs +++ b/MetasysServicesExampleApp/Program.cs @@ -20,7 +20,7 @@ static void Main(string[] args) connectionDetails = Console.ReadLine(); args = connectionDetails.Split(' '); } - string username=null, password= null, hostname= null, credManTarget=null; string version = null; + string username = null, password = null, hostname = null, credManTarget = null; string version = null; if (args.Length > 3) { @@ -42,7 +42,7 @@ static void Main(string[] args) Console.WriteLine("\nLogging in..."); var apiVersion = (ApiVersion)Enum.Parse(typeof(ApiVersion), version); - var client = new MetasysClient(hostname,true,apiVersion,logClientErrors:false); // Disable default logging since it is handled in this app. + var client = new MetasysClient(hostname, true, apiVersion, logClientErrors: false); // Disable default logging since it is handled in this app. // var client = new MetasysClient(hostname, true); // Ignore Certificate Errors // var client = new MetasysClient(hostname, false, ApiVersion.v2, culture); @@ -65,12 +65,12 @@ static void Main(string[] args) showMenu = MainMenu(client); } } - catch(Exception exception) + catch (Exception exception) { log.Logger.Error(string.Format("An error occured while login - {0}", exception.Message)); Console.WriteLine("\n \nAn Error occurred. Press Enter to exit"); Console.ReadLine(); - } + } } private static bool MainMenu(MetasysClient client) @@ -86,16 +86,17 @@ private static bool MainMenu(MetasysClient client) Console.WriteLine("7) Trends"); Console.WriteLine("8) Audits"); Console.WriteLine("9) JSON Output"); - Console.WriteLine("10) Exit"); + Console.WriteLine("10) Ad-hoc Calls"); + Console.WriteLine("11) Exit"); Console.Write("\r\nSelect an option: "); - var option = "9";// Console.ReadLine(); + var option = "10";// Console.ReadLine(); Console.WriteLine(); switch (option) { - case "1": + case "1": new GetObjectIdentifierDemo(client).Run(); return true; - case "2": + case "2": new GeneralDemo(client).Run(); return true; case "3": @@ -120,6 +121,7 @@ private static bool MainMenu(MetasysClient client) new JsonOutputDemo(client).Run(); return false; case "10": + new AdHocCallsDemo(client).Run(); return false; default: return true;