Implementing Azure SignalR

Client – Configuring Connection Data

Here’s the URL and target id that the client uses for messaging using SignalR:

namespace MessagingClient
{
    public static class Constants
    {
        public static string HostName { get; set; } = @"MY_AZURE_FUNCTION_URL";

        public static string CourierId { get; set; } = "some_courier_id";
    }
}

Client – Establishing Connection

Here’s the client’s SignalR service implementation:

using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Specification;

namespace MessagingClient
{
    public class SignalRService
    {
        HttpClient client;

        public delegate void MessageReceivedHandler(object sender, CourierLocation message);
        public delegate void ConnectionHandler(object sender, bool successful, string message);

        public event MessageReceivedHandler MessageReceived;
        public event ConnectionHandler      Connected;
        public event ConnectionHandler      ConnectionFailed;

        public bool IsConnected { get; private set; }
        public bool IsBusy { get; private set; }

        public SignalRService() => client = new HttpClient();

        public async Task SendAsync(CourierLocation location)
        {
            IsBusy = true;

            var json    = JsonConvert.SerializeObject(location);
            var content = new StringContent(json, Encoding.UTF8, "application/json");
            var result  = await client.PostAsync($"{Constants.HostName}/api/locationfn", content);

            if (!result.IsSuccessStatusCode)
            {
                throw new Exception("Sending message failed");
            }

            IsBusy = false;
        }

        public async Task ConnectAsync()
        {
            try
            {
                IsBusy = true;

                var negotiateJson = await client.GetStringAsync($"{Constants.HostName}/api/negotiate");
                var negotiate     = JsonConvert.DeserializeObject(negotiateJson);

                var connection = new HubConnectionBuilder()
                    .AddNewtonsoftJsonProtocol()
                    .WithUrl(negotiate.Url, options => options.AccessTokenProvider = async () => negotiate.AccessToken)
                    .Build();

                connection.Closed += Connection_Closed;
                connection.On(Constants.CourierId, OnIncomingMessage);
                await connection.StartAsync();

                IsConnected = true;
                IsBusy      = false;

                Connected?.Invoke(this, true, "Connection successful.");
            }
            catch (Exception ex)
            {
                ConnectionFailed?.Invoke(this, false, ex.Message);
                IsConnected = false;
                IsBusy = false;
            }
        }

        Task Connection_Closed(Exception arg)
        {
            ConnectionFailed?.Invoke(this, false, arg.Message);
            IsConnected = false;
            IsBusy      = false;
            return Task.CompletedTask;
        }

        void OnIncomingMessage(JObject message)
        {
            var courierId = message.GetValue("CourierId").ToString();
            var location  = message.SelectToken("Location");
            var latitude  = double.Parse (location.SelectToken("Latitude").ToString());
            var longitude = double.Parse (location.SelectToken("Longitude").ToString());

            var courierLocation = new CourierLocation(courierId, new Coordinate(latitude,longitude));
            MessageReceived?.Invoke(this, courierLocation);
        }
    }
}

The ConnectAsync method above registers the identifier that the SignalR Hub (on the server) will route messages to. In this case, the identifier is the courier id.

Here’s the line:

connection.On(Constants.CourierId, OnIncomingMessage);

Azure Function – Connection

Here’s the Azure Function for negotiating a SignalR connection:

using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;

namespace MessagingServer
{
    public static class Negotiate
    {
        [FunctionName("Negotiate")]
        public static SignalRConnectionInfo GetSignalRInfo(
            [HttpTrigger(AuthorizationLevel.Anonymous,"get",Route = "negotiate")]
            HttpRequest req,
            [SignalRConnectionInfo(HubName = "LocationHub")]
            SignalRConnectionInfo connectionInfo)
        {
            return connectionInfo;
        }
    }
}

Azure Function – Location

Here’s the Azure Function that first receives and then forwards a message to a SignalR client:

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Specification;

namespace MessagingServer
{
    public static class Location
    {
        [FunctionName(nameof(Location))]
        public static async Task Run(
            [HttpTrigger(
                AuthorizationLevel.Anonymous,
                "post",
                Route = "locationfn")]
            HttpRequest req,
            [SignalR(HubName = "LocationHub")]
            IAsyncCollector questionR,
            ILogger log)
        {
            log.LogInformation($"{nameof(Location)} has been invoked.");

            try
            {
                var json = await new StreamReader(req.Body).ReadToEndAsync();
                var courierLocation = JsonConvert.DeserializeObject(json);

                await questionR.AddAsync(
                    new SignalRMessage
                    {
                        Target    = courierLocation.CourierId,
                        Arguments = new[] { courierLocation }
                    });

                var location  = courierLocation.Location;
                var latitude  = location.Latitude;
                var longitude = location.Longitude;

                var message = $"Hello {courierLocation.CourierId}, your location was '({latitude},{longitude})'";
                log.LogInformation($"{nameof(Location)} returned: {message}");

                return new OkObjectResult(message);
            }
            catch (Exception ex)
            {
                return new BadRequestObjectResult("There was an error: " + ex.Message);
            }
        }
    }
}

The code above uses the target id to route messages. In this case, the target is the courier-id. The courier-id registered on the server will be matched with the courier-id on the client app when establishing the connection.

Here’s the snippet from the Azure Function above:

await questionR.AddAsync(
    new SignalRMessage
    {
        Target    = courierLocation.CourierId,
        Arguments = new[] { courierLocation }
    });

Automated Test – Sending a Location Message

Here’s the test that establishes a connection and then sends a location for another client to receive:

module _SignalR

open Xunit
open Specification.Core.DataTransfer
open SignalR.Support

let onConnectionChanged (_,_) = ()
let onMessageReceived _ = ()

let someCoordinate      = { Latitude=30.0; Longitude=50.0 }
let someCourierLocation = { CourierId= "some_courier_id"; Location= someCoordinate }

[Fact]
let ``Courier publishes location``() =

    // Test
    async {

        // Setup
        let signalR = new SignalRService();
        signalR.Connected        .Add onConnectionChanged
        signalR.ConnectionFailed .Add onConnectionChanged
        signalR.MessageReceived  .Add onMessageReceived

        do! signalR.ConnectAsync() |> Async.AwaitTask

        // Test
        do! signalR.SendAsync(someCourierLocation) |> Async.AwaitTask

    } |> Async.RunSynchronously

Appendix

namespace Specification

open System.Runtime.Serialization

[DataContract]
type Coordinate = {

    [field: DataMember(Name="Latitude")]
    Latitude  : double

    [field: DataMember(Name="Longitude")]
    Longitude : double
}

[DataContract]
type CourierLocation = {

    [field: DataMember(Name="CourierId")]
    CourierId  : string

    [field: DataMember(Name="Location")]
    Location : Coordinate
}
Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: