Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/Microsoft.Azure.SignalR.Common/Utilities/RestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public RestClient(IHttpClientFactory httpClientFactory, IPayloadContentBuilder c
_httpClientFactory = httpClientFactory;
_payloadContentBuilder = contentBuilder;
}

// TODO: Test only, will remove later
internal RestClient(IHttpClientFactory httpClientFactory) : this(httpClientFactory, new JsonPayloadContentBuilder(new JsonObjectSerializer()))
{
Expand Down Expand Up @@ -78,6 +78,17 @@ public Task SendMessageWithRetryAsync(
return SendAsyncCore(Constants.HttpClientNames.MessageResilient, api, httpMethod, new InvocationMessage(methodName, args), null, AsAsync(handleExpectedResponse), cancellationToken);
}

public Task SendMessageWithRetryAsync(
RestApiEndpoint api,
HttpMethod httpMethod,
string methodName,
object?[] args,
Func<HttpResponseMessage, Task<bool>>? handleExpectedResponseAsync = null,
CancellationToken cancellationToken = default)
{
return SendAsyncCore(Constants.HttpClientNames.MessageResilient, api, httpMethod, new InvocationMessage(methodName, args), null, handleExpectedResponseAsync, cancellationToken);
}

public Task SendStreamMessageWithRetryAsync(
RestApiEndpoint api,
HttpMethod httpMethod,
Expand Down
5 changes: 5 additions & 0 deletions src/Microsoft.Azure.SignalR.Management/RestApiProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,9 @@ private RestApiEndpoint GenerateRestApiEndpoint(string appName, string hubName,
: $"{pathAfterHub}?application={Uri.EscapeDataString(appName.ToLowerInvariant())}&api-version={Version}";
return new RestApiEndpoint($"{requestPrefixWithHub}{pathAfterHub}") { Query = queries };
}

public RestApiEndpoint SendClientInvocation(string appName, string hubName, string connectionId)
{
return GenerateRestApiEndpoint(appName, hubName, $"/connections/{Uri.EscapeDataString(connectionId)}/:invoke");
}
}
68 changes: 68 additions & 0 deletions src/Microsoft.Azure.SignalR.Management/RestHubLifetimeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
using System.Linq;
using System.Net;
using System.Net.Http;
#if NET7_0_OR_GREATER
using System.Text.Json;
using System.Text.Json.Nodes;
#endif
using System.Threading;
using System.Threading.Tasks;

using Azure;

using Microsoft.AspNetCore.SignalR;
#if NET7_0_OR_GREATER
using Microsoft.AspNetCore.SignalR.Protocol;
#endif
using Microsoft.Azure.SignalR.Protocol;
using Microsoft.Extensions.Primitives;

Expand Down Expand Up @@ -351,6 +358,67 @@ public async Task SendStreamCompletionAsync(string connectionId, string streamId
await _restClient.SendWithRetryAsync(api, HttpMethod.Post, cancellationToken: cancellationToken);
}

#if NET7_0_OR_GREATER
#nullable enable
public override async Task<T> InvokeConnectionAsync<T>(string connectionId, string methodName, object?[] args, CancellationToken cancellationToken = default)
{
// Validate input parameters
if (string.IsNullOrEmpty(methodName))
{
throw new ArgumentException(NullOrEmptyStringErrorMessage, nameof(methodName));
}
if (string.IsNullOrEmpty(connectionId))
{
throw new ArgumentException(NullOrEmptyStringErrorMessage, nameof(connectionId));
}

// Get API endpoint and prepare for the request
var api = _restApiProvider.SendClientInvocation(_appName, _hubName, connectionId);
string? responseContent = null;
var isSuccessStatusCode = false;
// Send request and capture the response
await _restClient.SendMessageWithRetryAsync(
api,
HttpMethod.Post,
methodName,
args,
async response =>
{
responseContent = await response.Content.ReadAsStringAsync();
isSuccessStatusCode = response.IsSuccessStatusCode;
return response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.BadRequest;
},
cancellationToken: cancellationToken);

// Ensure we have a response
if (string.IsNullOrWhiteSpace(responseContent))
{
throw new HubException("Response content is null or empty");
}

if (!isSuccessStatusCode)
{
throw new HubException(responseContent);
}

var root = JsonNode.Parse(responseContent)
?? throw new HubException("Failed to parse response as JSON");

var resultNode = root["result"]
?? throw new HubException("Result not found in JSON response");

return resultNode.Deserialize<T>()
?? throw new HubException("Failed to deserialize result");
}

public override Task SetConnectionResultAsync(string connectionId, CompletionMessage result)
{
// This method won't get trigger because in transient we will wait for the returned completion message.
// this is to honor the interface
throw new NotImplementedException();
}
#endif

private static bool FilterExpectedResponse(HttpResponseMessage response, string expectedErrorCode) =>
response.IsSuccessStatusCode
|| (response.StatusCode == HttpStatusCode.NotFound && response.Headers.TryGetValues(Headers.MicrosoftErrorCode, out var errorCodes) && errorCodes.First().Equals(expectedErrorCode, StringComparison.OrdinalIgnoreCase));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Azure.SignalR.Common;
using Microsoft.Azure.SignalR.Tests.Common;
using Moq;
using Moq.Protected;
using Xunit;

#nullable enable

namespace Microsoft.Azure.SignalR.Management.Tests
{
public class RestHubLifetimeManagerFacts
{
#if NET7_0_OR_GREATER
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly HttpClient _httpClient;
private readonly string _hubName = "TestHub";
private readonly string _appName = "TestApp";
private readonly RestHubLifetimeManager<TestHub> _manager;

private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;

public RestHubLifetimeManagerFacts()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();

_httpClient = new HttpClient(_httpMessageHandlerMock.Object);

_httpClientFactoryMock = new Mock<IHttpClientFactory>();
_httpClientFactoryMock
.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(_httpClient);

var restClient = new RestClient(_httpClientFactoryMock.Object);

_manager = new RestHubLifetimeManager<TestHub>(
_hubName,
new(FakeEndpointUtils.GetFakeConnectionString(1).First()),
_appName,
restClient
);
}

[Fact]
public async Task InvokeConnectionAsync_NullMethodName_ThrowsArgumentException()
{
string? methodName = null;
var connectionId = "connection1";
var args = Array.Empty<object>();

var exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName!, args));

Assert.Equal("methodName", exception.ParamName);

methodName = "";
exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args));
Assert.Equal("methodName", exception.ParamName);
}

