In the previous posts in this series we have developed our Twitter Sentiment Analysis library using Azure Cognitive Services, in this post we will build a Web Application with ASP.NET Core Blazor to use our library.

Blazor is an experimental .NET web framework using C#/Razor and HTML that runs in the browser with WebAssembly. You can find out more about Blazor including documentation and sample projects at Blazor.net

The first thing you need to do to get up and running with Blazor is install the project template. From your command line run the following

dotnet new -i Microsoft.AspNetCore.Blazor.Templates 

Once the templates install we can now create our Blazor Web App. We will create this as an entirely separate project from our Sentiment Analysis library and use the NuGet feed to pull down our package and use within the Web App.

Create a new folder alongside the TwitterSentiment solution (not inside it)

dotnet new blazorserverside -o TwitterSentimentWebApp
cd TwitterSentimentWebApp

Once this completes, open your new project in VS Code and the file structure should look like

You should now have a Web folder with 2 sub folders TwitterSentimentWebApp.App and TwitterSentimentWebApp.Server and a TwitterSentimentWebApp.sln file.

From your command line in the root of your solution, navigate into the Server project and build. This will build both the App and Server projects

cd TwitterSentimentWebApp.Server
dotnet build

To run our default Blazor app, remain in the Server project and run

dotnet watch run

This should start up the web server and we should now be able to access our Blazor web app from the browser via the port the app is listening on (usually https://localhost:5001)

If everything has worked, when you launch the web app in your browser you should see the default Blazor app screen below

Now that we have our basic Blazor template up and running, we want to add our Sentiment Analysis functionality. Open TwitterSentimentWebApp.App.csproj and add a PackageReference for our package we pushed to our NuGet feed. You can get the exact name and version from the Azure Artifacts feed

In this case the package name is TwitterSentiment and the version is 0.1.0-CI-20190103-204741

We can add the package reference to our csproj file by adding the following

<PackageReference Include="TwitterSentiment" Version="0.1.0-CI-20190103-204741" />

If you would always like to take the latest CI version available which is useful when developing libraries you can also use a wildcard

<PackageReference Include="TwitterSentiment" Version="0.1.0-CI-*" />

Your TwitterSentimentWebApp.App.csproj should now look like

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <OutputType>Exe</OutputType>
    <LangVersion>7.3</LangVersion>

    <!-- The linker is not beneficial for server-side execution. Re-enable this if you switch to WebAssembly execution. -->
    <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Blazor.Browser" Version="0.7.0" />
    <PackageReference Include="Microsoft.AspNetCore.Blazor.Build" Version="0.7.0" PrivateAssets="all" />
    <PackageReference Include="TwitterSentiment" Version="0.1.0-CI-*" />
  </ItemGroup>

</Project>

If we now try to restore our packages, we will encounter an error as the project doesn’t know where to look for this package as it’s in our private feed.

Unable to find package TwitterSentiment. No packages exist with this id in source(s): Microsoft Visual Studio Offline Packages, nuget.org

We must tell our project where to look for additional packages from our private feed.

To do this first create a NuGet.config file in the root of your solution with the following content (you can rename ‘MyPrivateFeed’ to anything you like)

<?xml version="1.0" encoding="utf-8"?>
<configuration>
	<packageSources>
		<!-- Add these repos to the list of available repositories -->
	<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
	<add key="MyPrivateFeed" value="FEED URL HERE" />
	</packageSources>
	<activePackageSource>
		<add key="All" value="(Aggregate source)"  />
	</activePackageSource>
</configuration>

Your project structure should be as follows

We must now find our Feed Url from Azure Artifacts

In my case the url is https://pkgs.dev.azure.com/WholeSoftware/_packaging/WholeschoolPackages/nuget/v3/index.json

Paste this into your config file replacing FEED URL HERE

Once that is completed our project will know where to look for packages in our private feed.

We will return to the command line and try our restore again, however this time we will add an –interactive flag. You need to do this the first time you are trying to restore from your private feed.

The --interactive feature was installed as part of the Azure Artifacts Credential Provider that we setup in part 4 of this blog post series.

From the root of our solution, navigate to the Server project and run the interactive restore

cd .\TwitterSentimentWebApp.Server\
dotnet restore --interactive

During the restore, you will be prompted to login to a URL

Copy the URL and open in your browser, when prompted enter the code provided

Once logged in you can close your browser and return to your command line, the restore should have completed successfully. Build the project to ensure everything looks good.

To make our Sentiment Analysis service available for use in our app, open Startup.cs under TwitterSentimentWebApp.App project and add the following line within the ConfigureServices method

services.AddTwitterSentiment();

Your Configure Services method should now look like

public void ConfigureServices(IServiceCollection services)
        {
            // Since Blazor is running on the server, we can use an application service
            // to read the forecast data.
            services.AddSingleton<WeatherForecastService>();
            
            services.AddTwitterSentiment();
        }

Next create an appsettings.json file in the TwitterSentimentWebApp.App project with the following content

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConsumerKey" : "Set in Azure. For development, set in User Secrets",
  "ConsumerSecret" : "Set in Azure. For development, set in User Secrets",
  "Token" : "Set in Azure. For development, set in User Secrets",
  "TokenSecret" : "Set in Azure. For development, set in User Secrets",
  "AzureTextAnalytics" : "Set in Azure. For development, set in User Secrets"
}

