ASP.NET Core – MVC Web Application – Integrate:
Publish with Continuous Integration and Continuous Delivery using GitHub Actions
The purpose of this document is to go over the steps that are necessary to build a development environment that will host an ASP.NET Core MVC Web Application, Azure Functions, and Azurite for Azure Storage Emulation.
Introduction — This post will be a standard “Todo” / “Task” application where Users of our application will store list of tasks on our site.
The following ASP.NET Core MVC Web Application will integrate with the following technologies for the following purposes:
- Azure Active Directory – We will be using the Single Tenant Authentication Option for user Authentication.
- Azure App Configuration – We will be using this to store information that we would commonly use in appsetting.json to be retrieved via this cloud service at runtime.
- Azure Key Vault – We will be using this to store secret information that we would commonly use in appsettings.json to be retrieved via this cloud service at runtime.
- Azure Storage Tables – We will be using this to store basic information about Users, and their Todos.
- Azure Storage Blobs – We will be using this to store lists of Tasks.
- Azure Storage Queues – We will be using this as a messaging system to let the Azure Functions know that a Todo has been modified, and that the lists should be rebuilt.
- Azure Functions – We will be using this to listen to messages sent to the Storage Queue and perform the list rebuilding.
- Azure App Service – We will be using this to host our MVC Web Application.
- GitHub Actions – We will be using this to Continuously Build and Delivery our MVC Web Application to Azure App Service, and Azure Functions using a mono repo approach.
There are many steps and integrations. We have previously published other articles that cover some of these topics in smaller bites, and while not necessary to follow along with this tutorial, it may be helpful to go through or review these articles:
- ASP.NET Core – How To Use Azure App Configuration and Key Vault for Development and Production – Software Consulting – Intertech
- ASP.NET Core – Azure Table Storage – Repository Pattern – Software Consulting – Intertech
- ASP.NET Core – Azure Blob Storage – Repository Pattern – Software Consulting – Intertech
- ASP.NET Core – Azure Queue Storage – MVC and .NET Functions Example Demonstration – Software Consulting – Intertech
To accomplish all of this we will be taking the following steps:
- Create a Development Container
- Confirm Connectivity to Azurite
- Create a new C# Solution
- Create a MVC Application
- Create an App Registration in Azure Active Directory Tenant and Configure the MVC Application
- Create Class Libraries that the MVC Application and Azure Functions can share
- Create an Azure Functions Project
- Set up debuggers for the MVC Application and Azure Function Projects
- Build the Entity Models
- Build the Repository Layer
- Build the Service Layer
- Build the Functions Project
- Build the Web Application
- Test the application locally
- Create Azure Resources:
- Copy settings to Azure App Configuration and Azure Key Vault
- Sign-in to Azure CLI, Setup Container Environment Variables and Setup Web App and Functions Project to read from Azure App Configuration and Key Vault
- Verify App Configuration is being downloaded locally and that your application still runs locally
- Add Environment Variables to the Web App and Functions app in Azure
- Add Configuration and Secrets for production to the App Configuration and Key Vault
- Create and Configure Web Application and Function Application identities to read App Configuration and Key Vault
- Publish to GitHub
- Build GitHub Actions to Deploy to Azure App Services and Functions App from GitHub repository
– Create an Azure Resource Group
– Create an Azure App Configuration
– Create an Azure Key Vault
– Create an Azure Storage Account
– Create an Azure Application App Service Plan
– Create an Azure App Service
– Create an Azure Functions App
Create a Development Container
To begin, let’s…
-
1 – Create a new folder on your computer somewhere. I will use the name of “azure-storage-todos-demo.”
2 – Open the empty folder in Visual Studio Code.
3 – Create a new folder labeled “.devcontrainer”.
In that folder create a new file labeled “Dockerfile”.
Give it the following code:
# [Choice] .NET version: 6.0-focal, 3.1-focal
ARG VARIANT="6.0-focal"
FROM mcr.microsoft.com/vscode/devcontainers/dotnet:0-${VARIANT}
# [Choice] Node.js version: none, lts/*, 18, 16, 14
ARG NODE_VERSION="lts/*"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment this line to install global node packages.
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g azure-functions-core-tools@4 --unsafe-perm true" 2>&1
So, next let’s create a docker-compose.yml file in that .devcontainer folder.
Give it the following code:
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
# Update 'VARIANT' to pick a version of .NET: 3.1-focal, 6.0-focal
VARIANT: "6.0-focal"
# Optional version of Node.js
NODE_VERSION: "lts/*"
volumes:
- ..:/workspace:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:azurite
# Uncomment the next line to use a non-root user for all processes.
# user: vscode
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
azurite:
image: mcr.microsoft.com/azure-storage/azurite
restart: unless-stopped
command: "azurite -d /opt/azurite/azurite_logs.txt"
# Add "forwardPorts": ["1433"] to **devcontainer.json** to forward MSSQL locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
Now, create a devcontainer.json file in that .devcontainer folder.
Give it the following code:
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.238.0/containers/dotnet-mssql
{
"name": "C# (.NET) and Azurite",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Set *default* container specific settings.json values on container create.
"settings": {},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-dotnettools.csharp",
"ms-azuretools.vscode-azurefunctions"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [10000, 10001, 10002],
// [Optional] To reuse of your local HTTPS dev cert:
//
// 1. Export it locally using this command:
// * Windows PowerShell:
// dotnet dev-certs https --trust; dotnet dev-certs https -ep "$env:USERPROFILE/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
// * macOS/Linux terminal:
// dotnet dev-certs https --trust; dotnet dev-certs https -ep "${HOME}/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
//
// 2. Uncomment these 'remoteEnv' lines:
// "remoteEnv": {
// "ASPNETCORE_Kestrel__Certificates__Default__Password": "SecurePwdGoesHere",
// "ASPNETCORE_Kestrel__Certificates__Default__Path": "/home/vscode/.aspnet/https/aspnetapp.pfx",
// },
//
// 3. Next, copy your certificate into the container:
// 1. Start the container
// 2. Drag ~/.aspnet/https/aspnetapp.pfx into the root of the file explorer
// 3. Open a terminal in VS Code and run "mkdir -p /home/vscode/.aspnet/https && mv aspnetapp.pfx /home/vscode/.aspnet/https"
// postCreateCommand.sh parameters: $1=SA password, $2=dacpac path, $3=sql script(s) path
"features": {
"github-cli": "latest",
"azure-cli": "latest"
}
}
- We are adding the csharp plug-in (OmniSharp) as well as the AzureFunctions tool to allow us to be able to debug functions in our development environment
- We are forwarding ports 10000, 10001, 10002 which are the Azureite services Blob, Queue, and Table services. Making it so that we can access those items from the Azure Storage Explorer Via our local computer
- We have also installed the latest github and azure command line interfaces
This should be sufficient to setup a development environment to run and debug our app as well as have Azurite run as a service in a separate container that both our local machine, and app container can access.
Open the command pallet (CTRL+SHIFT+P) and select the “Remote-Containers: Rebuild and Reopen in Container” option.
Confirm Connectivity to Azurite
Launch the Azure Storage Explorer on your computer.
According to https://docs.microsoft.com/en-us/azure/storage/common/storage-use-azurite the default HTTP Connection string is…
DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
Next, select that you want to connect to a Storage account or service
Give it a display name such as “Dev Container”, and then copy and paste the connection string from above.
You will now see the storage account in your available connections.
Double click on it and perform any experiments that you like.
Create a new C# Solution
We will now create a solution file to hold information about all of our projects.
Open a new terminal in Visual Studio Code (CTRL+SHIFT+`) and execute the following commands:
dotnet new sln
mv workspace.sln azure-storage-todos-demo.sln
Create an MVC Web Application
The web application will use Azure Storage in the following ways:
- Read Blobs to get lists of Todos
- Add, Update, and Delete Todos in Tables
- Send Messages to Queues to let the Queue know that a Table item has been modified, and that it is time to rebuild the list of Todos in the Blob Storage
Execute the following commands, assuming that you are still in the /workspace directory:
dotnet new mvc --auth SingleOrg --calls-graph -o AzureStorageTodos.Web
dotnet sln add ./AzureStorageTodos.Web/
dotnet dev-certs https --trust
Create an App Registration in Azure Active Directory Tenant and Configure the MVC Application
In this example we will be creating an application registration using the azure portal @ https://portal.azure.com/
NOTE: – Be sure that you are in a directory that you can manage, and then go to the “Azure Active Directory” resource.
To start with, copy and paste the Domain and TenantId on the Overview page to the appropriate settings in the appsettings.json file.
The supported account types will be: “Accounts in this organization only”.
For Redirect URI select the “Web” option.
For the URI section review the AzureStorageTodos.Web/properties/launchSettings.json file where it has an applicationUrl that will be like “https://localhost:7039;http://localhost:5179”.
NOTE: Your actual settings will probably be different.
Also review your appsettings.json file and the callback path. Combine the https portion of the application url with the callback path in appsettings.json and you will come up with the correct Redirect URI. For me it is https://localhost:7039/signin-oidc
Once the Application Registration has been created, you will be taken to the Overview page for the Application Registration. Copy and paste the Application (client) ID to your appsettings.json file.
-
1 – Click on the “Certificates & secrets” blade.
2 – Click on the “New Client Secret” button.
3 – For the description I will use “Debug Environment”. I will also select the default 6 months expiration.
4 – Copy the “Value” to the ClientSecret in the appsettings.json file
NOTE: –This is also your only time to be able to see this value. If you navigate away from this page, then you will need to delete and recreate this secret.
Create Class Libraries that the MVC Application and Azure Functions can share
Before we go much further let’s create some class libraries that both the Web Application and Azure Functions project will be able to share. We will have a library of entity models, a storage layer, and a service layer.
Assuming that the command prompt is still at /workspace, let’s execute the following commands:
dotnet new classlib -o AzureStorageTodos.Models
dotnet new classlib -o AzureStorageTodos.Repository
dotnet new classlib -o AzureStorageTodos.Service
dotnet sln add ./AzureStorageTodos.Models
dotnet sln add ./AzureStorageTodos.Repository
dotnet sln add ./AzureStorageTodos.Service
Assuming that the terminal is still at /workspace, the commands will be:
cd AzureStorageTodos.Repository
dotnet add reference ../AzureStorageTodos.Models/
cd ..
cd AzureStorageTodos.Service
dotnet add reference ../AzureStorageTodos.Models/
dotnet add reference ../AzureStorageTodos.Repository/
cd ..
cd AzureStorageTodos.Web
dotnet add reference ../AzureStorageTodos.Models/
dotnet add reference ../AzureStorageTodos.Repository/
dotnet add reference ../AzureStorageTodos.Service/
cd ..
Execute the following commands:
cd AzureStorageTodos.Repository
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Data.Tables
dotnet add package Azure.Storage.Queues
cd ..
cd AzureStorageTodos.Web
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Data.Tables
dotnet add package Azure.Storage.Queues
dotnet add package Microsoft.Extensions.Azure
cd ..
Create an Azure Functions Project
The Azure Functions Project will be monitoring the Queue Storage and executing tasks when the messages come in.
Execute the following commands, assuming that you are still in the /workspace directory:
mkdir AzureStorageTodos.AzureFunctions
cd AzureStorageTodos.AzureFunctions
func init (select options 1, and 1)
Execute the following commands, assuming that you are still in the /workspace/AzureStorageTodos.AzureFunctions folder.
func new (select option 1, and then give it the name of TodoUpdated)
Execute the following commands, assuming that you are still in the /workspace/AzureStorageTodos.AzureFunctions folder
dotnet add package Microsoft.Azure.Functions.Extensions
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Azure
dotnet add package Azure.Identity
dotnet add package Azure.Storage.Blobs
dotnet add reference ../AzureStorageTodos.Models/
dotnet add reference ../AzureStorageTodos.Repository/
dotnet add reference ../AzureStorageTodos.Service/
dotnet sln add /workspace/AzureStorageTodos.AzureFunctions/
Set up debuggers for the MVC Application and Azure Function Projects
-
1 – Open “Program.cs” from within the AzureStorageTodos.Web project in Visual Studio Code.
2 – CTRL+SHIFT+P to bring up the command pallet.
3 – Select the option to Restart OmniSharp.
4 – When prompted say “Yes” to add the Debug files.
5 – Modify the tasks.json file as follows:
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/AzureStorageTodos.Web/AzureStorageTodos.Web.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/AzureStorageTodos.Web/AzureStorageTodos.Web.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/AzureStorageTodos.Web/AzureStorageTodos.Web.csproj"
],
"problemMatcher": "$msCompile"
},
{
"label": "clean (functions)",
"options": {
"cwd": "${workspaceFolder}/AzureStorageTodos.AzureFunctions"
},
"command": "dotnet",
"args": [
"clean",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"problemMatcher": "$msCompile"
},
{
"label": "build (functions)",
"options": {
"cwd": "${workspaceFolder}/AzureStorageTodos.AzureFunctions"
},
"command": "dotnet",
"args": [
"build",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "process",
"dependsOn": "clean (functions)",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
"type": "func",
"dependsOn": "build (functions)",
"options": {
"cwd": "${workspaceFolder}/AzureStorageTodos.AzureFunctions/bin/Debug/net6.0"
},
"command": "host start",
"isBackground": true,
"problemMatcher": "$func-dotnet-watch"
}
]
}
- 6 – Modify the launch.json file as follows:
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/AzureStorageTodos.Web/bin/Debug/net6.0/AzureStorageTodos.Web.dll",
"args": [],
"cwd": "${workspaceFolder}/AzureStorageTodos.Web",
"stopAtEntry": false,
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
},
{
"name": "Attach to .NET Functions",
"type": "coreclr",
"request": "attach",
"processId": "${command:azureFunctions.pickProcess}"
},
],
"compounds": [
{
"name": "Attach to functions and launch web app",
"configurations": [".NET Core Launch (web)", "Attach to .NET Functions"],
"stopAll": true
}
]
}
For now, let’s go ahead and make sure that the Attach to function and launch web app is selected, and then press the green button or press F5.
It will launch a browser, and likely ask you to give consent on behalf of your organization to allow the application to run. Check the box to consent on behalf of your organization and click on Accept.
Within your Azure Storage Explorer connect to your development container, and then create a queue called “myqueue-items.”
To test out the debugger for the function go to line 13 within the TodoUpdated.cs file, and press F9 and send another message to the queue.
You will notice that– your Visual Studio Code will go Orange, requesting your attention. Click on it, and you will see some debug information on the left.
To confirm that the debugger is working for the web project as well open the “HomeController.cs” file within the AzureStorageTodos.Web project. Go to line 27 and put a break point there by pressing F9.
Now go to your browser where the web application is running, and press F5 to refresh the page. Visual Studio again should go orange, and you can see all of the debug information on the left.
Now that we have appropriate debuggers setup, let’s go ahead and create the application.
Build the Entity Models
- 1 – To the AzureStorageTodos.Models project, delete Class1.cs.
2 – Add an “Interface” folder.
3 – In the “Interface” folder create a new file called “IBaseAzureStorageEntityModel.cs”.
Give it the following code:
namespace AzureStorageTodos.Models.Interface;
public interface IBaseAzureStorageEntityModel
{
public string Id {get; set;}
}
using AzureStorageTodos.Models.Interface;
namespace AzureStorageTodos.Models;
public class BaseAzureStorageEntityModel : IBaseAzureStorageEntityModel
{
public BaseAzureStorageEntityModel()
{
this.Id = Guid.NewGuid().ToString();
}
public BaseAzureStorageEntityModel(string id)
{
this.Id = id;
}
public string Id {get; set;}
}
- The User entity
- The Todo entity
- List of Todos entity
Users will be stored in Tables. Todos will be stored in Tables. List of Todos will be stored in blobs. Their classes will all inherit from our BaseAzureSTorageEntityModel class.
Create a new file called “UserEntityModel.cs”. Give it the following code:
namespace AzureStorageTodos.Models;
public class UserEntityModel : BaseAzureStorageEntityModel
{
public string DisplayName {get; set;} = null!;
public string EmailAddress {get; set;} = null!;
}
namespace AzureStorageTodos.Models;
public class TodoEntityModel : BaseAzureStorageEntityModel
{
public string OwnerUserId {get; set;} = null!;
public string? Title { get; set; }
public string? Description { get; set; }
public bool Completed { get; set; } = false;
}
namespace AzureStorageTodos.Models;
public class TodoListEntityModel : BaseAzureStorageEntityModel
{
public TodoListEntityModel(string ownerUserId, string listType)
{
this.Id = $"{ownerUserId}-{listType}";
this.OwnerUserId = ownerUserId;
this.Results = new List<TodoEntityModel>();
}
public string OwnerUserId {get; set;} = null!;
public IEnumerable<TodoEntityModel> Results {get; set;} = null!;
}
Build the Repository Layer
- 1 – To the AzureStorageTodos.Repository project delete Class1.cs.
2 – Create an “Interface” folder.
3 – In the “Interface” folder create a new file called “IAzureStorageRepository.cs”.
Give it the following code:
using AzureStorageTodos.Models.Interface;
namespace AzureStorageTodos.Repository.Interface;
public interface IAzureStorageRepository<T> where T : IBaseAzureStorageEntityModel
{
Task<T?> UpsertAsync(T entityDetails);
Task<T?> GetOneAsync(string id);
Task<List<T>?> GetAllAsync();
Task DeleteAsync(string id);
}
- 4 – Create a new folder in the root of the AzureStorageTodos.Repository project called “BaseRepositories”
5 – Create a new file in the “BaseRepositories” folder called “AzureTableStorageRepository.cs”.
Give it the following code:
using Azure;
using Azure.Data.Tables;
using System.Reflection;
using AzureStorageTodos.Models.Interface;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.BaseRepositories;
public abstract class AzureTableStorageRepository<T> : IAzureStorageRepository<T> where T : IBaseAzureStorageEntityModel
{
private readonly TableServiceClient _tableServiceClient;
private readonly string _tableName;
private readonly string _partitionKey;
public AzureTableStorageRepository(TableServiceClient tableServiceClient, string tableName, string partitionKey)
{
_tableServiceClient = tableServiceClient;
_tableName = tableName;
_partitionKey = partitionKey;
}
public async Task DeleteAsync(string id)
{
var tableClient = await GetTableClientAsync();
await tableClient.DeleteEntityAsync(_partitionKey, id);
}
public async Task<List<T>?> GetAllAsync()
{
var tableClient = await GetTableClientAsync();
var results = new List<T>();
Pageable<TableEntity> queryResults = tableClient.Query<TableEntity>(w => w.PartitionKey == _partitionKey);
foreach (var qEntity in queryResults)
{
if (qEntity is not null)
{
var result = TableEntityToEntity(qEntity);
if (result is not null)
{
results.Add(result);
}
}
}
return results;
}
public async Task<T?> GetOneAsync(string id)
{
var tableClient = await GetTableClientAsync();
var tableEntity = await tableClient.GetEntityAsync<TableEntity>(_partitionKey, id);
if (tableEntity is not null)
{
var result = TableEntityToEntity(tableEntity);
if (result is not null)
{
return result;
}
}
return default(T);
}
public async Task<T?> UpsertAsync(T entityDetails)
{
var tableClient = await GetTableClientAsync();
var tableEntity = EntityToTableEntity(entityDetails);
await tableClient.UpsertEntityAsync(tableEntity);
return entityDetails;
}
private TableEntity EntityToTableEntity(T entity)
{
var result = new TableEntity();
result.PartitionKey = _partitionKey;
Type t = typeof(T);
PropertyInfo[] entityProperties = t.GetProperties();
foreach (var entityProperty in entityProperties)
{
var propertyName = entityProperty.Name;
if (propertyName == "Id")
{
result.RowKey = entity.Id;
}
else
{
var propertyValue = entityProperty.GetValue(entity);
result[propertyName] = propertyValue;
}
}
return result;
}
private T TableEntityToEntity(TableEntity tableEntity)
{
var result = (T)Activator.CreateInstance<T>();
Type t = typeof(T);
PropertyInfo[] entityProperties = t.GetProperties();
foreach (var entityProperty in entityProperties)
{
var propertyName = entityProperty.Name;
if (propertyName == "Id")
{
result.Id = tableEntity.RowKey;
}
else
{
var propertyValue = tableEntity[propertyName];
entityProperty.SetValue(result, propertyValue);
}
}
return result;
}
private async Task<TableClient> GetTableClientAsync()
{
var tableClient = _tableServiceClient.GetTableClient(_tableName);
await tableClient.CreateIfNotExistsAsync();
return tableClient;
}
}
This constructor will expect a tableServiceClient, tableName, and partitionKey to be passed into it. The tableServiceClient will be injected via Dependency injection in the Startup class of the Web project. We will cover that later. The table name will tell this repository which Azure Storage Table to use, and the partition key will be the default Partition Key to use for all of the T type entities. Since these are strings you can get creative. You may want each entity in its own table. You may want all of the entities in one table, but a different partition key. You may want some combination of both. The only thing that truly matters is that each entity has a unique combination of TableName and RowKey when this class is initialized.
The GetTableClientAsync private method is used for every public method of this class. It is used to establish the connection to Azure Table Storage and returns a TableClient. The main responsibility of the TableClient is to interact with TableEntities. The EntityToTableEntity and TableEntityToEntity method use reflections to convert an Azure TableEntity to our own custom entities that we pass to this class as a Type Paramater. And as long as that type inherits from the IBaseStorageEntityModel interface it can be used in this abstract class. An important thing that happens during the conversion aside from copying all of the properties is that when the property is the Id property then it will be the RowKey property for the TableEntity. Similarly, when the TableEntity is being converted to the standard entity the Id is set to the RowKey from the TableEntity.
We need a similar method to make a Blob repository.
In the “BaseRepositories” folder create a new file called “BlobStorageService.cs”. This will give us additional abstraction to make it easier for us to interact with Blobs for the purpose of a repository later.
Give it the following code:
using Azure.Storage.Blobs;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.BaseRepositories;
public class BlobStorageService : IBlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly string _containerName;
public BlobStorageService(BlobServiceClient blobServiceClient, string containerName)
{
_blobServiceClient = blobServiceClient;
_containerName = containerName;
}
public async Task DeleteDocumentAsync(string blobName)
{
var containerClient = await GetContainerClientAsync();
var blobClient = containerClient.GetBlobClient(blobName);
await blobClient.DeleteAsync();
}
public async Task<Stream> GetBlobContentAsync(string blobName)
{
var containerClient = await GetContainerClientAsync();
var blobClient = containerClient.GetBlobClient(blobName);
return await blobClient.OpenReadAsync();
}
public async Task<List<string>?> GetListOfBlobsInFolderAsync(string folderName)
{
var results = new List<string>();
var containerClient = await GetContainerClientAsync();
var blobsInFolder = containerClient.GetBlobs(prefix: folderName);
if(blobsInFolder is not null) {
foreach(var blob in blobsInFolder) {
results.Add(blob.Name);
}
return results;
}
return null;
}
public async Task UpdateBlobContentAsync(string blobName, Stream content)
{
var containerClient = await GetContainerClientAsync();
var blobClient = containerClient.GetBlobClient(blobName);
await blobClient.UploadAsync(content, true);
}
private async Task<BlobContainerClient> GetContainerClientAsync()
{
var blobContainerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
await blobContainerClient.CreateIfNotExistsAsync();
return blobContainerClient;
}
}
Now to officially create the BlobStorageRepository create a file called “AzureBlobStorageRepository.cs” in the “BaseRepositories” folder.
Give it the following code:
using System.Text.Json;
using Azure.Storage.Blobs;
using AzureStorageTodos.Models.Interface;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.BaseRepositories;
public abstract class AzureBlobStorageRepository<T> : IAzureStorageRepository<T> where T : IBaseAzureStorageEntityModel
{
private readonly IBlobStorageService _blobStorageService;
private readonly string _folderName;
public AzureBlobStorageRepository(BlobServiceClient blobServiceClient, string containerName, string folderName)
{
_blobStorageService = new BlobStorageService(blobServiceClient, containerName);
_folderName = folderName;
}
public async Task DeleteAsync(string id)
{
string filePath = GetFilePath(id);
await _blobStorageService.DeleteDocumentAsync(filePath);
}
public async Task<List<T>?> GetAllAsync()
{
try {
List<string>? filesInFolder = await _blobStorageService.GetListOfBlobsInFolderAsync(_folderName);
if(filesInFolder is not null) {
var results = new List<T>();
foreach(var file in filesInFolder) {
var id = GetIdFromFilePath(file);
try {
var result = await this.GetOneAsync(id);
if(result is not null) {
results.Add(result);
}
}
catch {
// Do nothing, try to get the next one
}
}
return results;
}
}
catch {
// Do nothing, will return null
}
return null;
}
public async Task<T?> GetOneAsync(string id)
{
try {
string filePath = GetFilePath(id);
var blobContent = await _blobStorageService.GetBlobContentAsync(filePath);
var contentAsObject = JsonSerializer.Deserialize<T>(blobContent);
return contentAsObject;
}
catch {
// Do nothing, will return null
}
return default(T);
}
public async Task<T?> UpsertAsync(T entityDetails)
{
try {
string filePath = GetFilePath(entityDetails.Id);
var entityDetailsAsString = JsonSerializer.Serialize(entityDetails, new JsonSerializerOptions { WriteIndented = true });
if(entityDetails is not null) {
var entityDetailsAsStream = new MemoryStream();
var streamWriter = new StreamWriter(entityDetailsAsStream);
streamWriter.Write(entityDetailsAsString);
streamWriter.Flush();
entityDetailsAsStream.Position = 0;
await _blobStorageService.UpdateBlobContentAsync(filePath, entityDetailsAsStream);
return entityDetails;
}
}
catch {
// Do nothing, return null
}
return default(T);
}
private string GetFilePath(string id)
{
string filePath = string.Join('/', _folderName, $"{id}.json");
return filePath;
}
private string GetIdFromFilePath(string filePath)
{
string[] fileParts = filePath.Split('/');
string lastPart = fileParts.Last();
string[] fileNameParts = lastPart.Split('.');
string idPart = fileNameParts.First();
return idPart;
}
}
With all of this in place we are now ready to create repositories for our Entity Model Classes.
In the “Interface” folder create a new file called “IUsersRepository.cs”.
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Repository.Interface;
public interface IUsersRepository : IAzureStorageRepository<UserEntityModel>
{
}
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Repository.Interface;
public interface ITodosRepository : IAzureStorageRepository<TodoEntityModel>
{
}
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Repository.Interface;
public interface ITodoListRepository : IAzureStorageRepository<TodoListEntityModel>
{
}
To the root of the AzureStorageTods.Repository project create a new file called “StaticBlobContainerNames.cs”.
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticBlobContainerNames
{
public const string TodoLists = "todo-lists";
}
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticBlobFolderNames
{
public const string TodoListItems = "todolist-items";
}
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticTableNames
{
public const string TodoUsers = "TodoUsers";
public const string Todos = "Todos";
}
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticTableRowKeys
{
public const string UserItems = "UserItems";
public const string TodoItems = "TodoItems";
}
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticQueueNames
{
public const string TodoUpdated = "TodoUpdated";
}
Give it the following code:
namespace AzureStorageTodos.Repository;
public static class StaticTodoListIds
{
public const string All = "All";
public const string Complete = "Complete";
public const string Incomplete = "Incomplete";
}
To the “Repositories” folder create a new file called “UsersRepository.cs”.
Give it the following code:
using Azure.Data.Tables;
using AzureStorageTodos.Models;
using AzureStorageTodos.Repository.BaseRepositories;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.Repositories;
public class UsersRepository : AzureTableStorageRepository<UserEntityModel>, IUsersRepository
{
public UsersRepository(TableServiceClient tableServiceClient) : base(tableServiceClient, StaticTableNames.TodoUsers, StaticTableRowKeys.UserItems)
{
}
}
Give it the following code:
using Azure.Data.Tables;
using AzureStorageTodos.Models;
using AzureStorageTodos.Repository.BaseRepositories;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.Repositories;
public class TodosRepository : AzureTableStorageRepository<TodoEntityModel>, ITodosRepository
{
public TodosRepository(TableServiceClient tableServiceClient) : base(tableServiceClient, StaticTableNames.Todos, StaticTableRowKeys.TodoItems)
{
}
}
Give it the following code:
using Azure.Storage.Blobs;
using AzureStorageTodos.Models;
using AzureStorageTodos.Repository.BaseRepositories;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.Repositories;
public class TodoListRepository : AzureBlobStorageRepository<TodoListEntityModel>, ITodoListRepository
{
public TodoListRepository(BlobServiceClient blobServiceClient) : base(blobServiceClient, StaticBlobContainerNames.TodoLists, StaticBlobFolderNames.TodoListItems)
{
}
}
To the “Interface” folder create a new file called “IAzureQueueStorageService.cs”.
Give it the following code:
namespace AzureStorageTodos.Repository.Interface;
public interface IAzureQueueStorageService
{
public Task SendMessageAsync(string message);
}
Give it the following code:
using Azure.Storage.Queues;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Repository.BaseRepositories;
public abstract class AzureQueueStorageService : IAzureQueueStorageService
{
private readonly QueueServiceClient _queueServiceClient;
private readonly string _queueName;
public AzureQueueStorageService(QueueServiceClient queueServiceClient, string queueName)
{
_queueServiceClient = queueServiceClient;
_queueName = queueName;
}
public async Task SendMessageAsync(string message)
{
var queueClient = await GetQueueClientAsync();
await queueClient.SendMessageAsync(message);
}
private async Task<QueueClient> GetQueueClientAsync()
{
var queueClient = _queueServiceClient.GetQueueClient(_queueName);
await queueClient.CreateIfNotExistsAsync();
return queueClient;
}
}
Give it the following code:
namespace AzureStorageTodos.Repository.Interface;
public interface ITodoUpdatedQueue : IAzureQueueStorageService
{
}
Give it the following code:
using Azure.Storage.Queues;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Repository.BaseRepositories;
namespace AzureStorageTodos.Repository.Repositories;
public class TodoUpdatedQueue : AzureQueueStorageService, ITodoUpdatedQueue
{
public TodoUpdatedQueue(QueueServiceClient queueServiceClient) : base (queueServiceClient, StaticQueueNames.TodoUpdated)
{
}
}
Build the Service Layer
- 1 – To the AzureStorageTodos.Service project, delete Class1.cs.
2 – Create a new folder called “Interface”.
3 – In the “Interface” folder create a new file called ” IAzureStorageRepositoryClient.cs”.
Give it the following code.
using AzureStorageTodos.Models.Interface;
namespace AzureStorageTodos.Service.Interface;
public interface IAzureStorageRepositoryClient<T> where T : IBaseAzureStorageEntityModel
{
Task<T?> UpsertAsync(T entityDetails);
Task<T?> GetOneAsync(string id);
Task<List<T>?> GetAllAsync();
Task DeleteAsync(string id);
}
In the “BaseRepositoryClients” folder create a new file called “AzureStorageRepositoryClient.cs”.
Give it the following code:
using AzureStorageTodos.Models.Interface;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Service.Interface;
namespace AzureStorageTodos.Service.BaseRepositoryClients;
public abstract class AzureStorageRepsoitoryClient<T> : IAzureStorageRepositoryClient<T> where T : IBaseAzureStorageEntityModel
{
private readonly IAzureStorageRepository<T> _repository;
public AzureStorageRepsoitoryClient(IAzureStorageRepository<T> repository)
{
_repository = repository;
}
public virtual Task DeleteAsync(string id)
{
try {
return _repository.DeleteAsync(id);
}
catch {
// Do nothing
}
return Task.CompletedTask;
}
public virtual async Task<List<T>?> GetAllAsync()
{
try {
return await _repository.GetAllAsync();
}
catch {
// Do nothing, return null
}
return null;
}
public virtual async Task<T?> GetOneAsync(string id)
{
try {
return await _repository.GetOneAsync(id);
}
catch {
// Do nothing, return null
}
return default(T);
}
public virtual async Task<T?> UpsertAsync(T entityDetails)
{
try {
var results = await _repository.UpsertAsync(entityDetails);
return results;
}
catch {
// Do nothing, return null
}
return default(T);
}
}
We now need to create repository clients for each entity.
To the “Interface” folder create a new file called “IUsersRepositoryClient.cs”.
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Service.Interface;
public interface IUsersRepositoryClient : IAzureStorageRepositoryClient<UserEntityModel>
{
}
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Service.Interface;
public interface ITodosRepositoryClient : IAzureStorageRepositoryClient<TodoEntityModel>
{
}
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Service.Interface;
public interface ITodoListRepositoryClient : IAzureStorageRepositoryClient<TodoListEntityModel>
{
}
- 1 – Create a new folder called “RepositoryClients”.
2 – Create a new file called “UsersRepositoryClient.cs”.
Give it the following code:
using AzureStorageTodos.Models;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.BaseRepositoryClients;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Service.RepositoryClients;
public class UsersRepositoryClient : AzureStorageRepsoitoryClient<UserEntityModel>, IUsersRepositoryClient
{
public UsersRepositoryClient(IUsersRepository usersRepository) : base(usersRepository)
{
}
}
Give it the following code:
using AzureStorageTodos.Models;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.BaseRepositoryClients;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Service.RepositoryClients;
public class TodosRepositoryClient : AzureStorageRepsoitoryClient<TodoEntityModel>, ITodosRepositoryClient
{
public TodosRepositoryClient(ITodosRepository todosRepository) : base(todosRepository)
{
}
}
Give it the following code:
using AzureStorageTodos.Models;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.BaseRepositoryClients;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Service.RepositoryClients;
public class TodoListRepositoryClient : AzureStorageRepsoitoryClient<TodoListEntityModel>, ITodoListRepositoryClient
{
public TodoListRepositoryClient(ITodoListRepository todoListRepository) : base(todoListRepository)
{
}
}
In the “Interface” folder create a new file called “ITodoListManager.cs”.
Give it the following code:
namespace AzureStorageTodos.Service.Interface;
public interface ITodoListManager
{
public Task RefreshTodoListsForUserAsync(string userId);
}
In the “Services” folder create a new file called “TodoListManager.cs”.
Give it the following code:
using AzureStorageTodos.Models;
using AzureStorageTodos.Repository;
using AzureStorageTodos.Service.Interface;
namespace AzureStorageTodos.Service.Services;
public class TodoListManager : ITodoListManager
{
private readonly ITodosRepositoryClient _todosRepositoryClient;
private readonly ITodoListRepositoryClient _todoListRepositoryClient;
public TodoListManager(ITodosRepositoryClient todosRepositoryClient, ITodoListRepositoryClient todoListRepositoryClient)
{
_todosRepositoryClient = todosRepositoryClient;
_todoListRepositoryClient = todoListRepositoryClient;
}
public async Task RefreshTodoListsForUserAsync(string userId)
{
var allTodosInRepository = await _todosRepositoryClient.GetAllAsync();
if(allTodosInRepository is not null)
{
var allTodosForUser = allTodosInRepository.Where(w => w.OwnerUserId == userId).ToList();
var allTodosListEntity = new TodoListEntityModel(userId, StaticTodoListIds.All);
var completedTodosListEntity = new TodoListEntityModel(userId, StaticTodoListIds.Complete);
var incompleteTodosListEntity = new TodoListEntityModel(userId, StaticTodoListIds.Incomplete);
if(allTodosForUser is not null)
{
allTodosListEntity.Results = allTodosForUser;
completedTodosListEntity.Results = allTodosForUser.Where(w => w.Completed == true).ToList();
incompleteTodosListEntity.Results = allTodosForUser.Where(w => w.Completed == false).ToList();
await _todoListRepositoryClient.UpsertAsync(allTodosListEntity);
await _todoListRepositoryClient.UpsertAsync(completedTodosListEntity);
await _todoListRepositoryClient.UpsertAsync(incompleteTodosListEntity);
}
}
}
}
Modify the TodosRepositoryClient.cs file to be as such:
using AzureStorageTodos.Models;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.BaseRepositoryClients;
using AzureStorageTodos.Repository.Interface;
namespace AzureStorageTodos.Service.RepositoryClients;
public class TodosRepositoryClient : AzureStorageRepsoitoryClient<TodoEntityModel>, ITodosRepositoryClient
{
private readonly ITodosRepository _todosRepository;
private readonly ITodoUpdatedQueue _todoUpdatedQueue;
public TodosRepositoryClient(ITodosRepository todosRepository, ITodoUpdatedQueue todoUpdatedQueue) : base(todosRepository)
{
_todosRepository = todosRepository;
_todoUpdatedQueue = todoUpdatedQueue;
}
public override async Task<TodoEntityModel?> UpsertAsync(TodoEntityModel entityDetails)
{
var results = await base.UpsertAsync(entityDetails);
await _todoUpdatedQueue.SendMessageAsync($"Todo Updated For User: {entityDetails.OwnerUserId}");
return results;
}
public override async Task DeleteAsync(string id)
{
var currentDetails = await this.GetOneAsync(id);
if(currentDetails is not null)
{
await base.DeleteAsync(id);
await _todoUpdatedQueue.SendMessageAsync($"Todo Updated For User: {currentDetails.OwnerUserId}");
}
}
}
Build the Functions Project
The “TodoUpdated” queue trigger that we created earlier will need access to the TodoListManager service that we created earlier. In order for it to have access to that we need to implement dependency injection. Earlier we included the packages to allow us to do it. So, Let’s make it happen now.
To the AzureStorageTodos.AzureFunctions project add a new file called “Startup.cs”.
Give it the following code:
using Azure.Identity;
using Microsoft.Extensions.Azure;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Repository.Repositories;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.RepositoryClients;
using AzureStorageTodos.Service.Services;
[assembly: FunctionsStartup(typeof(AzureStorageTodos.AzureFunctions.Startup))]
namespace AzureStorageTodos.AzureFunctions;
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
var config = builder.GetContext().Configuration;
string azureWebJobsStorage = config.GetValue<string>("AzureWebJobsStorage");
// Register Azure Clients
builder.Services.AddAzureClients(azureClientsBuilder => {
azureClientsBuilder.AddTableServiceClient(azureWebJobsStorage);
azureClientsBuilder.AddBlobServiceClient(azureWebJobsStorage);
azureClientsBuilder.UseCredential(new DefaultAzureCredential());
});
// Register Repositories
builder.Services.AddTransient<ITodosRepository, TodosRepository>();
builder.Services.AddTransient<ITodoListRepository, TodoListRepository>();
// Register Services
builder.Services.AddTransient<ITodosRepositoryClient, TodosRepositoryClient>();
builder.Services.AddTransient<ITodoListRepositoryClient, TodoListRepositoryClient>();
builder.Services.AddTransient<ITodoListManager, TodoListManager>();
}
}
Modify the TodoUpdated.cs file with the following code:
using System;
using System.Linq;
using System.Threading.Tasks;
using AzureStorageTodos.Repository;
using AzureStorageTodos.Service.Interface;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Host;
using Microsoft.Extensions.Logging;
namespace AzureStorageTodos.AzureFunctions
{
public class TodoUpdated
{
private readonly ITodoListManager _todoListManager;
public TodoUpdated(ITodoListManager todoListManager)
{
_todoListManager = todoListManager;
}
[FunctionName("TodoUpdated")]
public async Task Run([QueueTrigger(StaticQueueNames.TodoUpdated, Connection = "AzureWebJobsStorage")]string queueMessage, ILogger log)
{
string[] queueMessageParts = queueMessage.Split(": ");
string updatedOwnerUserId = queueMessageParts.Last();
await _todoListManager.RefreshTodoListsForUserAsync(updatedOwnerUserId);
}
}
}
When it comes to the new Run method, we converted it to an async function. As you may recall (from the TodosRepositoryClient.cs Service file) that we will be sending the message of “Todo Updated For User: SomeUserId”. So, we are parsing the string, and just grabbing the Id portion of that message, and then using the RefreshToDoListForUserAsync method from the TodoListManager service with the provided user id.
Build the Web Application
One of the first things that we want to do is create a user record any time someone logs in to our system. To do so begin by creating an WebAppOpenIdConnectEvents.cs file in the root of the AzureStorageTodos.Web project.
Give it the following code:
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using AzureStorageTodos.Models;
using AzureStorageTodos.Service.Interface;
public class WebAppOpenIdConnectEvents : OpenIdConnectEvents
{
public WebAppOpenIdConnectEvents()
{
this.OnTicketReceived = async ctx => {
if(ctx.Principal is not null)
{
var myPrincipal = ctx.Principal;
var uidClaim = myPrincipal.Claims.FirstOrDefault(w => w.Type == "uid");
var nameClaim = myPrincipal.Claims.FirstOrDefault(w => w.Type == "name");
var emailClaim = myPrincipal.Claims.FirstOrDefault(w => w.Type == "preferred_username");
if (uidClaim is not null && nameClaim is not null && emailClaim is not null)
{
var usersRepositoryClient = ctx.HttpContext.RequestServices.GetRequiredService<IUsersRepositoryClient>();
var userEntity = new UserEntityModel
{
Id = uidClaim.Value,
DisplayName = nameClaim.Value,
EmailAddress = emailClaim.Value
};
await usersRepositoryClient.UpsertAsync(userEntity);
}
}
await Task.Yield();
};
}
}
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using Graph = Microsoft.Graph;
using Microsoft.Extensions.Azure;
using Azure.Identity;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Repository.BaseRepositories;
using AzureStorageTodos.Repository.Repositories;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.RepositoryClients;
using AzureStorageTodos.Service.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
var initialScopes = builder.Configuration["DownstreamApi:Scopes"]?.Split(' ');
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options => {
options.Instance = builder.Configuration["AzureAd:Instance"];
options.Domain = builder.Configuration["AzureAd:Domain"];
options.TenantId = builder.Configuration["AzureAd:TenantId"];
options.ClientId = builder.Configuration["AzureAd:ClientId"];
options.ClientSecret = builder.Configuration["AzureAd:ClientSecret"];
options.CallbackPath = builder.Configuration["AzureAd:CallbackPath"];
options.Events = new WebAppOpenIdConnectEvents();
})
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
builder.Services.AddControllersWithViews(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
builder.Services.AddRazorPages()
.AddMicrosoftIdentityUI();
// Register Azure Clients
builder.Services.AddAzureClients(azureClientsBuilder => {
string azureStorageConnectionString = builder.Configuration.GetConnectionString("AzureStorage");
azureClientsBuilder.AddTableServiceClient(azureStorageConnectionString);
azureClientsBuilder.AddBlobServiceClient(azureStorageConnectionString);
azureClientsBuilder.AddQueueServiceClient(azureStorageConnectionString);
azureClientsBuilder.UseCredential(new DefaultAzureCredential());
});
// Register Repositories
builder.Services.AddTransient<IUsersRepository, UsersRepository>();
builder.Services.AddTransient<ITodosRepository, TodosRepository>();
builder.Services.AddTransient<ITodoListRepository, TodoListRepository>();
builder.Services.AddTransient<ITodoUpdatedQueue, TodoUpdatedQueue>();
// Register Services
builder.Services.AddTransient<IUsersRepositoryClient, UsersRepositoryClient>();
builder.Services.AddTransient<ITodosRepositoryClient, TodosRepositoryClient>();
builder.Services.AddTransient<ITodoListRepositoryClient, TodoListRepositoryClient>();
builder.Services.AddTransient<ITodoListManager, TodoListManager>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.Run();
Now let’s continue to build the web application. Create a “TodosHomePageViewModel.cs” file within the “Models” folder of the AzureStorageTodos.Web project.
Give it the following code:
using AzureStorageTodos.Models;
namespace AzureStorageTodos.Web.Models;
public class TodosHomePageViewModel
{
public IEnumerable<TodoEntityModel>? AllTodos {get; set;}
public IEnumerable<TodoEntityModel>? CompletedTodos {get; set;}
public IEnumerable<TodoEntityModel>? IncompleteTodos {get; set;}
}
Give it the following code:
using AzureStorageTodos.Models;
using AzureStorageTodos.Repository;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AzureStorageTodos.Web.Controllers;
[Authorize]
public class TodosController : Controller
{
private readonly ITodoListRepositoryClient _todoListRepositoryClient;
private readonly ITodosRepositoryClient _todosRepositoryClient;
public TodosController(ITodoListRepositoryClient todoListRepositoryClient, ITodosRepositoryClient todosRepositoryClient)
{
_todoListRepositoryClient = todoListRepositoryClient;
_todosRepositoryClient = todosRepositoryClient;
}
[HttpGet]
public async Task<IActionResult> Index()
{
TodosHomePageViewModel todosHomePageViewModel = new TodosHomePageViewModel();
var currentUser = this.HttpContext.User;
var currentUserUserIdClaim = currentUser.Claims.Where(w => w.Type == "uid").FirstOrDefault();
if(currentUserUserIdClaim is not null)
{
var currentUserUserIdValue = currentUserUserIdClaim.Value;
var allFileId = $"{currentUserUserIdValue}-{StaticTodoListIds.All}";
var completedFileId = $"{currentUserUserIdValue}-{StaticTodoListIds.Complete}";
var incompleteFileId = $"{currentUserUserIdValue}-{StaticTodoListIds.Incomplete}";
var allTodos = await _todoListRepositoryClient.GetOneAsync(allFileId);
var completedTods = await _todoListRepositoryClient.GetOneAsync(completedFileId);
var incompleteTodos = await _todoListRepositoryClient.GetOneAsync(incompleteFileId);
if(allTodos is not null)
{
todosHomePageViewModel.AllTodos = allTodos.Results;
}
if(completedTods is not null)
{
todosHomePageViewModel.CompletedTodos = completedTods.Results;
}
if(incompleteTodos is not null)
{
todosHomePageViewModel.IncompleteTodos = incompleteTodos.Results;
}
}
return View(todosHomePageViewModel);
}
[HttpGet]
public async Task<IActionResult> Details(string id)
{
var currentDetails = await _todosRepositoryClient.GetOneAsync(id);
if(currentDetails is null)
{
return NotFound();
}
return View(currentDetails);
}
[HttpGet]
public IActionResult Create()
{
TodoEntityModel newTodoEntityModel = new TodoEntityModel();
var currentUser = this.HttpContext.User;
var currentUserUserIdClaim = currentUser.Claims.Where(w => w.Type == "uid").FirstOrDefault();
if(currentUserUserIdClaim is not null)
{
var currentUserUserIdValue = currentUserUserIdClaim.Value;
newTodoEntityModel.OwnerUserId = currentUserUserIdValue;
}
return View(newTodoEntityModel);
}
[HttpPost]
public async Task<IActionResult> Create(TodoEntityModel todoEntityModel)
{
if(ModelState.IsValid)
{
await _todosRepositoryClient.UpsertAsync(todoEntityModel);
return RedirectToAction(nameof(Index));
}
return View(todoEntityModel);
}
[HttpGet]
public async Task<IActionResult> Edit([FromRoute] string id)
{
var currentDetails = await _todosRepositoryClient.GetOneAsync(id);
if(currentDetails is null)
{
return NotFound();
}
return View(currentDetails);
}
[HttpPost]
public async Task<IActionResult> Edit([FromForm] TodoEntityModel todoEntityModel)
{
if(ModelState.IsValid)
{
await _todosRepositoryClient.UpsertAsync(todoEntityModel);
return RedirectToAction(nameof(Index));
}
return View(todoEntityModel);
}
[HttpGet]
public async Task<IActionResult> Delete([FromRoute] string id)
{
var currentDetails = await _todosRepositoryClient.GetOneAsync(id);
if(currentDetails is null)
{
return NotFound();
}
return View(currentDetails);
}
[HttpPost]
public async Task<IActionResult> Delete([FromForm] TodoEntityModel todoEntityModel)
{
await _todosRepositoryClient.DeleteAsync(todoEntityModel.Id);
return RedirectToAction(nameof(Index));
}
}
To the “View” folder and the “Shared” folder, create a new folder called “DisplayTemplates”.
Create a new file called “ListOfTodosListItem.cshtml”.
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
<tr>
<td>@Html.DisplayFor(m => m.Title)</td>
<td>@Html.DisplayFor(m => m.Description)</td>
<td>@Html.DisplayFor(m => m.Completed)</td>
<td>
<a asp-action="Details" asp-controller="Todos" asp-route-id="@Model.Id">Details</a> |
<a asp-action="Edit" asp-controller="Todos" asp-route-id="@Model.Id">Edit</a> |
<a asp-action="Delete" asp-controller="Todos" asp-route-id="@Model.Id">Delete</a>
</td>
</tr>
Give it the following code:
@model List<AzureStorageTodos.Models.TodoEntityModel>
<table class="table table-bordered">
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Completed</th>
<th> </th>
</tr>
</thead>
<tbody>
@foreach(var todo in Model)
{
if(todo is not null)
{
@Html.DisplayFor(m => todo, "ListOfTodosListItem")
}
}
</tbody>
</table>
Give it the following code:
@model AzureStorageTodos.Web.Models.TodosHomePageViewModel
<ul class="nav nav-tabs" id="TodoListsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#AllTodos" type="button" role="tab" aria-controls="AllTodos" aria-selected="true">All</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="completed-tab" data-bs-toggle="tab" data-bs-target="#CompletedTodos" type="button" role="tab" aria-controls="CompletedTodos">Completed</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="incomplete-tab" data-bs-toggle="tab" data-bs-target="#IncompleteTodos" type="button" role="tab" aria-controls="IncompleteTodos">Incomplete</button>
</li>
</ul>
<div class="tab-content p-4" id="TodosListContent">
<div class="tab-pane fade show active" id="AllTodos" role="tabpanel" aria-labelledby="all-tab">
@if(Model.AllTodos is not null)
{
@Html.DisplayFor(m => m.AllTodos, "ListOfTodos")
}
</div>
<div class="tab-pane fade" id="CompletedTodos" role="tabpanel" aria-labelledby="completed-tab">
@if(Model.CompletedTodos is not null)
{
@Html.DisplayFor(m => m.CompletedTodos, "ListOfTodos")
}
</div>
<div class="tab-pane fade" id="IncompleteTodos" role="tabpanel" aria-labelledby="incomplete-tab">
@if(Model.IncompleteTodos is not null)
{
@Html.DisplayFor(m => m.IncompleteTodos, "ListOfTodos")
}
</div>
</div>
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
<dl>
<dt>@Html.DisplayNameFor(m => m.Id)</dt>
<dd>@Html.DisplayFor(m => m.Id)</dd>
<dt>@Html.DisplayNameFor(m => m.OwnerUserId)</dt>
<dd>@Html.DisplayFor(m => m.OwnerUserId)</dd>
<dt>@Html.DisplayNameFor(m => m.Title)</dt>
<dd>@Html.DisplayFor(m => m.Title)</dd>
<dt>@Html.DisplayNameFor(m => m.Description)</dt>
<dd>@Html.DisplayFor(m => m.Description)</dd>
<dt>@Html.DisplayNameFor(m => m.Completed)</dt>
<dd>@Html.DisplayFor(m => m.Completed)</dd>
</dl>
To the new “EditorTemplates” folder create a new file called “TodoEntityModel.cshtml”.
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
@Html.HiddenFor(m => m.OwnerUserId)
<div class="form-group">
<label asp-for="Id"></label>
<input class="form-control" asp-for="Id" readonly />
</div>
<div class="form-group">
<label asp-for="Title"></label>
<input class="form-control" asp-for="Title" />
</div>
<div class="form-group">
<label asp-for="Description"></label>
<textarea class="form-control" asp-for="Description"></textarea>
</div>
<div class="form-group">
<label asp-for="Completed"></label>
@Html.EditorFor(m => m.Completed)
</div>
<input class="btn btn-primary" type="submit" value="Save" />
In the new “Todos” folder create a new file called “Index.cshtml”.
Give it the following code:
@model AzureStorageTodos.Web.Models.TodosHomePageViewModel
@{
ViewData["Title"] = "Todos";
}
<h1>@ViewData["Title"]</h1>
<a class="btn btn-primary mb-4" asp-action="Create" asp-controller="Todos">Create</a>
<p class="text-info mb-4">* If you recently made changes to a Todo, the effects may not be immediate while the Azure Function Rebuilds the List. Refresh the page a few times after making changes.</p>
@Html.DisplayForModel()
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
@{
ViewData["Title"] = "Todo Details";
}
<h1>@ViewData["Title"]</h1>
<a class="mb-4" asp-action="Index" asp-controller="Todos">Return to List</a>
@Html.DisplayForModel()
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
@{
ViewData["Title"] = "Delete Todo";
}
<h1>@ViewData["Title"]</h1>
<a class="mb-4" asp-action="Index" asp-controller="Todos">Return to List</a>
@Html.DisplayForModel()
<form asp-action="Delete" method="POST">
@Html.HiddenFor(m => m.Id)
@Html.HiddenFor(m => m.OwnerUserId)
@Html.HiddenFor(m => m.Title)
@Html.HiddenFor(m => m.Description)
@Html.HiddenFor(m => m.Completed)
<input class="btn btn-danger" type="submit" value="Confirm" />
</form>
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
@{
ViewData["Title"] = "Create Todo";
}
<h1>@ViewData["Title"]</h1>
<a class="mb-4" asp-action="Index" asp-controller="Todos">Return to List</a>
<form asp-action="Create" asp-controller="Todos" method="post">
@Html.EditorForModel()
</form>
Give it the following code:
@model AzureStorageTodos.Models.TodoEntityModel
@{
ViewData["Title"] = "Edit Todo";
}
<h1>@ViewData["Title"]</h1>
<a class="mb-4" asp-action="Index" asp-controller="Todos">Return to List</a>
<form asp-action="Edit" asp-controller="Todos" method="post">
@Html.EditorForModel()
</form>
The new code should look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AzureStorageTodos.Web</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/AzureStorageTodos.Web.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">AzureStorageTodos.Web</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Todos" asp-action="Index">Todos</a>
</li>
</ul>
<partial name="_LoginPartial" />
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
© 2022 - AzureStorageTodos.Web - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
Test the application locally
With all of this in place the application should be ready to test. Go to Run and Debug. Ensure that the “Attach to functions and launch web app” is selected as the profile and press the play button or open any file and press F5.
Click the Todos link. Practice creating, editing, deleting, and viewing Todos. As you do. Check your terminal output labeled “Host Start” and you will see that the function has been executing a few times as you were adding and deleting items.
Use the Azure Storage Explorer. Review the “Todos” and “TodoUsers” tables. Review the “todo-lists” container and it’s file and folders. Double click on them to review them.
Sample Screen Shots from my computer:
Build Azure Resources
All of these items will be created using the Azure Portal @ https://portal.azure.com/.
Create an Azure Resource Group
Search for Resource Group. Give it any name and select a region close to you. I will be using “rg-sw-storage-todos-demo-001”
Create an Azure App Configuration
Select the resource group that you created earlier. Click the Create button to add services to the resource group. Search for “App Configuration” and then click on “Create”
Make sure it is associated with the appropriate Subscription and Resource Group. Select the appropriate Location. Give it a name and select the free pricing tier. I will use the name “app-config-storage-todos-demo”
Create an Azure Key Vault
Select the resource group that you created earlier. Click the Create button to add service to the resource group. Search for “Key Vault” and then click on “Create”.
Make sure it is associated with the appropriate Subscription and Resource Group. Select the appropriate Location and standard pricing tier. I will use the name: “kv-storage-todos”. Also select the “Azure role-based access control” permission model.
Create an Azure Application App Service Plan
Select the resource group that you created earlier. Click the Create button to add service to the resource group. Search for “App Service Plan” and then click on “Create”
Make sure it is associated with the appropriate Subscription and Resource Group. Select the appropriate region and give it a name. I would also recommend changing the SKU and Size to free if possible. I will also be using the Linux version. The name that I will use is: “asp-sw-storage-todos-demo-001”
Create an Azure App Service
Select the resource group that you created earlier. Click the Create button to add service to the resource group. Search for “Web App” and then click on “Create”
Make sure that it is associated with the appropriate Subscription and Resource Group. Give it a name. Select Runtime stack of .NET 6, and Linux OS. Select the appropriate region, and the App Service Plan that you created earlier. I will use the name: “sw-storage-todos-demo”
Create an Azure Storage Account
Select the resource group that you created earlier. Click the Create button to add service to the resource group. Search for “Storage Account” and then click on “Create”.
Be sure that the subscription and resource group are set to the ones that you created earlier. Give the storage account a name and select a region close to you or with at least the same resource group location. Standard Performance and Locally-redundant storage should be sufficient. I will be using the name of “swstoragetodosdemo”
Create an Azure Functions App
Select the resource group that you created earlier. Click the Create button to add service to the resource group. Search for “Function App” and then click on “Create”.
Be sure that the subscription and resource group are set to the ones that you created earlier. Give the storage account a name and select a region close to your or with at least the same resource group location. I will be using the name ” sw-storage-todos-demo-functions”. I will be publishing code and the runtime stack is .NET 6. The operating system will be Linux. The plan type will be “App Service plan”. Select the App Service Plan that you created earlier.
Your resource group should look something like this:
Copy settings to Azure App Configuration and Azure Key Vault
In Visual Studio Code open the appsettings.json file. In your browse go to the Azure App Configuration that you created earlier and go to the “Configuration Explorer” blade.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
- 1 – Click on “Create” and select Key-Value. The key will be “AzureAd:Instance”. The value will be https://login.microsoftonline.com/ and the label will Development. We will have different values for the different instances of our application.
2 – Repeat those steps for every key in the “AzureAD” instance except for Client Secret. We will do that differently.
3 – Before we add the AzureAd:ClientSecret your app config should look something like this:
- 4 – Browse to the Key Vault Resource that you created earlier.
5 – Go to access control (IAM). Click on “Add”. Add yourself to the “Key Vault Secrets Officer” role.
6 – Click on the “Secrets” blade.
- 7 – Click on the “Generate/Import” button
The Upload options is manual. The name should be something like: “AzureAdClientSecret-Development”. And the value should be the value from your appsettings.json
After that is saved return to the App Configuration resource that you created earlier.
This time, when going to the Configuration Explorer, click on “Create” and select the “Key-Vault Reference” option.
- 8 – Select the Subscription, Resource Group, and Key Vault that you created earlier.
9 – Select the “AzureAdClientSecret-Development”
-
10 – Click on Apply.
With all of that in there we can now delete the AzureAd key from our appsettings.json file. I am also going to remove the other comments from this json file.
It should now look like this
{
"DownstreamApi": {
"BaseUrl": "https://graph.microsoft.com/v1.0",
"Scopes": "user.read"
},
"ConnectionStrings": {
"AzureStorage": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Similar to what was done earlier. Browse to the Azure Key Vault resource that you created earlier. Go to the Secrets blade. Click the Generate/Import button to create a new secret. It will be a manual upload. The name will be “AzureStorageConnectionString-Development”. The value will be the value from appsettings.json.
- 11 – Click on Create.
When done return to the App Configuration resource that you created earlier. Go to the Configuration explorer again and click the “Create” button and select Key Vault Reference and this time be sure that the name is “ConnectionStrings:AzureStorage”. The label is “Development.”
Next, select the appropriate Subscription, Resource Group and Key Vault. Then Select the AzureStorageConnectionString-Development” Secret and click on Apply. You should be able to delete the connection strings key from your appsettings.json now.
{
"DownstreamApi": {
"BaseUrl": "https://graph.microsoft.com/v1.0",
"Scopes": "user.read"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Sign-in to Azure CLI, Setup Container Environment Variables and Setup Web App and Functions Project to read from Azure App Configuration and Key Vault
Go to a terminal in Visual Studio Code. Return to /workspace. Execute the following command:
az login
- 1 – Log in to the browser and return to the CLI when prompted. You should see a JSON result summarizing your account.
2 – To the .devcontainer folder add two new files called: devcontainer.env and devcontainer.env.example
The purpose of this is to make it easier for our peer developers to get up to speed when they are onboarded on to the project.
The devcontainer.env file will be yours on your computer, and you will be giving instructions in your README file to your other developers to rename the devcontainer.env.example file to devcontainer.env with instructions on filling it out.
In your azure portal browse to the App Configuration resource that you created earlier. Go to the access keys blade. Grab the URL that is in your address bar. Update your devcontainer.env.example file as follows:
APPCONFIG_CONNECTION_STRING=Connection string from <Address in your address bar>
ASPNETCORE_ENVIRONMENT=Development
Click the two pages icon to copy the connection string.
APPCONFIG_CONNECTION_STRING=<Connection string that you just copied from the portal (without the angle brackets)>
ASPNETCORE_ENVIRONMENT=Development
env_file: devcontainer.env
version: '3'
services:
app:
env_file: devcontainer.env
build:
context: .
dockerfile: Dockerfile
args:
# Update 'VARIANT' to pick a version of .NET: 3.1-focal, 6.0-focal
VARIANT: "6.0-focal"
# Optional version of Node.js
NODE_VERSION: "lts/*"
volumes:
- ..:/workspace:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:azurite
# Uncomment the next line to use a non-root user for all processes.
# user: vscode
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
azurite:
image: mcr.microsoft.com/azure-storage/azurite
restart: unless-stopped
command: "azurite -d /opt/azurite/azurite_logs.txt"
# Add "forwardPorts": ["1433"] to **devcontainer.json** to forward MSSQL locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
Of course, the dev container is running right now, so it has not yet read those variables. It won’t read them just from restarting either. We need to rebuild them.
Do a CTRL+SHIFT+P to bring up the Visual Studio Code Command Pallet and select the “Remote-Containers: Rebuild Remote Container” option.
echo $APPCONFIG_CONNECTION_STRING
echo $ASPNETCORE_ENVIRONMENT
dotnet dev-certs https --trust
dotnet restore
az login
cd AzureStroageTodos.Web
dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore
cd ..
cd AzureStorageTods.AzureFunctions
dotnet add package Microsoft.Azure.AppConfiguration.AspNetCore
To the AzureStorageTodos.Web project, modify the Program.cs file add the following using:
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
string? appConfigConnectionString = Environment.GetEnvironmentVariable("APPCONFIG_CONNECTION_STRING");
string? aspNetCoreEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
builder.Host.ConfigureAppConfiguration(configurationBuilder => {
if(appConfigConnectionString is not null && aspNetCoreEnvironmentName is not null)
{
configurationBuilder.AddAzureAppConfiguration(azureAppConfigurationOptions => {
azureAppConfigurationOptions.Connect(appConfigConnectionString)
.Select(KeyFilter.Any, LabelFilter.Null)
.Select(KeyFilter.Any, aspNetCoreEnvironmentName)
.ConfigureKeyVault(kv =>
{
kv.SetCredential(new DefaultAzureCredential());
});
});
}
});
Your Program.cs file should look something like this:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.UI;
using Graph = Microsoft.Graph;
using Microsoft.Extensions.Azure;
using Azure.Identity;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Repository.BaseRepositories;
using AzureStorageTodos.Repository.Repositories;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.RepositoryClients;
using AzureStorageTodos.Service.Services;
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
var builder = WebApplication.CreateBuilder(args);
string? appConfigConnectionString = Environment.GetEnvironmentVariable("APPCONFIG_CONNECTION_STRING");
string? aspNetCoreEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
builder.Host.ConfigureAppConfiguration(configurationBuilder => {
if(appConfigConnectionString is not null && aspNetCoreEnvironmentName is not null)
{
configurationBuilder.AddAzureAppConfiguration(azureAppConfigurationOptions => {
azureAppConfigurationOptions.Connect(appConfigConnectionString)
.Select(KeyFilter.Any, LabelFilter.Null)
.Select(KeyFilter.Any, aspNetCoreEnvironmentName)
.ConfigureKeyVault(kv =>
{
kv.SetCredential(new DefaultAzureCredential());
});
});
}
});
// Add services to the container.
var initialScopes = builder.Configuration["DownstreamApi:Scopes"]?.Split(' ');
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options => {
options.Instance = builder.Configuration["AzureAd:Instance"];
options.Domain = builder.Configuration["AzureAd:Domain"];
options.TenantId = builder.Configuration["AzureAd:TenantId"];
options.ClientId = builder.Configuration["AzureAd:ClientId"];
options.ClientSecret = builder.Configuration["AzureAd:ClientSecret"];
options.CallbackPath = builder.Configuration["AzureAd:CallbackPath"];
options.Events = new WebAppOpenIdConnectEvents();
})
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
builder.Services.AddControllersWithViews(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
builder.Services.AddRazorPages()
.AddMicrosoftIdentityUI();
// Register Azure Clients
builder.Services.AddAzureClients(azureClientsBuilder => {
string azureStorageConnectionString = builder.Configuration.GetConnectionString("AzureStorage");
azureClientsBuilder.AddTableServiceClient(azureStorageConnectionString);
azureClientsBuilder.AddBlobServiceClient(azureStorageConnectionString);
azureClientsBuilder.AddQueueServiceClient(azureStorageConnectionString).ConfigureOptions(queueOptions => {
queueOptions.MessageEncoding = Azure.Storage.Queues.QueueMessageEncoding.Base64;
});
azureClientsBuilder.UseCredential(new DefaultAzureCredential());
});
// Register Repositories
builder.Services.AddTransient<IUsersRepository, UsersRepository>();
builder.Services.AddTransient<ITodosRepository, TodosRepository>();
builder.Services.AddTransient<ITodoListRepository, TodoListRepository>();
builder.Services.AddTransient<ITodoUpdatedQueue, TodoUpdatedQueue>();
// Register Services
builder.Services.AddTransient<IUsersRepositoryClient, UsersRepositoryClient>();
builder.Services.AddTransient<ITodosRepositoryClient, TodosRepositoryClient>();
builder.Services.AddTransient<ITodoListRepositoryClient, TodoListRepositoryClient>();
builder.Services.AddTransient<ITodoListManager, TodoListManager>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.Run();
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
string appConfigConnectionString = Environment.GetEnvironmentVariable("APPCONFIG_CONNECTION_STRING");
string aspNetCoreEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
builder.ConfigurationBuilder.AddAzureAppConfiguration(azureAppConfigurationBuilder => {
azureAppConfigurationBuilder.Connect(appConfigConnectionString)
.Select(KeyFilter.Any, LabelFilter.Null)
.Select(KeyFilter.Any, aspNetCoreEnvironmentName)
.ConfigureKeyVault(kv =>
{
kv.SetCredential(new DefaultAzureCredential());
});
});
base.ConfigureAppConfiguration(builder);
}
using Azure.Identity;
using Microsoft.Extensions.Azure;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using AzureStorageTodos.Repository.Interface;
using AzureStorageTodos.Repository.Repositories;
using AzureStorageTodos.Service.Interface;
using AzureStorageTodos.Service.RepositoryClients;
using AzureStorageTodos.Service.Services;
using System;
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
[assembly: FunctionsStartup(typeof(AzureStorageTodos.AzureFunctions.Startup))]
namespace AzureStorageTodos.AzureFunctions;
public class Startup : FunctionsStartup
{
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
string appConfigConnectionString = Environment.GetEnvironmentVariable("APPCONFIG_CONNECTION_STRING");
string aspNetCoreEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
builder.ConfigurationBuilder.AddAzureAppConfiguration(azureAppConfigurationBuilder => {
azureAppConfigurationBuilder.Connect(appConfigConnectionString)
.Select(KeyFilter.Any, LabelFilter.Null)
.Select(KeyFilter.Any, aspNetCoreEnvironmentName)
.ConfigureKeyVault(kv =>
{
kv.SetCredential(new DefaultAzureCredential());
});
});
base.ConfigureAppConfiguration(builder);
}
public override void Configure(IFunctionsHostBuilder builder)
{
var config = builder.GetContext().Configuration;
string azureWebJobsStorage = config.GetValue<string>("AzureWebJobsStorage");
// Register Azure Clients
builder.Services.AddAzureClients(azureClientsBuilder => {
azureClientsBuilder.AddTableServiceClient(azureWebJobsStorage);
azureClientsBuilder.AddBlobServiceClient(azureWebJobsStorage);
azureClientsBuilder.UseCredential(new DefaultAzureCredential());
});
// Register Repositories
builder.Services.AddTransient<ITodosRepository, TodosRepository>();
builder.Services.AddTransient<ITodoListRepository, TodoListRepository>();
// Register Services
builder.Services.AddTransient<ITodosRepositoryClient, TodosRepositoryClient>();
builder.Services.AddTransient<ITodoListRepositoryClient, TodoListRepositoryClient>();
builder.Services.AddTransient<ITodoListManager, TodoListManager>();
}
}
Verify App Configuration is being downloaded locally and that your application still runs locally
Go to the Run and Debug screen. Ensure that the “Attach to functions and launch web app” profile is still selected and press the green play button or press F5.
Try adding and removing Todos and such again. Once you are sure that the application is still running locally continue on to the next steps.
Add Environment Variables to the Web App and Functions App in Azure
The Web App and Functions App will need an environment variable for your App Configuration Connection String and ASPNETCORE_ENVIRONMENT.
First grab the Connection String to the App Configuration again by going to the App Configuration resource that you created earlier and going to the Keys blade and click the two pages button to copy the Connection String and note it down in a notepad.
Then go to the Web App resource that you created in Azure earlier. Go to the Configuration Blade.
Give it the name of “APPCONFIG_CONNECTION_STRING” with the value of what your noted down in the empty notepad.
Add an additional application setting named: “ASPNETCORE_ENVIRONMENT” and give it the value of “Production”
Your application settings should look something like this:
Go to the Functions App that you created in Azure Earlier. Add these same settings again in there.
Save and Continue those changes as well.
Your Functions App configuration should look something like this:
Add Configuration and Secrets for production to the App Configuration and Key Vault
With all of that in place it is now time for us to create a new App Registration in Azure Active Directory, and then configure out Azure App Configuration and Key Vault with the details from the registration.
Take note of your current Web App URL by going to the Azure Web App that you created earlier. You will need this information to create a new Azure App Registration in the Active Directory.
Browse to the Azure Active Directory resource in the Azure Portal.
Go to the App Registrations Tab
Give it any name that you like. I will be using “Azure Storage Todos – Production”. The supported account types are going to be “Accounts in this organizational directory only)”. The Redirect URI is going to be a Web platform and will be the azurewebsites.net URL that is being used by the Azure Web App service with adding a /signin-oidc at the end. For me that would mean https://sw-storage-todos-demo.azurewebsites.net/signin-oidc
- 1 – Click on Register
2 – Take note of your Application/ Client Id to a separate notepad. You will need this when we build the Production App Configuration Labels.
3 – Go to Certificates and Secrets.
4 – Create a new Client Secret. Give it a description of “Azure Storage Todos – Production” and click on Add. Take note of the value that you get there. (Again, make sure you get the Value, and not the Secret Id).
5 – Go to the Key Vault resource that you created earlier.
6 – Go to the Secrets blade again.
7 – Generate / Import a new secret.
Once again, this is a Manual upload. The name should be “AzureAdClientSecret-Production” the value will be the client secret that you just got from the App Registration Screen.
We also need to add a new AzureStorageConnectionString for production to the Key Vault. To get the correct Value go to the Storage Account resource that you created earlier.
Go to the “Access Keys” blade.
- 8 – Return to the Key Vault resource that you created earlier.
9 – Go to the Secrets blade again
10 – Generate / Import a new secret.
Again, this is a Manual upload. The name should be “AzureStorageConnectionString-Production” and the value will be the connection string value you just got from the storage account window.
Your Key Vault Secrets blade should look something like this now
Copy all of the App Configurations but give them the production Label except for the AzureAd:ClientId, AzureAd:ClientSecret, and ConnectionStrings:AzureStorage. For example:
-
• Click on the Create Button and Select Key Value
• Give it the name of AzureAd:CallbackPath
• Give it the same value as the other AzureAd:CallbackPath
• Give it the label of Production
• Click on Apply
Repeat those steps for
-
• AzureAd:Domain
• AzureAd:Instance
• AzureAd:TenantId
Again, ensure that the label that you are using is “Production”.
When it comes to AzureAd:ClientId. grab the ClientId from that new App Registration that you just did in Azure Ad.
When it comes to AzureAd:ClientSecret. add it as a Key vault reference. Then select the Key Vault Reference for your AzureAdClientSecret-Production
The trick though is that the application needs to be run by an identity that has permission to download our app configuration and secrets from the key vault.
Furthermore, the Functions App is currently configured to run on it’s own storage account that it created. We need to update it to use our storage account.
Browse to the Storage account that we created earlier.
Click on the “Access Keys” blade.
- 1 – Click on Configuration.
2 – Click on “Edit” for the AzureWebJobsStorage” setting.
3 – Paste in the value that you got from the other storage account connection string.
Create and Configure Web Application and Function Application Identities to read App Configuration and Key Vault Secrets
In this demonstration we will be using a System assigned Identity. There are options for user assigned identities too. Our key vault is currently using Role Based Access Control which makes using system assigned identities easier.
In the azure portal go to the Web Application Resource that you created earlier.
Go to the “Identity” blade
On the System assigned tab move the status to On and click on Save and then click on Yes to confirm
Browse to the App Configuration resource that you created earlier.
Click on the “Access control (IAM)” blade.
Select the “App Configuration Data Reader” role and then click on “Next”
Be sure to select the Subscription, and Managed identity type for App Service and select the app service identity and then click on the “Select” button.
Now that the user has access to our App Configuration, let’s give it access to our Key Vault as well.
Go to the Key Vault resource that you created earlier.
Once again. Go to the Access control (IAM) tab.
Click to add a role assignment again. This time select the “Key Vault Secrets User” role.
The user will also need to be able to read and write data to our storage account. Go to the Storage Account Azure resource that you created earlier.
Click on the Access Control (IAM) blade there. Click on Add Role Assignment. Add it to the “Storage Account Contributor” role.
Go to the Functions Application resource that you created earlier. From there is an Identity Blade as well. Click on that and create another managed identity. Repeat the steps in this section to add the Functions Application identity to the “App Configuration Data Reader”, “Key Vaults Secrets User” and “Storage Account Contributor” roles like was done for the Web Application.
Publish to GitHub
In order to prepare to publish to GitHub let’s create a few files and initialize a git repository.
In Visual Studio Code open a terminal assuming that you are still at the /workspace folder execute the following commands:
dotnet new gitignore
# Dev Container Environment
devcontainer.env
* text=auto eol=lf
# Azure Storage Todos
After cloning this repository. Open it in Visual Studio Code and when prompted say "No" to re-open in container at first.
In order to configure your dev container to connect to our Azure App Configuration go to the .devcontainer folder. Make a copy of devcontainer.env.example and name it devcontainer.env. Copy the connection string from the link provided there to properly configure your APPCONFIG_CONNECTION_STRING environment variable.
Once you have a valid connection string do a CTRL+SHIFT+P to bring up the command pallet and select the option to re-open in container from there.
Once your container is built and Visual Studio Code is running from the container do a CTRL+SHIFT+` to bring up a command prompt from the container.
Execute the following commands (these commands will likely need to be done any time you rebuild the dev container):
```bash
dotnet restore
dotnet dev-certs https --trust
az login
gh auth login
```
After you have successfully logged in to the Azure CLI in your dev container you will be able to download the App Configuration and User Secrets for Development.
Execute the following commands
git init
git add .
git commit -a -m "Initial Commit"
Execute the following commands
gh auth login
Login to GitHub with your account in the browser.
Provide the onetime code from the CLI
Execute the following command
gh repo create
-
• Push an existing local repository to GitHub
• .
• azure-storage-todos-demo
• Demo of how to use aAzure Storage
• Public
• Yes
• Origin
• Yes
This was the output from my screen and image referred to above:.
Build GitHub Actions to Deploy to Azure App Services and Functions App from GitHub repository
This is where things can get a little tricky. We have a unique situation where we are deploying multiple projects to separate places within one repository.
From a high level what we need to do is build the Web Application, and Functions Applications separately. Upload them as artifacts and then use a release server to upload the artifacts to the Web Application and Functions Applications on Azure.
We will also need the publish profiles from our Web Application and Functions Application as secrets in GitHub that can be used by the release agent computer.
To make things more simple for the purpose of this demonstration we will create those secrets first, and then the GitHub actions second (since the GitHub Action will be reading these secrets).
I would recommend using two browser windows / tabs in order to complete this.
In your GitHub tab browse to Settings. Then click on Secrets and select actions.
In your other tab that I will call your Azure tab browse to https://portal.azure.com/ and go to the Web Application resource that you created earlier.
Click on the “Get publish profile button”
In your GitHub tab click on the “New repository secret” button.
On your Azure tab go to the Functions Application that you created earlier.
There is another Get publish profile there. Click that to download that one.
In your GitHub tab add another secret with the name of AZURE_FUNCTIONAPP_PUBLISH_PROFILE
Search for Azure Web App and select the “Deploy a .NET Core app to an Azure Web App and then select Configure
Give it the following code:
# This workflow will build and push a .NET Core app to an Azure Web App when a commit is pushed to your default branch.
#
# This workflow assumes you have already created the target Azure App Service web app.
# For instructions see https://docs.microsoft.com/en-us/azure/app-service/quickstart-dotnetcore?tabs=net60&pivots=development-environment-vscode
#
# To configure this workflow:
#
# 1. Download the Publish Profile for your Azure Web App. You can download this file from the Overview page of your Web App in the Azure Portal.
# For more information: https://docs.microsoft.com/en-us/azure/app-service/deploy-github-actions?tabs=applevel#generate-deployment-credentials
#
# 2. Create a secret in your repository named AZURE_WEBAPP_PUBLISH_PROFILE, paste the publish profile contents as the value of the secret.
# For instructions on obtaining the publish profile see: https://docs.microsoft.com/azure/app-service/deploy-github-actions#configure-the-github-secret
#
# 3. Change the value for the AZURE_WEBAPP_NAME. Optionally, change the AZURE_WEBAPP_PACKAGE_PATH and DOTNET_VERSION environment variables below.
#
# For more information on GitHub Actions for Azure: https://github.com/Azure/Actions
# For more information on the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# For more samples to get started with GitHub Action workflows to deploy to Azure: https://github.com/Azure/actions-workflow-samples
name: Build and deploy ASP.Net Core app to an Azure Web App
env:
AZURE_WEBAPP_NAME: sw-storage-todos-demo # set this to the name of your Azure Web App
AZURE_FUNCTIONAPP_NAME: sw-storage-todos-demo-functions # set this to your application's name
AZURE_WEBAPP_PACKAGE_PATH: './myapp' # set this to the path to your web app project, defaults to the repository root
AZURE_FUNCTIONAPP_PACKAGE_PATH: './myfunctions' # set this to the path to your web app project, defaults to the repository root
DOTNET_VERSION: '6.0.x' # set this to the .NET Core version to use
on:
push:
branches:
- "master"
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up .NET Core
uses: actions/setup-dotnet@v2
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Set up dependency caching for faster builds
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Build with dotnet
run: dotnet build $GITHUB_WORKSPACE/AzureStorageTodos.Web/AzureStorageTodos.Web.csproj /property:GenerateFullPaths=true --configuration Release
- name: dotnet publish
run: dotnet publish $GITHUB_WORKSPACE/AzureStorageTodos.Web/AzureStorageTodos.Web.csproj /property:GenerateFullPaths=true -c Release -o ${{env.DOTNET_ROOT}}/myapp
- name: Build function
run: dotnet build $GITHUB_WORKSPACE/AzureStorageTodos.AzureFunctions/AzureStorageTodos_AzureFunctions.csproj /property:GenerateFullPaths=true --configuration Release --output ${{env.DOTNET_ROOT}}/myfunctions
- name: Upload web app artifact for deployment job
uses: actions/upload-artifact@v3
with:
name: .net-app
path: ${{env.DOTNET_ROOT}}/myapp
- name: Upload functions app artificat for deployment job
uses: actions/upload-artifact@v3
with:
name: .net-functions
path: ${{env.DOTNET_ROOT}}/myfunctions
deploy:
permissions:
contents: none
runs-on: ubuntu-latest
needs: build
environment:
name: 'Development'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
steps:
- name: Download web app artifact from build job
uses: actions/download-artifact@v3
with:
name: .net-app
path: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
- name: Download functions app artificat from build job
uses: actions/download-artifact@v3
with:
name: .net-functions
path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
with:
app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }}
package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}
Click on the “Start Commit” button and then click on Commit new file.
Test your application on Azure
Go to the Web Application Resource that you created earlier. On the overview blade there is a link to your application. When you first sign in you will be asked to give consent on behalf of your organization. Click on Accept.
In another browser go to the Storage Account Resource that you created earlier. Click on the “Open in Explorer” button.
Conclusion
Azure offers a wider variety of Storage solutions and making them work together to achieve a set of goals can be very rewarding. We demonstrated how to use all three storage offerings that an Azure Storage account makes available. We are using Tables to store User Demographics and Individual Todos. We are using Blobs to preserve lists of Todos, and we are using Queues as a trigger to let a function know that the Todo List should be rebuilt.
Keeping secrets out of application code is important. When you have people that collaborate on this project with you, they will need at least the same permissions that you gave the Web Application and Functions Application identities. You may want to elevate them to contributor so that they can add configurations and secrets as well.
GitHub repository: https://github.com/woodman231/azure-storage-todos-demo
About Intertech
Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Blackslate Software teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Blackslate Software employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Blackslate Software is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.