Implementing Azure Notification Hubs – Part 3 (API Project and Testing)

The complete solution for this can be found on my GitHub account.

In launchSettings.json, remove the IIS settings as well as Launch BrowserLaunch Browser URL and the HTTPS URL. I prefer to run the application directly instead of through IIS and using HTTPS can sometimes cause issues while testing UWP applications. You could use HTTPS by installing and trusting the development certificate provided by ASP.NET Core. After the changes, the launchSettings.json file looks like: 

{
    "$schema": "https://json.schemastore.org/launchsettings.json",
    "profiles": {
        "LearningNotificationHubs.API": {
            "commandName": "Project",
            "dotnetRunMessages": true,
            "applicationUrl": "http://localhost:5000",
            "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            }
        }
    }
}

Models 

In the root of the API project, create a new folder called Models. In this folder create a new class file called User.cs  with a public class User which will contain data about a user as well as a list of their devices. The code for this class is relatively simple: 

public class User 
{ 
    public string Username { get; set; } 
    public List<Device> Devices { get; set; } = new List<Device>(); 
} 

The Device class does not exist yet. Create it by adding a new class file to the Models folder with the name Device.cs and a public class Device. It will contain all the information about a user’s device as well as a reference back to the user. 

public class Device 
{ 
    public Guid Id { get; set; } 
    public Platform Platform { get; set; } 
    public string PnsToken { get; set; } 
    public string RegistrationId { get; set; } 

    public string Username { get; set; } 
    public User User { get; set; } 
} 

Database 

As mentioned previously, we will be using Entity Framework Core with an SQLite database to store the application data. In the API project, install the following two NuGet packages: 

  1. Microsoft.EntityFrameworkCore.Design 
  2. Microsoft.EntityFrameworkCore.Sqlite 

Next, you need to set up the database. I prefer to use the OnModelCreating method of the DbContext class instead of data annotations on the model objects. This helps to keep the model classes simple and allows me to keep my database rules in one place. 

Create a new folder in the root of the API project called Data. Within this folder, create a new class file called ApplicationDbContext.cs. The file will contain a class ApplicationDbContext which will inherit from DbContext

Within the ApplicationDbContext class, add two DbSet<T> properties for each of the models. 

public DbSet<User> Users { get; set; } 
public DbSet<Device> Devices { get; set; } 

The constructor can be blank with a call to the base constructor. 

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) 
        : base(options) {} 

The OnModelCreating override is where most of the work exists. Here you need to set up the keys for the User and Device models, as well as include some constraints and relationship mapping between the two models. A device has a composite key of Identifier and Username to allow a device to be used by more than one user. 

protected override void OnModelCreating(ModelBuilder builder) 
{ 
    base.OnModelCreating(builder); 

    builder.Entity<User>(user => 
    { 
        user 
        .HasKey(u => u.Username); 
    }); 

    builder.Entity<Device>(device => 
    { 
        device 
        .HasKey(d => new { d.Id, d.Username }); 

        device 
        .HasOne(d => d.User) 
        .WithMany(u => u.Devices) 
        .HasForeignKey(d => d.Username) 
        .OnDelete(DeleteBehavior.Restrict); 

        device 
        .Property(d => d.Platform) 
        .IsRequired(); 

        device 
        .Property(d => d.PnsToken) 
        .IsRequired(); 

        device 
        .Property(d => d.RegistrationId) 
        .IsRequired(); 
    }); 
} 

Next, go to the Startup.cs class to register the database with the dependency injection service. In the ConfigureServices method, add the following: 

services.AddDbContext<ApplicationDbContext>(options => 
{ 
    options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")); 
}); 

Next, set the connection string in the appsettings.Development.json file. 

{ 
  //… 
  "ConnectionStrings": { 
    "DefaultConnection": "DataSource=app.db" 
  } 
} 

Right-click on the API project in the solution explorer and select Open in Terminal. This will open a new Developer PowerShell window which you can use to execute the commands to make Entity Framework build the database. 

To create the migrations, enter: dotnet ef migrations add “Initial”. You will see a new folder called Migrations added to the root of the API project with two files. Open the xxx_Initial.cs file to see the generated code that will set-up the database. 

  • Next, in the Terminal window, type: dotnet ef database update. This will create and set up the database using the migration file. You will see a new app.db file in the root directory of the API project. This is the database file. You can use DB Browser for SQLite to open and explore the database structure. 

If you need to wipe your database, you can type: dotnet ef database drop. After the database has been dropped, you can run the update command again to recreate it.  

Services 

In the root of the API project, create a new folder called Services. Then, within the Services folder, create another folder called Azure. This folder will allow us to keep all our Azure-specific code in one place. There will be only one file here in this project though. 

In the Azure folder, create a new class file called AzureUtilities.cs which will contain a public static class called AzureUtilities. Inside this class add a method to generate a SAS token that is required by Azure for authorization when making requests to a Notification Hub. 

public static class AzureUtililities 
{ 
    public static string GenerateSASToken(string resourceUri, string keyName, string key, TimeSpan? expires = null) 
    { 
        if (expires is null) 
        { 
            expires = TimeSpan.FromHours(1); 
        } 

        TimeSpan expirySinceEpoch = DateTime.UtcNow - new DateTime(1970, 1, 1) + expires.Value; 
        string expiresString = Convert.ToString((int)expirySinceEpoch.TotalSeconds); 
        string stringToSign = HttpUtility.UrlEncode(resourceUri) + "\n" + expiresString; 
        string signature; 

        using (HMACSHA256 hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key))) 
        { 
            signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign))); 
        } 

        string sasToken = $"SharedAccessSignature sr={HttpUtility.UrlEncode(resourceUri)}&sig={HttpUtility.UrlEncode(signature)}&se={expiresString}&skn={keyName}"; 
        
        return sasToken; 
    } 
} 

In the root of the Services folder, create an interface file called INotificationHubService.cs with an interface called INotificationHubService. This interface will hold a couple of methods we’ll need to interact with Azure Notification Hubs. 

public interface INotificationHubService 
{ 
    Task<string> CreateRegistration(SignInUserDto signInDeviceDto); 
    Task<string> UpdateRegistration(SignInUserDto signInDeviceDto, string registrationId); 
    Task DeleteRegistration(string registrationId); 
    Task SendNotification(string message, string tag); 
} 

Next, in the same folder, create a class file called NotificationHubService.cs with a class called NotificationHubService. This class must implement the INotificationHubService interface. 

Declare private readonly string fields for the Notification Hub’s namespace name, hub name. SAS key name and SAS key. These fields will be read from the configuration file in the constructor. 

private readonly string _namespaceName; 
private readonly string _hubName; 
private readonly string _sasKeyName; 
private readonly string _sasKey; 

public NotificationHubService(IConfiguration configuration) 
{ 
    _namespaceName = configuration.GetSection("NotificationHubService:NamespaceName").Value; 
    _hubName = configuration.GetSection("NotificationHubService:HubName").Value; 
    _sasKeyName = configuration.GetSection("NotificationHubService:SasKeyName").Value; 
    _sasKey = configuration.GetSection("NotificationHubService:SasKey").Value; 
} 

Let’s set up those configuration values now before we forget. Open appsettings.Development.json and add them in. 

"NotificationHubService": { 
    "NamespaceName": "<YOUR_NAMESPACE_NAME>", 
    "HubName": "<YOUR_HUB_NAME>", 
    "SasKeyName": "<YOUR_SAS_KEY_NAME>", 
    "SasKey": "<YOUR_SAS_KEY> 
}

Back to the NotificationHubService class. The different ecosystems require slightly different registration templates. We will be making use of template registrations so that we can send one type of notification request to the Notification Hub, which can work for all devices. Create a private method called CreateRegistrationBody that will handle creating the registration body for each platform. 

private string CreateRegistrationBody(SignInUserDto signInDeviceDto) 
{ 
    string windowsRegistration = @"<?xml version=""1.0"" encoding=""utf-8""?> 
<entry xmlns=""http://www.w3.org/2005/Atom""> 
<content type=""application/xml""> 
<RegistrationDescription xmlns:i=""http://www.w3.org/2001/XMLSchema-instance"" i:type=""WindowsTemplateRegistrationDescription"" xmlns=""http://schemas.microsoft.com/netservices/2010/10/servicebus/connect""> 
    <Tags>{0}</Tags> 
    <ChannelUri>{1}</ChannelUri> 
    <BodyTemplate> 
        <![CDATA[<toast><visual><binding template=""ToastText01""><text id=""1"">$(message)</text></binding></visual></toast>]]> 
    </BodyTemplate> 
    <WnsHeaders> 
        <WnsHeader> 
            <Header>X-WNS-Type</Header> 
            <Value>wns/toast</Value> 
        </WnsHeader> 
    </WnsHeaders> 
</RegistrationDescription> 
</content> 
</entry>"; 

    string appleRegistration = @"<?xml version=""1.0"" encoding=""utf-8""?> 
<entry xmlns=""http://www.w3.org/2005/Atom""> 
<content type=""application/xml""> 
<RegistrationDescription xmlns:i=""http://www.w3.org/2001/XMLSchema-instance"" i:type=""AppleTemplateRegistrationDescription"" xmlns=""http://schemas.microsoft.com/netservices/2010/10/servicebus/connect""> 
    <Tags>{0}</Tags> 
    <DeviceToken>{1}</DeviceToken>  
    <BodyTemplate> 
        <![CDATA[ 
            {""aps"":{""alert"":""$(message)""}} 
        ]]> 
    </BodyTemplate> 
</RegistrationDescription> 
</content> 
</entry>"; 

    string androidRegistration = @"<?xml version=""1.0"" encoding=""utf-8""?> 
<entry xmlns=""http://www.w3.org/2005/Atom""> 
<content type=""application/xml""> 
<RegistrationDescription xmlns:i=""http://www.w3.org/2001/XMLSchema-instance"" i:type=""GcmTemplateRegistrationDescription"" xmlns=""http://schemas.microsoft.com/netservices/2010/10/servicebus/connect""> 
    <Tags>{0}</Tags> 
    <GcmRegistrationId>{1}</GcmRegistrationId>  
    <BodyTemplate> 
        <![CDATA[ 
            { 
                ""notification"":{ 
                    ""title"":""Learning Notification Hubs"", 
                    ""body"":""$(message)"" 
                } 
            } 
        ]]> 
    </BodyTemplate> 
</RegistrationDescription> 
</content> 
</entry>"; 

    string createRegistration; 
    switch (signInDeviceDto.Platform) 
    { 
        case Platform.Android: 
            createRegistration = androidRegistration; 
            break; 
        case Platform.iOS: 
            createRegistration = appleRegistration; 
            break; 
        case Platform.UWP: 
            createRegistration = windowsRegistration; 
            break; 
        default: 
            throw new ArgumentException($"{signInDeviceDto.Platform} platform is not supported."); 
    } 

    createRegistration = createRegistration 
        .Replace("{0}", $"User_{signInDeviceDto.Username}") 
        .Replace("{1}", signInDeviceDto.PnsToken); 

    return createRegistration; 
} 

Now let’s begin implementing the interface. Create the CreateRegistration method. This method will use the CreateRegistrationBody we just created. It will then build an HttpRequestMessage and generate a SAS token before making a request to the Notification Hubs REST API. If the request is successful, it will return the Registration ID. 

public async Task<string> CreateRegistration(SignInUserDto signInUserDto) 
{ 
    string createRegistration = CreateRegistrationBody(signInUserDto); 

    string resourceUri = $"https://{_namespaceName}.servicebus.windows.net/{_hubName}/registrations?api-version=2015-01"; 
    
    HttpRequestMessage request = new HttpRequestMessage() 
    { 
        RequestUri = new Uri(resourceUri), 
        Method = HttpMethod.Post, 
        Content = new StringContent(createRegistration, Encoding.UTF8, "application/atom+xml") 
    }; 

    string sasToken = Azure.AzureUtililities.GenerateSASToken(resourceUri, _sasKeyName, _sasKey); 

    request.Headers.TryAddWithoutValidation("Authorization", sasToken); 

    HttpClient httpClient = new HttpClient(); 
    HttpResponseMessage response = await httpClient.SendAsync(request); 

    response.EnsureSuccessStatusCode(); 

    string responseContent = await response.Content.ReadAsStringAsync(); 

    XmlDocument xmlDocument = new XmlDocument(); 
    xmlDocument.LoadXml(responseContent); 

    string registrationId = xmlDocument.GetElementsByTagName("title")[0].InnerText; 
    return registrationId; 
}

You’ll notice that TryAddWithoutValidation is used for adding headers. This is because the SAS token does not pass the validation in .NET for an authorization header. Quite surprising considering both Azure and .NET are Microsoft products, but it is what it is. 

Next up is the UpdateRegistration method. This method is similar to the CreateRegistration method, except it is used for updating a registration when a device has already been registered previously. As such, it also uses the CreateRegistrationBody method and the GenerateSASToken method. If the REST API call is successful, it returns the Registration ID. 

public async Task<string> UpdateRegistration(SignInUserDto signInDeviceDto, string registrationId) 
{ 
    string registrationBody = CreateRegistrationBody(signInDeviceDto); 

    string resourceUri = $"https://{_namespaceName}.servicebus.windows.net/{_hubName}/registrations/{registrationId}?api-version=2015-01"; 

    HttpRequestMessage request = new HttpRequestMessage() 
    { 
        RequestUri = new Uri(resourceUri), 
        Method = HttpMethod.Post, 
        Content = new StringContent(registrationBody, Encoding.UTF8, "application/atom+xml") 
    }; 

    string sasToken = Azure.AzureUtililities.GenerateSASToken(resourceUri, _sasKeyName, _sasKey); 

    request.Headers.TryAddWithoutValidation("Authorization", sasToken); 
    request.Headers.TryAddWithoutValidation("If-Match", "*"); 

    HttpClient httpClient = new HttpClient(); 
    HttpResponseMessage response = await httpClient.SendAsync(request); 

    response.EnsureSuccessStatusCode(); 

    string contentLocation = response.Headers.First(header => header.Key == "Content-Location").Value.First(); 

    string newRegistrationId = new Uri(contentLocation).Segments.Last(); 
    return newRegistrationId; 
} 

The next method to implement is the DeleteRegistration method, which as the name suggests, is used to delete a device registration from the Notification Hub. The code is like the previous two methods, except that no request body is required. The registration’s ID is included in the URL. 

public async Task DeleteRegistration(string registrationId) 
{ 
    string resourceUri = $"https://{_namespaceName}.servicebus.windows.net/{_hubName}/registrations/{registrationId}?api-version=2015-01"; 
    
    HttpRequestMessage request = new HttpRequestMessage() 
    { 
        RequestUri = new Uri(resourceUri), 
        Method = HttpMethod.Delete 
    }; 

    string sasToken = Azure.AzureUtililities.GenerateSASToken(resourceUri, _sasKeyName, _sasKey); 

    request.Headers.TryAddWithoutValidation("Authorization", sasToken); 
    request.Headers.TryAddWithoutValidation("If-Match", "*"); 

    HttpClient httpClient = new HttpClient(); 
    HttpResponseMessage response = await httpClient.SendAsync(request); 

    response.EnsureSuccessStatusCode(); 
} 

Finally, we have the SendNotification method. This method uses a notification template to send a notification to any device registered with the Notification Hub, regardless of the platform the device is running on. It uses a tag to control which devices the notification must be delivered to. 

public async Task SendNotification(string message, string tag) 
{ 
    string notificationBody = "{\"message\":\"" + message + "\"}"; 

    string resourceUri = $"https://{_namespaceName}.servicebus.windows.net/{_hubName}/messages/?api-version=2015-01"; 

    HttpRequestMessage request = new HttpRequestMessage() 
    { 
        RequestUri = new Uri(resourceUri), 
        Method = HttpMethod.Post, 
        Content = new StringContent(notificationBody, Encoding.UTF8, "application/json") 
    }; 

    string sasToken = Azure.AzureUtililities.GenerateSASToken(resourceUri, _sasKeyName, _sasKey); 

    request.Headers.TryAddWithoutValidation("Authorization", sasToken); 
    request.Headers.TryAddWithoutValidation("ServiceBusNotification-Tags", tag); 
    request.Headers.TryAddWithoutValidation("ServiceBusNotification-Format", "template"); 

    HttpClient httpClient = new HttpClient(); 
    HttpResponseMessage response = await httpClient.SendAsync(request); 

    response.EnsureSuccessStatusCode();        
}