This will act as a placeholder for our various Twitter and Azure keys needed to interact with the API’s.

Note that we wont actually put our keys into this appsettings.json file, we will use the ‘User Secrets’ feature in .NET Core when developing on our local machine and the Azure Application Settings feature when we deploy to our Azure App Service. This ensures we don’t check any sensitive information into source control and is considered a best practice when developing ASP.NET Core applications locally. I do however like to have the keys in the appsettings.json file with a comment to remind me (or another developer) where the keys should be located.

ASP.NET Core User Secrets provides a technique for storing and retrieving sensitive data during the development of an ASP.NET Core app, you read more from the official documentation here

To get started using secrets in our application, we must first add a UserSecretsId to our project, open TwitterSentimentWebApp.Server.csproj and add the following element within a PropertyGroup (you can replace the GUID with your own)

 <UserSecretsId>7c32f487-7c35-4d85-9b75-9f0e56c3fe2c</UserSecretsId>

Your config file should now look like

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
    <LangVersion>7.3</LangVersion>
    <UserSecretsId>7c32f487-7c35-4d85-9b75-9f0e56c3fe2c</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.AspNetCore.Blazor.Server" Version="0.7.0" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\TwitterSentimentWebApp.App\TwitterSentimentWebApp.App.csproj" />
  </ItemGroup>

</Project>

We are now ready to start adding our secrets, first lets add our 4 twitter keys and secret

  • ConsumerKey
  • ConsumerSecret
  • Token
  • TokenSecret

You can get these keys from the Twitter Developer Portal here. If you are not already setup as a developer, you may need to complete a short survey during sign-up and I found the turn around to get access to create apps was very quick. Once you are setup with your account, create a new App and fill in the relevant information

Once the app is created, under ‘Keys and Tokens’ you will find the 4 pieces of data required.

Back on our command line, navigate to the TwitterSentimentWebApp.Server folder and run the following commands to add your secrets, substituting with your keys

  dotnet user-secrets set "ConsumerKey" "REPLACE WITH API KEY"
  dotnet user-secrets set "ConsumerSecret" "REPLACE WITH API SECRET KEY"
  dotnet user-secrets set "Token" "REPLACE WITH ACCESS TOKEN"
  dotnet user-secrets set "TokenSecret" "REPLACE WITH ACCESS TOKEN SECRET"

We just need to add our Azure Text Analytics API key and that should be our secrets all setup.

You can get this key from the Text Analytics service we created in the Azure Portal in part 6 of this blog post series

Once you have one of the two keys copied run this command from your command line, replacing with your API key

dotnet user-secrets set "AzureTextAnalytics" "REPLACE WITH TEXT ANALYTICS API KEY"

You can check all 5 secrets have been added correctly using the following command

dotnet user-secrets list

It should output the following

We are now ready to build out our actual web page. Under the TwitterSentimentWebApp.App project open Index.cshtml located within the Pages folder.

We are going to replace all of the content in this file with the following

@page "/"
@using System.Text.RegularExpressions
@using TwitterSentiment
@inject TwitterClient twitterClient
@inject TextAnalyticsClient analyticsClient