[Fact]
public async Task InvokeConnectionAsync_NullConnectionId_ThrowsArgumentException()
{
var methodName = "testMethod";
string? connectionId = null;
var args = Array.Empty<object>();

var exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId!, methodName, args));

Assert.Equal("connectionId", exception.ParamName);

connectionId = "";
exception = await Assert.ThrowsAsync<ArgumentException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args));
Assert.Equal("connectionId", exception.ParamName);
}

[Fact]
public async Task InvokeConnectionAsync_WithStringResult_ReturnsDeserializedValue()
{
// Arrange
var connectionId = "connection1";
var methodName = "getUsername";
var args = new object?[] { 42, "test-param", true };
var expectedResult = "John Doe";

var jsonResponse = $"{{\"result\":\"{expectedResult}\"}}";

_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(jsonResponse)
});

// Act
var result = await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args);

// Assert
Assert.Equal(expectedResult, result);
}

[Fact]
public async Task InvokeConnectionAsync_WithComplexObjectResult_ReturnsDeserializedObject()
{
// Arrange
var connectionId = "connection1";
var methodName = "getUserProfile";
var args = new object?[] { "userId123", new { filter = "personal" } };

var jsonResponse = @"{""result"":{""id"":123,""name"":""Jane Doe"",""active"":true,""roles"":[""user"",""admin""]}}";

_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(jsonResponse)
});

// Act
var result = await _manager.InvokeConnectionAsync<UserProfile>(connectionId, methodName, args);

// Assert
Assert.NotNull(result);
Assert.Equal(123, result.id);
Assert.Equal("Jane Doe", result.name);
Assert.True(result.active);
Assert.Equal(2, result.roles.Length);
Assert.Contains("admin", result.roles);
}

[Fact]
public async Task InvokeConnectionAsync_WithErrorResponse_ThrowsHubException()
{
// Arrange
var connectionId = "connection1";
var methodName = "getError";
var args = Array.Empty<object>();
var errorMessage = "Connection does not exist";

_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(errorMessage)
});

// Act & Assert
var exception = await Assert.ThrowsAsync<AzureSignalRInaccessibleEndpointException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args));
}

[Fact]
public async Task InvokeConnectionAsync_WithMissingResultNode_ThrowsHubException()
{
// Arrange
var connectionId = "connection1";
var methodName = "getIncompleteData";
var args = Array.Empty<object>();

// JSON missing the required result node
var incompleteJsonResponse = "{\"jsonObject\":{}}";

_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(incompleteJsonResponse)
});

// Act & Assert
var exception = await Assert.ThrowsAsync<HubException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args));

Assert.Contains("Result not found in JSON response", exception.Message);
}

[Fact]
public async Task InvokeConnectionAsync_WithMissingJsonObjectNode_ThrowsHubException()
{
// Arrange
var connectionId = "connection1";
var methodName = "getIncompleteData";
var args = Array.Empty<object>();

// JSON missing the required jsonObject node
var incompleteJsonResponse = "{}";

_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(incompleteJsonResponse)
});

// Act & Assert
var exception = await Assert.ThrowsAsync<HubException>(
async () => await _manager.InvokeConnectionAsync<string>(connectionId, methodName, args));

Assert.Contains("Result not found in JSON response", exception.Message);
}
#endif

public class TestHub : Hub { }

public class UserProfile
{
public int id { get; set; }
public string name { get; set; } = string.Empty;
public bool active { get; set; }
public string[] roles { get; set; } = Array.Empty<string>();
}
}
}
Loading