That’s it for the service layer. Next, we need to implement the controllers. 

Controllers 

Auth Controller 

Delete the WeatherForecast.cs file. Rename the WeatherForecastController.cs to AuthController.cs. In AuthController.cs, update the class name from WeatherForecastController to AuthController. Remove the Summaries field as well as the Get() method. You can also remove the _logger field and its reference in the constructor. 

Next create private readonly fields for the ApplicationDbContext and the INotificationHubService and inject them using construction injection. 

private readonly ApplicationDbContext _applicationDbContext; 

private readonly INotificationHubService _notificationHubService; 

public AuthController(ApplicationDbContext applicationDbContext, INotificationHubService notificationHubService) 
{ 
    _applicationDbContext = applicationDbContext; 
    _notificationHubService = notificationHubService; 
} 

There are two action methods we need to set up in this controller – SignUserIn and SignUserOut. Their names are self-explanatory – one signs the user in, and the other signs a user out. 

SignUserIn will check if a user exists, if the user does not exist, it will create the user and device in the database and update the notification hub. If the user already exists, it will only add the device to the database and update the notification hub. The method will accept the SignInUserDto created earlier. The complete code for this method is: 

[HttpPost("SignUserIn")] 
public async Task<IActionResult> SignUserIn(SignInUserDto signInUserDto) 
{         
    // Check if this user exists. 
    User user = _applicationDbContext.Users.FirstOrDefault(u => u.Username == signInUserDto.Username); 

    // If the user does not exist, create the user and the device. 
    if (user is null) 
    { 
        user = new User 
        { 
            Username = signInUserDto.Username, 
        }; 

        await _applicationDbContext.Users.AddAsync(user); 

        // Register with Azure Notification Hub 
        string registrationId = await _notificationHubService.CreateRegistration(signInUserDto); 

        Device device = new Device 
        { 
            Id = signInUserDto.DeviceId.Value, 
            Platform = signInUserDto.Platform.Value, 
            PnsToken = signInUserDto.PnsToken, 
            Username = signInUserDto.Username, 
            RegistrationId = registrationId 
        }; 

        await _applicationDbContext.Devices.AddAsync(device); 
    } 

    // Check if this device has been assigned to the user. 
    else 
    { 
        Device device = await _applicationDbContext.Devices 
            .FirstOrDefaultAsync(d => d.Id == signInUserDto.DeviceId && d.Username == signInUserDto.Username); 

        // If the device has not been assigned, assign it. 
        if (device is null) 
        { 
            string registrationId = await _notificationHubService.CreateRegistration(signInUserDto); 

            device = new Device 
            { 
                Id = signInUserDto.DeviceId.Value, 
                Platform = signInUserDto.Platform.Value, 
                PnsToken = signInUserDto.PnsToken, 
                Username = signInUserDto.Username, 
                RegistrationId = registrationId 
            }; 

            await _applicationDbContext.Devices.AddAsync(device); 
        } 

        // Update the registration 
        else 
        { 
            await _notificationHubService.UpdateRegistration(signInUserDto, device.RegistrationId); 
            device.RegistrationId = signInUserDto.PnsToken; 
        } 
    } 

    await _applicationDbContext.SaveChangesAsync(); 

    return Ok(); 
} 

SignUserOut allows users to “sign out”. It checks if the user exists in the database, and if so, checks the device the user is signing out with. If the device is found, it is removed from the Notification Hub and the database. If the user no longer has any devices left, the user is also removed. 

