Skip to content

Commit

Permalink
feat: support ad-hoc calls (#129)
Browse files Browse the repository at this point in the history
* feat: support ad-hoc calls

* fix: code review comments
1. used Url.Combine to form new URL from base URL and relative URL
2. added more test cases

* fix: select one value for each HTTP header

* fix: added test cases for supported HTTP methods

* tests: assert content in the request
  • Loading branch information
nagtilak authored Feb 1, 2023
1 parent 6febe25 commit f3b8555
Show file tree
Hide file tree
Showing 5 changed files with 324 additions and 11 deletions.
210 changes: 207 additions & 3 deletions MetasysServices.Tests/MetasysClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<UriFormatException>(() =>
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<TestData>();

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
}
}
14 changes: 14 additions & 0 deletions MetasysServices/Interfaces/IMetasysClient.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -404,5 +406,17 @@ public interface IMetasysClient :IBasicService
/// <inheritdoc cref="IMetasysClient.GetServerTime()"/>
Task<DateTime> GetServerTimeAsync();

/// <summary>
/// Send an HTTP request as an asynchronous operation.
///
/// <para>
/// This method currently only supports 1 value per header rather than multiple. In a future revision, this is planned to be addressed.
/// </para>
/// </summary>
/// <param name="request">The HTTP request message to send.</param>
/// <param name="completionOption"> When the operation should complete (as soon as a response is available or after reading the whole response content).</param>
/// <param name="cancellationToken">The cancellation token to cancel operation.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, CancellationToken cancellationToken = default);
}
}
44 changes: 44 additions & 0 deletions MetasysServices/MetasysClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -1207,7 +1209,49 @@ private async Task SendCommandRequestAsync(Guid id, string command, IEnumerable<
}
}

#region "ad-hoc calls" // =========================================================================================================
// Support Ad-Hoc calls -----------------------------------------------------------------------------------------------------------
///<inheritdoc/>
public async Task<HttpResponseMessage> 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
}

}
Expand Down
49 changes: 49 additions & 0 deletions MetasysServicesExampleApp/FeaturesDemo/AdHocCallsDemo.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading

0 comments on commit f3b8555

Please sign in to comment.