Skip to content

Commit

Permalink
[SignalR] Add Strongly typed serverless hub (#25075)
Browse files Browse the repository at this point in the history
# All SDK Contribution checklist:

This checklist is used to make sure that common guidelines for a pull request are followed.
- [x] **Please open PR in `Draft` mode if it is:**
	- Work in progress or not intended to be merged.
	- Encountering multiple pipeline failures and working on fixes.
- [ ] If an SDK is being regenerated based on a new swagger spec, a link to the pull request containing these swagger spec changes has been included above.
- [x] **I have read the [contribution guidelines](https://github.com/Azure/azure-sdk-for-net/blob/main/CONTRIBUTING.md).**
- [x] **The pull request does not introduce [breaking changes](https://github.com/dotnet/corefx/blob/master/Documentation/coding-guidelines/breaking-change-rules.md).**

### [General Guidelines and Best Practices](https://github.com/Azure/azure-sdk-for-net/blob/main/CONTRIBUTING.md#general-guidelines)
- [x] Title of the pull request is clear and informative.
- [x] There are a small number of commits, each of which have an informative message. This means that previously merged commits do not appear in the history of the PR. For more information on cleaning up the commits in your PR, [see this page](https://github.com/Azure/azure-powershell/blob/master/documentation/development-docs/cleaning-up-commits.md).

### [Testing Guidelines](https://github.com/Azure/azure-sdk-for-net/blob/main/CONTRIBUTING.md#testing-guidelines)
- [x] Pull request includes test coverage for the included changes.
  • Loading branch information
Y-Sindo authored Nov 17, 2021
1 parent fdc5bbf commit e24341e
Show file tree
Hide file tree
Showing 16 changed files with 400 additions and 10 deletions.
6 changes: 3 additions & 3 deletions eng/Packages.Data.props
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@
<PackageReference Update="CloudNative.CloudEvents" Version="2.0.0" />
<PackageReference Update="CloudNative.CloudEvents.SystemTextJson" Version="2.0.0" />
<PackageReference Update="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="3.0.0" Condition="'$(TargetFramework)' == 'netcoreapp3.1'" />
<PackageReference Update="Microsoft.Azure.SignalR" Version="1.12.0" />
<PackageReference Update="Microsoft.Azure.SignalR.Management" Version="1.12.0" />
<PackageReference Update="Microsoft.Azure.SignalR.Protocols" Version="1.12.0" />
<PackageReference Update="Microsoft.Azure.SignalR" Version="1.13.0" />
<PackageReference Update="Microsoft.Azure.SignalR.Management" Version="1.13.0" />
<PackageReference Update="Microsoft.Azure.SignalR.Protocols" Version="1.13.0" />
<PackageReference Update="Microsoft.Azure.SignalR.Serverless.Protocols" Version="1.6.0" />
<PackageReference Update="Microsoft.Azure.WebJobs" Version="3.0.30" />
<PackageReference Update="Microsoft.Azure.WebJobs.Sources" Version="3.0.30" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ public SignalRConnectionAttribute(string connectionStringSetting) { }
public string Connection { get { throw null; } set { } }
}
}
public abstract partial class ServerlessHub<T> where T : class
{
protected ServerlessHub(Microsoft.Azure.SignalR.Management.ServiceHubContext<T> serviceHubContext = null) { }
public Microsoft.Azure.SignalR.Management.ClientManager ClientManager { get { throw null; } }
public Microsoft.AspNetCore.SignalR.IHubClients<T> Clients { get { throw null; } }
public Microsoft.Azure.SignalR.Management.GroupManager Groups { get { throw null; } }
public Microsoft.Azure.SignalR.Management.UserGroupManager UserGroups { get { throw null; } }
protected static System.Collections.Generic.IList<System.Security.Claims.Claim> GetClaims(string jwt) { throw null; }
protected System.Threading.Tasks.Task<Microsoft.Azure.WebJobs.Extensions.SignalRService.SignalRConnectionInfo> NegotiateAsync(Microsoft.Azure.SignalR.Management.NegotiationOptions options) { throw null; }
}
public partial class SignalRAsyncCollector<T> : Microsoft.Azure.WebJobs.IAsyncCollector<T>
{
internal SignalRAsyncCollector() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ public SignalRConnectionAttribute(string connectionStringSetting) { }
public string Connection { get { throw null; } set { } }
}
}
public abstract partial class ServerlessHub<T> where T : class
{
protected ServerlessHub(Microsoft.Azure.SignalR.Management.ServiceHubContext<T> serviceHubContext = null) { }
public Microsoft.Azure.SignalR.Management.ClientManager ClientManager { get { throw null; } }
public Microsoft.AspNetCore.SignalR.IHubClients<T> Clients { get { throw null; } }
public Microsoft.Azure.SignalR.Management.GroupManager Groups { get { throw null; } }
public Microsoft.Azure.SignalR.Management.UserGroupManager UserGroups { get { throw null; } }
protected static System.Collections.Generic.IList<System.Security.Claims.Claim> GetClaims(string jwt) { throw null; }
protected System.Threading.Tasks.Task<Microsoft.Azure.WebJobs.Extensions.SignalRService.SignalRConnectionInfo> NegotiateAsync(Microsoft.Azure.SignalR.Management.NegotiationOptions options) { throw null; }
}
public partial class SignalRAsyncCollector<T> : Microsoft.Azure.WebJobs.IAsyncCollector<T>
{
internal SignalRAsyncCollector() { }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Strongly Typed Serverless Hub

Strongly typed serverless hub is a programming model which allows you to define your SignalR client methods in an interface, and the RPC implementation will be done by SignalR.

This sample demonstrates how to create a strongly typed serverless hub and invoke SignalR client methods in it. To see more details on serverless hub, please go [here](https://docs.microsoft.com/azure/azure-signalr/signalr-concept-serverless-development-config#class-based-model).

## Define a strongly typed serverless hub class

Let's say you want to invoke a SignalR client method `ReceiveMessage` with a string parameter when a HTTP request comes.

Firstly you need to define an interface for the client method.

```C# Snippet:StronglyTypedHub_ClientMethodInterface
public interface IChatClient
{
Task ReceiveMessage(string message);
}
```

Then you creates a strongly typed hub with the interface:

```C# Snippet:StronglyTypedHub
public class StronglyTypedHub : ServerlessHub<IChatClient>
{
[FunctionName(nameof(Broadcast))]
public async Task Broadcast([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest _, string message)
{
await Clients.All.ReceiveMessage(message);
}
}
```

## Call client methods

The code snippet above defines a method in the hub, which broadcasts the message to all the clients once triggered by HTTP request.
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Threading.Tasks;
using Microsoft.Azure.SignalR;
using Microsoft.Azure.SignalR.Management;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService
{
internal interface IInternalServiceHubContextStore : IServiceHubContextStore
{
AccessKey[] AccessKeys { get; }

ValueTask<ServiceHubContext<T>> GetAsync<T>(string hubName) where T : class;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,26 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService
internal class ServiceHubContextStore : IInternalServiceHubContextStore
{
private readonly ConcurrentDictionary<string, (Lazy<Task<IServiceHubContext>> Lazy, IServiceHubContext Value)> _store = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, Lazy<Task<object>>> _stronglyTypedStore = new(StringComparer.OrdinalIgnoreCase);
private readonly IServiceEndpointManager _endpointManager;

public IServiceManager ServiceManager { get; }
private readonly ServiceManager _serviceManager;

public AccessKey[] AccessKeys => _endpointManager.Endpoints.Keys.Select(endpoint => endpoint.AccessKey).ToArray();

public ServiceHubContextStore(IServiceEndpointManager endpointManager, IServiceManager serviceManager)
public IServiceManager ServiceManager => _serviceManager as IServiceManager;

public ServiceHubContextStore(IServiceEndpointManager endpointManager, ServiceManager serviceManager)
{
_serviceManager = serviceManager;
_endpointManager = endpointManager;
ServiceManager = serviceManager;
}

public async ValueTask<ServiceHubContext<T>> GetAsync<T>(string hubName) where T : class
{
// The GetAsync for strongly typed hub is more simple than that for weak typed hub, as it removes codes to handle transient errors. The creation of service hub context should not contain transient errors.
var lazy = _stronglyTypedStore.GetOrAdd(hubName, new Lazy<Task<object>>(async () => await _serviceManager.CreateHubContextAsync<T>(hubName, default).ConfigureAwait(false)));
var hubContext = await lazy.Value.ConfigureAwait(false);
return (ServiceHubContext<T>)hubContext;
}

public ValueTask<IServiceHubContext> GetAsync(string hubName)
Expand Down Expand Up @@ -55,6 +65,7 @@ private async Task<IServiceHubContext> GetFromLazyAsync(string hubName, (Lazy<Ta
}
catch (Exception)
{
// Allow to retry for transient errors.
_store.TryRemove(hubName, out _);
throw;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ private IInternalServiceHubContextStore CreateHubContextStore(string connectionS
}
})
.AddSignalRServiceManager()
.AddSingleton(sp => (ServiceManager)sp.GetService<IServiceManager>())
.AddSingleton(_loggerFactory)
.AddSingleton<IInternalServiceHubContextStore, ServiceHubContextStore>();
if (_router != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Version>1.7.0-beta.1</Version>
<SignAssembly>true</SignAssembly>
<IsExtensionClientLibrary>true</IsExtensionClientLibrary>
<NoWarn>$(NoWarn);CS1591;AZC0001</NoWarn>
<NoWarn>$(NoWarn);CS1591;AZC0001;AZC0107;</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand All @@ -18,7 +18,7 @@
<PackageReference Include="Microsoft.Azure.SignalR.Protocols" />
<PackageReference Include="Microsoft.Azure.SignalR.Serverless.Protocols" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.1'">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Azure.Core.Pipeline;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Azure.SignalR.Management;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService
{
[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "https://github.com/Azure/azure-sdk-for-net/issues/17164")]
public abstract class ServerlessHub<T> where T : class
{
private static readonly Lazy<JwtSecurityTokenHandler> JwtSecurityTokenHandler = new(() => new JwtSecurityTokenHandler());
private readonly ServiceHubContext<T> _hubContext;

/// <summary>
/// Gets an object that can be used to invoke methods on the clients connected to this hub.
/// </summary>
public IHubClients<T> Clients => _hubContext.Clients;

/// <summary>
/// Get the group manager of this hub.
/// </summary>
public GroupManager Groups => _hubContext.Groups;

/// <summary>
/// Get the user group manager of this hub.
/// </summary>
public UserGroupManager UserGroups => _hubContext.UserGroups;

/// <summary>
/// Get the client manager of this hub.
/// </summary>
public ClientManager ClientManager => _hubContext.ClientManager;

protected ServerlessHub(ServiceHubContext<T> serviceHubContext = null)
{
if (serviceHubContext is null)
{
serviceHubContext = ((IInternalServiceHubContextStore)StaticServiceHubContextStore.Get()).GetAsync<T>(GetType().Name).Result;
}
_hubContext = serviceHubContext;
}

/// <summary>
/// Gets client endpoint access information object for SignalR hub connections to connect to Azure SignalR Service
/// </summary>
protected async Task<SignalRConnectionInfo> NegotiateAsync(NegotiationOptions options)
{
var negotiateResponse = await _hubContext.NegotiateAsync(options).ConfigureAwait(false);
return new SignalRConnectionInfo
{
Url = negotiateResponse.Url,
AccessToken = negotiateResponse.AccessToken
};
}

/// <summary>
/// Get claim list from a JWT.
/// </summary>
protected static IList<Claim> GetClaims(string jwt)
{
if (jwt.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
jwt = jwt.Substring("Bearer ".Length).Trim();
}
return JwtSecurityTokenHandler.Value.ReadJwtToken(jwt).Claims.ToList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ internal SignalRTriggerAttribute GetParameterResolvedAttribute(SignalRTriggerAtt
var declaredType = method.DeclaringType;
string[] parameterNamesFromAttribute;

if (declaredType != null && declaredType.IsSubclassOf(typeof(ServerlessHub)))
if (declaredType != null && IsServerlessHub(declaredType))
{
// Class based model
if (!string.IsNullOrEmpty(hubName) ||
Expand Down Expand Up @@ -195,5 +195,27 @@ private static bool HasBindingAttribute(IEnumerable<Attribute> attributes)
{
return attributes.Any(attribute => attribute.GetType().GetCustomAttribute<BindingAttribute>(false) != null);
}

private static bool IsServerlessHub(Type type)
{
if (type == null)
{
return false;
}
if (type.IsSubclassOf(typeof(ServerlessHub)))
{
return true;
}
var baseType = type.BaseType;
while (baseType != null)
{
if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(ServerlessHub<>))
{
return true;
}
baseType = baseType.BaseType;
}
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Extensions.Http;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Samples
{
#region Snippet:StronglyTypedHub
public class StronglyTypedHub : ServerlessHub<IChatClient>
{
[FunctionName(nameof(Broadcast))]
public async Task Broadcast([HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest _, string message)
{
await Clients.All.ReceiveMessage(message);
}
}
#endregion

#region Snippet:StronglyTypedHub_ClientMethodInterface
public interface IChatClient
{
Task ReceiveMessage(string message);
}
#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Threading.Tasks;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests
{
public interface IChatClient
{
Task ReceiveMessage(string message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Linq;
using Microsoft.Azure.SignalR.Management;
using Microsoft.Azure.SignalR.Tests.Common;
using Xunit;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Trigger.StronglyTypedHub
{
public class StronglyTypedHubContextStoreTests
{
[Fact]
public void GetStronglyTypedHubContextFact()
{
var serviceManager = new ServiceManagerBuilder().WithOptions(o => o.ConnectionString = FakeEndpointUtils.GetFakeConnectionString(1).Single()).BuildServiceManager();
var hubContext = new ServiceHubContextStore(null, serviceManager).GetAsync<IChatClient>(GetType().Name).Result;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Linq;
using System.Threading.Tasks;
using Microsoft.Azure.SignalR.Management;
using Microsoft.Azure.SignalR.Tests.Common;
using Xunit;

namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests
{
public class StronglyTypedServerlessHubTests
{
[Fact]
public async Task NegotiateAsync()
{
var serviceManager = new ServiceManagerBuilder().WithOptions(o => o.ConnectionString = FakeEndpointUtils.GetFakeConnectionString(1).Single()).BuildServiceManager();
var hubContext = await serviceManager.CreateHubContextAsync<IChatClient>("hubName", default);
var myHub = new TestStronglyTypedHub(hubContext);
var connectionInfo = await myHub.Negotiate("user");
Assert.NotNull(connectionInfo);
}
}
}
Loading

0 comments on commit e24341e

Please sign in to comment.