[HttpPost("SignUserOut")] 
public async Task<IActionResult> SignUserOut(SignOutUserDto signOutUserDto) 
{ 
    // Check if this user exists. 
    User user = _applicationDbContext.Users.FirstOrDefault(u => u.Username == signOutUserDto.Username); 

    if (user is null) 
    { 
        return BadRequest("The user does not exist."); 
    } 

    // Check if this device has been assigned to the user. 
    Device device = await _applicationDbContext.Devices 
        .FirstOrDefaultAsync(d => d.Id == signOutUserDto.DeviceId && d.Username == signOutUserDto.Username); 

    // If the device has not been assigned, unassign it. 
    if (device is not null) 
    { 
        await _notificationHubService.DeleteRegistration(device.RegistrationId); 

        _applicationDbContext.Devices.Remove(device); 
        
        await _applicationDbContext.SaveChangesAsync(); 
    } 

    // If the user has no more devices left, delete them. 
    int deviceCount = await _applicationDbContext.Devices 
        .Where(d => d.Username == signOutUserDto.Username) 
        .CountAsync(); 

    if (deviceCount == 0) 
    { 
        _applicationDbContext.Remove(user); 
    } 

    await _applicationDbContext.SaveChangesAsync(); 
    
    return Ok(); 
} 

Manager Controller 

The manager controller will allow us to get a list of users and associated devices from the API as well as to send notifications to specific users. 

To get started, create a ManagerController.cs file in the Controllers folder with a ManagerController class inside that inherits from ControllerBase. Set the ApiController and Route(“[controller]”) attributes on the class. Next, create private readonly fields for the ApplicationDbContext and the INotificationHubService and inject them in the constructor. 

[ApiController] 
[Route("[controller]")] 
public class ManagerController : ControllerBase 
{ 
    private readonly ApplicationDbContext _applicationDbContext; 
    private readonly INotificationHubService _notificationHubService; 

    public ManagerController(ApplicationDbContext applicationDbContext, INotificationHubService notificationHubService) 
    { 
        _applicationDbContext = applicationDbContext; 
        _notificationHubService = notificationHubService; 
    } 

    // Action methods 
} 

The controller will contain two action methods – Users which will return the list of Users and Devices, and SendNotification which will send the notification.  

Let’s start with the Users method. It needs to obtain the Users with Devices from the database and then map this to the ResponseUserDtos in an enumeration, which will be serialised and returned to the client. 

[HttpGet("Users")] 
public async Task<IActionResult> Users() 
{ 
    List<ResponseUserDto> responseUserDtos = new List<ResponseUserDto>(); 
        await _applicationDbContext.Users.ForEachAsync(async user => 
        { 
            ResponseUserDto responseUserDto = new ResponseUserDto(user.Username); 

            responseUserDto.Devices = await _applicationDbContext.Devices 
                .Where(d => d.Username == user.Username) 
                .Select(d => new ResponseDeviceDto(d.Id, d.Platform, d.RegistrationId, d.PnsToken)) 
                .ToListAsync(); 

            responseUserDtos.Add(responseUserDto); 
        }); 

    return Ok(responseUserDtos); 
} 

Next, implement the SendNotifications method. It will check if the user exists and if so, use the INotificationHubService to send a notification. 

Testing 

Now that the code is complete, it’s time to test it. If you want to run more than one project within the solution in debug mode, right-click on the solution in the Solution Explorer and choose Properties. Under Common Properties -> Startup Project, select Multiple startup projects. Then set the Action of the projects you want to build to start. Finally, click Apply. In the screenshot below, both the API and UWP application will run together with debugging enabled. 

Run one of the applications, and you should see the labels populated. Click Sign In, and wait for the alert message to appear, then click OK. 

Next, load up an API testing tool such as Insomnia, and make a request to the Users endpoint to see the data returned. You could also inspect the database directly using DB Browser

Next, use the API testing tool to send a notification to the user. 

You should receive the notification. 

Conclusion 

We’ve successfully built and end-to-end solution where users can sign into an application which communicates with a back-end API. Once signed in, we are then able to send push notifications to the sign in users on all three major platforms – Android, iOS and Windows. I hope these posts have helped you with implementing Azure Notification Hubs in your own applications. 

Leave a comment

Your email address will not be published.