<div class="row mt-5">
    <div class="col-md-12">
        <h1 class="text-center text-muted">
            Twitter Sentiment Analysis
        </h1>
    </div>
</div>
<div class="container">
    <br />
    <div class="row justify-content-center">
        <div class="col-12 col-md-10 col-lg-8">
            <div class="card card-sm">
                <div class="card-body row no-gutters align-items-center">
                    <div class="col-auto">
                        <i class="fas fa-search h4 text-body"></i>
                    </div>
                    <!--end of col-->
                    <div class="col">
                        <input class="form-control form-control-lg form-control-borderless @InputCssClass" type="search" placeholder="Enter twitter username" bind="@username" />
                    </div>
                    <!--end of col-->
                    <div class="col-auto">
                        <button class="btn btn-lg btn-primary" onclick="@Search" onkeypress="@KeyPress">Search</button>
                    </div>
                    <!--end of col-->
                </div>
            </div>
        </div>
        <!--end of col-->
    </div>
    <div class="alert alert-danger" role="alert" hidden="@HideError">@Error</div>
    <div class="loader" hidden="@HideLoader">Loading...</div>
    @if (TweetsLoaded && HideLoader)
    {
        <div class="alert alert-@GetSentimentText(@Average, true) mt-5" role="alert">
        Overall this timeline is <strong>@GetSentimentText(@Average)</strong>
        </div>
        <table class="table mt-5">
            <tbody>
                @foreach (var tweet in TweetSentiment)
                {
                    <tr>
                        <td>@tweet.Text</td>
                        <td>
                            <button type="button" class="btn btn-@GetSentimentText(@tweet.Score, true) btn-block">@GetSentimentText(@tweet.Score)</button>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    }
</div>
@functions {
    IEnumerable<TweetsWithSentiment> TweetSentiment;
    bool TweetsLoaded => TweetSentiment != null;
    bool HideLoader = true;
    string username;
    double Average;

    string Error = "";
    bool HideError => String.IsNullOrWhiteSpace(Error); 
    string InputCssClass => HideError ? "" : "is-invalid"; 

    private async Task Search()
    {
        var isValid = IsUsernameValid(username);

        if(isValid)
        {
            await ToggleLoader();

            if(username != "demo")
                await DoSearch();
            else
                await DoDemoSearch();

            await ToggleLoader();
        }
        else
        {
           Error = "A username cannot be longer than 15 characters and contain only alphanumeric characters (letters A-Z, numbers 0-9) or underscores";
           await RefreshState();
        }
        
    }

    private async Task DoSearch()
    {
        var tweets = await twitterClient.GetTimeline(username);
        var sentiment = await analyticsClient.AnalyzeSentiment(tweets.ProjectToDocuments());

        TweetSentiment = tweets.Combine(sentiment);

        Average = TweetSentiment.Select(s=> s.Score).Average();
    }

    private async Task DoDemoSearch()
    {
       await Task.Delay(3000);

       TweetSentiment = new List<TweetsWithSentiment> {
            new TweetsWithSentiment("1", "Wholeschool is awesome!", 0.9),
            new TweetsWithSentiment("1", "Wholeschool staff are the best", 0.7),
            new TweetsWithSentiment("1", "Moodle is a great product", 0.6),
            new TweetsWithSentiment("1", "Wholeschool are a software company", 0.5),
            new TweetsWithSentiment("1", "NodeJs is pants!", 0.1),
        };

        Average = TweetSentiment.Select(s=> s.Score).Average();
    }

    private async Task ToggleLoader()
    {
       HideLoader = !HideLoader;
       base.StateHasChanged();
    }
    
    private async Task RefreshState()
    {
        base.StateHasChanged();
    }

    private bool IsUsernameValid(string username)
    {
        Error = "";

        if(string.IsNullOrWhiteSpace(username))
            return false;

        return new Regex(@"^@?(\w){1,15}$")
                .Match(username)
                .Success;
    }

    private string GetSentimentText(double score, bool isCssClass = false)
    {
        switch (score)
        {
            case var s when (s >= 0.8): return isCssClass ? "success" : "Very Positive";
            case var s when (s > 0.5): return isCssClass ? "success" : "Positive";
            case var s when (s == 0.5): return isCssClass ? "default" : "Neutral";
            case var s when (s <= 0.2): return isCssClass ? "danger" : "Very Negative";
            case var s when (s < 0.5): return isCssClass ? "danger" : "Negative";
            default: return isCssClass ? "success" : "default";
        }
    }

    async Task KeyPress(UIKeyboardEventArgs e)
    {
        Console.WriteLine("KEY:" + e.Key);
        if (e.Key == "Enter")
        {
            await Search();
        }
    }
}

We copied in quite a bit of code but there are really 3 key areas in the file.

At the top of the page we have an @page directive thats defining the route to the page followed by several @using statements that are importing libraries needed for our page including our TwitterSentiment library and our Typed Http Clients

@page "/"
@using System.Text.RegularExpressions
@using TwitterSentiment
@inject TwitterClient twitterClient
@inject TextAnalyticsClient analyticsClient

Next we have our HTML markup thats using Bootstrap 4 for layout and finally we have our @functions block that contains our client-side C# code that will compile to Web Assembly.

At the top of our functions block we declare some variables for our page to hold analysis results and some properties to manage our page state.

    IEnumerable<TweetsWithSentiment> TweetSentiment;
    bool TweetsLoaded => TweetSentiment != null;
    bool HideLoader = true;
    string username;
    double Average;

    string Error = "";
    bool HideError => String.IsNullOrWhiteSpace(Error); 
    string InputCssClass => HideError ? "" : "is-invalid";

Within our markup with have an input field to capture the username and a search button that will trigger the search. Note the bind attribute in the input element, this is set to @username which will bind to the username property we defined at the top of the function block.

On the button element we have an onclick attribute bound to @Search that will call the Search method.

<input class="form-control form-control-lg form-control-borderless @InputCssClass" 
    type="search" placeholder="Enter twitter username" bind="@username" />

<button class="btn btn-lg btn-primary" onclick="@Search" 
    onkeypress="@KeyPress">Search</button>

The Search called by clicking the button validates the username then calls one of two Search methods and manages some UI state. You can see below if the username entered is ‘demo’ it will bypass the call to the Text Analytics API and return some fake results which is useful for testing without using your Azure credits.

if(username != "demo")
                await DoSearch();
            else
                await DoDemoSearch();

The main DoSearch method calls our TwitterClient to get the last 20 tweets for the specified username then passes those tweets to the TextAnalyticsClient to analyse the sentiment and return the final results.

private async Task DoSearch()
    {
        var tweets = await twitterClient.GetTimeline(username);
        var sentiment = await analyticsClient.AnalyzeSentiment(tweets.ProjectToDocuments());

        TweetSentiment = tweets.Combine(sentiment);

        Average = TweetSentiment.Select(s=> s.Score).Average();
    }

Finally we use razor syntax to bind the analysis results to an HTML table and use a GetSentimentText helper method to return labels for Positive or Negative etc

<table class="table mt-5">
            <tbody>
                @foreach (var tweet in TweetSentiment)
                {
                    <tr>
                        <td>@tweet.Text</td>
                        <td>
                            <button type="button" class="btn btn-@GetSentimentText(@tweet.Score, true) btn-block">@GetSentimentText(@tweet.Score)</button>
                        </td>
                    </tr>
                }
            </tbody>
        </table>

Ok one more small tweak and we should be good to go, we don’t need the left menu in our web app now, so open MainLayout.cshtml under the Shared folder and replace its content with

@inherits BlazorLayoutComponent

<div class="main">
    <div class="container-fluid">
         @Body
    </div>
</div>

Thats our Blazor Web App completed, we just need to test everything is working.

Return to your command line and navigate to the TwitterSentimentWebApp.Server project folder then build and run your application

cd TwitterSentimentWebApp.Server
dotnet build
dotnet run

This should start up our web app, you can then navigate to the app in your browser, in my case its under https://localhost:5001

When your app loads up in the browser it should now prompt you to enter a Twitter username, for example stevenknox101

When you enter a valid username and click Search it should return the last 20 tweets for that account along with the sentiment score against each tweet and an overall rating

Thats our Web Application completed, all thats left now is to push our Web App to a new GitHub repo and setup a CI/CD pipeline to deploy our application to Azure App Services. Continue to the next post