项目作者: paolosalvatori

项目描述 :
This sample shows how to configure an Azure App Service to access Azure Cache for Redis and Azure SQL Database via regional VET integration and Private Endpoints
高级语言: JavaScript
项目地址: git://github.com/paolosalvatori/web-app-redis-sql-db.git
创建时间: 2021-03-26T11:40:47Z
项目社区:https://github.com/paolosalvatori/web-app-redis-sql-db

开源协议:MIT License

下载



products: azure, aspnet, azure-application-insights, azure-app-service, azure-blob-storage, azure-storage-accounts, azure-sql, azure-cache-for-redis, azure-database, azure-web-app, azure-log-analytics, azure-nat-gateway, azure-virtual-machines, vs-code

How to configure an Azure Web App to call Azure Cache for Redis and Azure SQL Database via Private Endpoints

This sample shows how to deploy an infrastructure and network topology on Azure where an ASP.NET Core web application hosted by an Azure App Service accesses data from Azure Cache for Redis and Azure SQL Database using Azure Private Endpoints. The Azure Web App is hosted in a Standard, Premium, PremiumV2, PremiumV3 with Regional VNET Integration.
Private endpoints are fully supported also by the Standard tier of Azure Cache for Redis. However, to use private endpoints, an Azure Cache for Redis instance needs to have been created after July 28th, 2020. Currently, zone redundancy, portal console support, and persistence to firewall storage accounts are not supported.

This sample also shows how to:

  • use a system-assigned managed identity to let the Web App access secrets from Azure Key Vault
  • deploy an ASP.NET Core application to an Azure App Service using a GitHub Actions workflow
  • disable the public network access from the internet to all the managed services used by the application:

    • Azure Blob Storage Account
    • Azure Key Vault
    • Azure Cache for Redis
    • Azure SQL Database
    • Azure Application Insights

As an alternative solution, this sample also shows how to deploy Premium Azure Cache for Redis in a virtual network. When an Azure Cache for Redis instance is configured with a virtual network, it isn’t publicly addressable and can only be accessed from virtual machines and applications within the virtual network.

For more information, see:

In addition, Azure Web Apps can be configured to be called via a private IP address by applications located in the same virtual network, or in a peered network, or on-premises via ExpressRoute or a S2S VPN. For more information, see:

Deploy to Azure

You can use the following button to deploy the demo to your Azure subscription:

Azure Cache for Redis via Private Endpoints

Deploy to Azure

Azure Cache for Redis in a virtual network

Deploy to Azure

Architecture

The following picture shows the architecture and network topology of the first solution where a Standard Azure Cache for Redis is accessed by an Azure Web App via Regional VNET Integration and Azure Private Endpoints.

Architecture with Azure Cache for Redis accessed via Private Endpoint

The ARM template deploys the following resources:

  • Virtual Network: this virtual network is composed of the following subnets:
    • WebAppSubnet: this subnet is used for the regional VNET integration with the Azure Web App app hosted by a Premium Plan. For more information, see Using Private Endpoints for Azure Web App.
    • PrivateEndpointSubnet: hosts the private endpoints used by the application.
    • VirtualMachineSubnet: hosts the jumpbox virtual machine and any additional virtual machine used by the solution.
    • AzureBastionSubnet: hosts Azure Bastion. For more information, see Working with NSG access and Azure Bastion.
  • Network Security Group: this resource contains an inbound rule to allow access to the jumpbox virtual machine on port 3389 (RDP)
  • A Windows 10 virtual machine. This virtual machine can be used as jumpbox virtual machine to simulate a real application and send requests to the Azure Web Apps exposed via Azure Private Link.
  • A Public IP for Azure Bastion
  • Azure Bastion is used to access the jumpbox virtual machine from the Azure Portal via RDP. For more information, see What is Azure Bastion?.
  • An ADLS Gen 2 storage account used to store the boot diagnostics logs of the virtual machine as blobs
  • A Standard, Premium, PremiumV2, PremiumV3 hosting plan that supports Regional VNET Integration
  • An Azure App Service containing an ASP.NET Core application that uses a system-assigned managed identity to read settings from Key vault. The web site is a single page application that stores data in Azure SQL Database and caches items in Azure Cache for Redis.
  • An Application Insights resource used by the Azure Web Apps app to store logs, traces, requests, exceptions, and metrics. For more information, see Web application monitoring on Azure.
  • An Azure SQL Server and Azure SQL Database hosting the ProductDB relational database used by the Web App.
  • An Azure Key Vault used to store the following application settings. These settings are automtically created by the ARM template as secrets in Azure Key Vault:

    • Azure Cache for Redis connection string
    • Azure SQL Database connection string
    • Application Insights Instrumentation Key
  • A private endpoint to the:

    • Azure Blob storage account (boot diagnostics logs)
    • Azure Cache for Redis
    • Azure SQL Database
    • Azure Key Vault
  • A Private DNS Zone Group to link each private endpoint with the corresponding Private DNS Zone.

  • The NIC used by the jumpbox virtual machine and for each private endpoint.
  • A Log Analytics workspace used to monitor the health status of the services such as the hosting plan or NSG.
  • A Private DNS Zone for Azure Blob Storage Account private endpoint (privatelink.blob.core.windows.net)
  • A Private DNS Zone for Azure Cache for Redis private endpoint (privatelink.redis.cache.windows.net)
  • A Private DNS Zone for Azure SQL Database private endpoint (privatelink.database.windows.net)
  • A Private DNS Zone for Azure Key Vault private endpoint (privatelink.vaultcore.azure.net)

The following picture shows the architecture and network topology of the first solution where a Standard Azure Cache for Redis is accessed by an Azure Web App via Regional VNET Integration and Azure Private Endpoints.

Architecture with Azure Cache for Redis accessed in a VNET

The ARM template deploys the following resources:

  • Virtual Network: this virtual network is composed of the following subnets:
    • WebAppSubnet: this subnet is used for the regional VNET integration with the Azure Web App app hosted by a Premium Plan. For more information, see Using Private Endpoints for Azure Web App.
    • PrivateEndpointSubnet: hosts the private endpoints used by the application.
    • VirtualMachineSubnet: hosts the jumpbox virtual machine and any additional virtual machine used by the solution.
    • AzureBastionSubnet: hosts Azure Bastion. For more information, see Working with NSG access and Azure Bastion.
    • RedisCacheSubnet: hosts the Premium Azure Cache for Redis
  • Network Security Group: this resource contains an inbound rule to allow access to the jumpbox virtual machine on port 3389 (RDP)
  • A Windows 10 virtual machine. This virtual machine can be used as jumpbox virtual machine to simulate a real application and send requests to the Azure Web Apps exposed via Azure Private Link.
  • Azure Bastion is used to access the jumpbox virtual machine from the Azure Portal via RDP. For more information, see What is Azure Bastion?.
  • An ADLS Gen 2 storage account used to store the boot diagnostics logs of the virtual machine as blobs
  • A Standard, Premium, PremiumV2, PremiumV3 hosting plan that supports Regional VNET Integration
  • An Azure App Service containing an ASP.NET Core application that uses a system-assigned managed identity to read settings from Key vault. The web site is a single page application that stores data in Azure SQL Database and caches items in Azure Cache for Redis.
  • An Application Insights resource used by the Azure Web Apps app to store logs, traces, requests, exceptions, and metrics. For more information, see Web application monitoring on Azure.
  • An Azure SQL Server and Azure SQL Database hosting the ProductDB relational database used by the Web App.
  • An Azure Key Vault used to store the following application settings. These settings are automtically created by the ARM template as secrets in Azure Key Vault:

    • Azure Cache for Redis connection string
    • Azure SQL Database connection string
    • Application Insights Instrumentation Key
  • A private endpoint to the:

    • Azure Blob storage account (boot diagnostics logs)
    • Azure SQL Database
    • Azure Key Vault
  • A Private DNS Zone Group to link each private endpoint with the corresponding Private DNS Zone.

  • The NIC used by the jumpbox virtual machine and for each private endpoint.
  • A Log Analytics workspace used to monitor the health status of the services such as the hosting plan or NSG.
  • A Private DNS Zone for Azure Blob Storage Account private endpoint (privatelink.blob.core.windows.net)
  • A Private DNS Zone for Azure SQL Database private endpoint (privatelink.database.windows.net)
  • A Private DNS Zone for Azure Key Vault private endpoint (privatelink.vaultcore.azure.net)

Important Notes

The two ARM templates disable the public access to both Azure SQL Database and Azure Cache for Redis via the publicNetworkAccess parameter which default value is set to false. Using private endpoints is not enough to secure an application, you also have to disable the public access to the managed services used by the application, in this case Azure SQL Database and Azure Cache for Redis.

In addition, both ARM templates automatically create the connection string to both the Azure Cache for Redis and Azure SQL Database as application settings of the Azure App Service. However, in a production environment, it’s recommended to access adopt one of the following approaches:

Prerequisites

The following components are required to run this sample:

Topology Deployment

You can use the ARM template and Bash script included in the sample to deploy to Azure the entire infrastructure necessary to host the demo:

  1. #!/bin/bash
  2. # Clear the screen
  3. clear
  4. # Print the menu
  5. echo "================================================="
  6. echo "Install Demo. Choose an option (1-3): "
  7. echo "================================================="
  8. options=("Inject Premium Azure Cache for Redis in a VNET"
  9. "Azure Cache for Redis with Azure Private Link"
  10. "Quit")
  11. # Select an option
  12. COLUMNS=0
  13. select opt in "${options[@]}"; do
  14. case $opt in
  15. "Inject Premium Azure Cache for Redis in a VNET")
  16. template="../templates/azuredeploy.vnet.json"
  17. parameters="../templates/azuredeploy.vnet.parameters.json"
  18. resourceGroupName="WebAppSqlDbRedisInVnetRG"
  19. break
  20. ;;
  21. "Azure Cache for Redis with Azure Private Link")
  22. template="../templates/azuredeploy.endpoint.json"
  23. parameters="../templates/azuredeploy.endpoint.parameters.json"
  24. resourceGroupName="WebAppSqlDbRedisCacheRG"
  25. break
  26. ;;
  27. "Quit")
  28. exit
  29. ;;
  30. *) echo "invalid option $REPLY" ;;
  31. esac
  32. done
  33. # Variables
  34. location="WestEurope"
  35. # SubscriptionId of the current subscription
  36. subscriptionId=$(az account show --query id --output tsv)
  37. subscriptionName=$(az account show --query name --output tsv)
  38. # Check if the resource group already exists
  39. createResourceGroup() {
  40. local resourceGroupName=$1
  41. local location=$2
  42. # Parameters validation
  43. if [[ -z $resourceGroupName ]]; then
  44. echo "The resource group name parameter cannot be null"
  45. exit
  46. fi
  47. if [[ -z $location ]]; then
  48. echo "The location parameter cannot be null"
  49. exit
  50. fi
  51. echo "Checking if [$resourceGroupName] resource group actually exists in the [$subscriptionName] subscription..."
  52. if ! az group show --name "$resourceGroupName" &>/dev/null; then
  53. echo "No [$resourceGroupName] resource group actually exists in the [$subscriptionName] subscription"
  54. echo "Creating [$resourceGroupName] resource group in the [$subscriptionName] subscription..."
  55. # Create the resource group
  56. if az group create --name "$resourceGroupName" --location "$location" 1>/dev/null; then
  57. echo "[$resourceGroupName] resource group successfully created in the [$subscriptionName] subscription"
  58. else
  59. echo "Failed to create [$resourceGroupName] resource group in the [$subscriptionName] subscription"
  60. exit
  61. fi
  62. else
  63. echo "[$resourceGroupName] resource group already exists in the [$subscriptionName] subscription"
  64. fi
  65. }
  66. # Validate the ARM template
  67. validateTemplate() {
  68. local resourceGroupName=$1
  69. local template=$2
  70. local parameters=$3
  71. local arguments=$4
  72. # Parameters validation
  73. if [[ -z $resourceGroupName ]]; then
  74. echo "The resource group name parameter cannot be null"
  75. fi
  76. if [[ -z $template ]]; then
  77. echo "The template parameter cannot be null"
  78. fi
  79. if [[ -z $parameters ]]; then
  80. echo "The parameters parameter cannot be null"
  81. fi
  82. echo "Validating [$template] ARM template..."
  83. if [[ -z $arguments ]]; then
  84. error=$(az deployment group validate \
  85. --resource-group "$resourceGroupName" \
  86. --template-file "$template" \
  87. --parameters "$parameters" 2>&1 | grep 'ERROR:')
  88. else
  89. error=$(az deployment group validate \
  90. --resource-group "$resourceGroupName" \
  91. --template-file "$template" \
  92. --parameters "$parameters" \
  93. --arguments $arguments 2>&1 | grep 'ERROR:')
  94. fi
  95. if [[ -z $error ]]; then
  96. echo "[$template] ARM template successfully validated"
  97. else
  98. echo "Failed to validate the [$template] ARM template"
  99. echo "$error"
  100. exit 1
  101. fi
  102. }
  103. # Deploy ARM template
  104. deployTemplate() {
  105. local resourceGroupName=$1
  106. local template=$2
  107. local parameters=$3
  108. local arguments=$4
  109. # Parameters validation
  110. if [[ -z $resourceGroupName ]]; then
  111. echo "The resource group name parameter cannot be null"
  112. exit
  113. fi
  114. if [[ -z $template ]]; then
  115. echo "The template parameter cannot be null"
  116. exit
  117. fi
  118. if [[ -z $parameters ]]; then
  119. echo "The parameters parameter cannot be null"
  120. exit
  121. fi
  122. # Deploy the ARM template
  123. echo "Deploying [$template] ARM template..."
  124. if [[ -z $arguments ]]; then
  125. az deployment group create \
  126. --resource-group $resourceGroupName \
  127. --template-file $template \
  128. --parameters $parameters 1>/dev/null
  129. else
  130. az deployment group create \
  131. --resource-group $resourceGroupName \
  132. --template-file $template \
  133. --parameters $parameters \
  134. --parameters $arguments 1>/dev/null
  135. fi
  136. if [[ $? == 0 ]]; then
  137. echo "[$template] ARM template successfully provisioned"
  138. else
  139. echo "Failed to provision the [$template$] ARM template"
  140. exit -1
  141. fi
  142. }
  143. # Create Resource Group
  144. createResourceGroup \
  145. "$resourceGroupName" \
  146. "$location"
  147. # Validate ARM Template
  148. validateTemplate \
  149. "$resourceGroupName" \
  150. "$template" \
  151. "$parameters"
  152. # Deploy ARM Template
  153. deployTemplate \
  154. "$resourceGroupName" \
  155. "$template" \
  156. "$parameters"

Create tables and stored procedures

You can use the following ProductsDB T-SQL script to initialize the SQL database used by the ASP.NET Core application.

  1. IF OBJECT_ID('Products') > 0 DROP TABLE [Products]
  2. GO
  3. -- Create Products table
  4. CREATE TABLE [Products]
  5. (
  6. [ProductID] [int] IDENTITY(1,1) NOT NULL ,
  7. [Name] [nvarchar](50) NOT NULL ,
  8. [Category] [nvarchar](50) NOT NULL ,
  9. [Price] [smallmoney] NOT NULL
  10. CONSTRAINT [PK_Products] PRIMARY KEY CLUSTERED
  11. (
  12. [ProductID]
  13. )
  14. )
  15. GO
  16. -- Create stored procedures
  17. IF OBJECT_ID('GetProduct') > 0 DROP PROCEDURE [GetProduct]
  18. GO
  19. CREATE PROCEDURE GetProduct
  20. @ProductID int
  21. AS
  22. SELECT [ProductID], [Name], [Category], [Price]
  23. FROM [Products]
  24. WHERE [ProductID] = @ProductID
  25. GO
  26. IF OBJECT_ID('GetProducts') > 0 DROP PROCEDURE [GetProducts]
  27. GO
  28. CREATE PROCEDURE GetProducts
  29. AS
  30. SELECT [ProductID], [Name], [Category], [Price]
  31. FROM [Products]
  32. GO
  33. IF OBJECT_ID('GetProductsByCategory') > 0 DROP PROCEDURE [GetProductsByCategory]
  34. GO
  35. CREATE PROCEDURE GetProductsByCategory
  36. @Category [nvarchar](50)
  37. AS
  38. SELECT [ProductID], [Name], [Category], [Price]
  39. FROM [Products]
  40. WHERE [Category] = @Category
  41. GO
  42. IF OBJECT_ID('AddProduct') > 0 DROP PROCEDURE [AddProduct]
  43. GO
  44. CREATE PROCEDURE AddProduct
  45. @ProductID int OUTPUT,
  46. @Name [nvarchar](50),
  47. @Category [nvarchar](50),
  48. @Price [smallmoney]
  49. AS
  50. INSERT INTO Products
  51. VALUES
  52. (@Name, @Category, @Price)
  53. SET @ProductID = @@IDENTITY
  54. GO
  55. IF OBJECT_ID('UpdateProduct') > 0 DROP PROCEDURE [UpdateProduct]
  56. GO
  57. CREATE PROCEDURE UpdateProduct
  58. @ProductID int,
  59. @Name [nvarchar](50),
  60. @Category [nvarchar](50),
  61. @Price [smallmoney]
  62. AS
  63. UPDATE Products
  64. SET [Name] = @Name,
  65. [Category] = @Category,
  66. [Price] = @Price
  67. WHERE [ProductID] = @ProductID
  68. GO
  69. IF OBJECT_ID('DeleteProduct') > 0 DROP PROCEDURE [DeleteProduct]
  70. GO
  71. CREATE PROCEDURE DeleteProduct
  72. @ProductID int
  73. AS
  74. DELETE [Products]
  75. WHERE [ProductID] = @ProductID
  76. GO
  77. -- Create test data
  78. SET NOCOUNT ON
  79. GO
  80. INSERT INTO Products
  81. VALUES
  82. (N'Tomato soup', N'Groceries', 1.39)
  83. GO
  84. INSERT INTO Products
  85. VALUES
  86. (N'Babo', N'Toys', 19.99)
  87. GO
  88. INSERT INTO Products
  89. VALUES
  90. (N'Hammer', N'Hardware', 16.49)
  91. GO

You can proceed as follows to create the tables and stored procedure in the SQL database:

  • VPN into the jumpbox virtual machine using Azure Bastion as shown in the picture below
  • Open a browser and connect to the Azure Portal
  • Open the Query Editor under the Azure SQL Database resource
  • Copy and paste the code in ProductsDB T-SQL script into a new query
  • Execute the scripts that creates the tables and some test data in the Products table used by the Web App

Resources

ASP.NET Core application

This sample provides an ASP.NET Core single-page application (SPA) to test the topology. The application reads:

  • Azure Cache for Redis connection string
  • Azure SQL Database connection string
  • Application Insights Instrumentation Key

application settings from Azure Key Vault using the following code defined in the Program class. For more information, see Azure Key Vault configuration provider in ASP.NET Core. The application uses the system-assigned managed identity of the App Service to access secrets from Azure Key Vault. The ARM template creates Key Vault, the secrets used application settings by the ASP.NET Core aaplication, and the access policies to grant permissions on secrets to the system-assigned managed identity. For more information, see How to use managed identities for App Service and Azure Functions.

Program.cs

  1. using System;
  2. using Microsoft.AspNetCore.Hosting;
  3. using Microsoft.Extensions.Configuration;
  4. using Microsoft.Extensions.Hosting;
  5. using Azure.Identity;
  6. using Azure.Security.KeyVault.Secrets;
  7. using Azure.Extensions.AspNetCore.Configuration.Secrets;
  8. using Products.Properties;
  9. namespace Products
  10. {
  11. public class Program
  12. {
  13. public static void Main(string[] args)
  14. {
  15. CreateHostBuilder(args).Build().Run();
  16. }
  17. public static IHostBuilder CreateHostBuilder(string[] args) =>
  18. Host.CreateDefaultBuilder(args)
  19. .ConfigureAppConfiguration((context, config) =>
  20. {
  21. var builtConfig = config.Build();
  22. var keyVaultUri = builtConfig[Resources.KeyVaultUri];
  23. if (string.IsNullOrEmpty(keyVaultUri))
  24. {
  25. throw new Exception("KeyVaultUri parameter in the appsettings.json cannot be null or empty");
  26. }
  27. var secretClient = new SecretClient(
  28. new Uri(keyVaultUri),
  29. new DefaultAzureCredential());
  30. config.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());
  31. })
  32. .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());
  33. }
  34. }

The application makes use of the following libraries and features:

Startup.cs

  1. using System;
  2. using Microsoft.OpenApi.Models;
  3. using Microsoft.AspNetCore.Builder;
  4. using Microsoft.AspNetCore.Hosting;
  5. using Microsoft.Extensions.Configuration;
  6. using Microsoft.Extensions.DependencyInjection;
  7. using Microsoft.Extensions.Hosting;
  8. using Microsoft.EntityFrameworkCore;
  9. using StackExchange.Redis;
  10. using Products.Properties;
  11. using Products.Models;
  12. using Products.Helpers;
  13. namespace Products
  14. {
  15. public class Startup
  16. {
  17. /// <summary>
  18. /// Creates an instance of the Startup class
  19. /// </summary>
  20. /// <param name="configuration">The configuration created by the CreateDefaultBuilder.</param>
  21. public Startup(IConfiguration configuration)
  22. {
  23. Configuration = configuration;
  24. }
  25. /// <summary>
  26. /// Gets or sets the Configuration property.
  27. /// </summary>
  28. public IConfiguration Configuration { get; }
  29. /// <summary>
  30. /// This method gets called by the runtime. Use this method to add services to the container.
  31. /// </summary>
  32. /// <param name="services">The services collection.</param>
  33. public void ConfigureServices(IServiceCollection services)
  34. {
  35. services.AddControllersWithViews();
  36. services.AddApplicationInsightsTelemetry(Configuration[Resources.ApplicationInsightsConnectionString]);
  37. services.AddOptions();
  38. services.AddMvc();
  39. services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(Configuration.GetConnectionString(Resources.RedisCacheConnectionString)));
  40. services.AddDbContext<ProductsContext>(options => options.UseSqlServer(Configuration.GetConnectionString(Resources.SqlServerConnectionString)));
  41. // Register the Swagger generator, defining one or more Swagger documents
  42. services.AddSwaggerGen(c =>
  43. {
  44. c.SwaggerDoc("v1", new OpenApiInfo
  45. {
  46. Version = "v1",
  47. Title = "Products API",
  48. Description = "A simple example ASP.NET Core Web API",
  49. TermsOfService = new Uri("https://www.apache.org/licenses/LICENSE-2.0"),
  50. Contact = new OpenApiContact
  51. {
  52. Name = "Paolo Salvatori",
  53. Email = "paolos@microsoft.com",
  54. Url = new Uri("https://github.com/paolosalvatori")
  55. },
  56. License = new OpenApiLicense
  57. {
  58. Name = "Use under Apache License 2.0",
  59. Url = new Uri("https://www.apache.org/licenses/LICENSE-2.0")
  60. }
  61. });
  62. });
  63. }
  64. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
  65. public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  66. {
  67. if (env.IsDevelopment())
  68. {
  69. app.UseDeveloperExceptionPage();
  70. }
  71. else
  72. {
  73. app.UseExceptionHandler("/Home/Error");
  74. // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
  75. app.UseHsts();
  76. }
  77. // Enable middleware to serve generated Swagger as a JSON endpoint.
  78. app.UseSwagger();
  79. // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
  80. app.UseSwaggerUI(c =>
  81. {
  82. c.SwaggerEndpoint("/swagger/v1/swagger.json", "TodoList API V1");
  83. c.RoutePrefix = "swagger";
  84. });
  85. app.UseHttpsRedirection();
  86. app.UseStaticFiles();
  87. app.UseRouting();
  88. app.UseAuthorization();
  89. app.UseEndpoints(endpoints =>
  90. {
  91. endpoints.MapControllerRoute(
  92. name: "default",
  93. pattern: "{controller=Home}/{action=Index}/{id?}");
  94. });
  95. }
  96. }
  97. }

The table below shows the code of the REST API implemented by the ProductsController class. This API is called via jQuery by the client-side script running in the single-page application.

  1. using System;
  2. using System.Linq;
  3. using System.Data;
  4. using System.Diagnostics;
  5. using System.Threading.Tasks;
  6. using System.Globalization;
  7. using Microsoft.AspNetCore.Mvc;
  8. using Microsoft.Data.SqlClient;
  9. using Microsoft.Extensions.Logging;
  10. using Microsoft.EntityFrameworkCore;
  11. using StackExchange.Redis;
  12. using Products.Models;
  13. using Products.Properties;
  14. using Products.Helpers;
  15. namespace Products.Controllers
  16. {
  17. [Route("api/[controller]")]
  18. [Produces("application/json")]
  19. [ApiController]
  20. public class ProductsController : ControllerBase
  21. {
  22. #region Private Instance Fields
  23. private readonly ILogger<ProductsController> logger;
  24. private readonly ProductsContext context;
  25. private readonly IDatabase database;
  26. #endregion
  27. #region Public Constructors
  28. public ProductsController(ILogger<ProductsController> logger,
  29. ProductsContext context,
  30. IConnectionMultiplexer connectionMultiplexer)
  31. {
  32. this.logger = logger;
  33. this.context = context;
  34. database = connectionMultiplexer.GetDatabase();
  35. }
  36. #endregion
  37. #region Public Methods
  38. /// <summary>
  39. /// Gets all the products.
  40. /// </summary>
  41. /// <returns>All the products.</returns>
  42. /// <response code="200">Get all the products, if any.</response>
  43. [HttpGet]
  44. [ProducesResponseType(typeof(Product), 200)]
  45. public async Task<IActionResult> GetAllProductsAsync()
  46. {
  47. var stopwatch = new Stopwatch();
  48. try
  49. {
  50. stopwatch.Start();
  51. logger.LogInformation("Listing all products...");
  52. var values = await database.SetMembersAsync(Resources.RedisKeys);
  53. var items = await database.GetAsync<Product>(values.Select(v => (string)v).ToArray());
  54. if (items.Any())
  55. {
  56. var list = items.ToList();
  57. list.Sort((x, y) => x.ProductId - y.ProductId);
  58. return new OkObjectResult(list.ToArray());
  59. }
  60. var products = context.Products.FromSqlRaw(Resources.GetProducts);
  61. foreach (var product in products)
  62. {
  63. var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);
  64. await database.SetAsync(idAsString, product);
  65. await database.SetAddAsync(Resources.RedisKeys, idAsString);
  66. }
  67. return new OkObjectResult(products.ToArray());
  68. }
  69. catch (Exception ex)
  70. {
  71. var errorMessage = MessageHelper.FormatException(ex);
  72. logger.LogError(errorMessage);
  73. return StatusCode(400, new { error = errorMessage });
  74. }
  75. finally
  76. {
  77. stopwatch.Stop();
  78. logger.LogInformation($"GetAllProductsAsync method completed in {stopwatch.ElapsedMilliseconds} ms.");
  79. }
  80. }
  81. /// <summary>
  82. /// Gets a specific product by id.
  83. /// </summary>
  84. /// <param name="id">Id of the product.</param>
  85. /// <returns>Product with the specified id.</returns>
  86. /// <response code="200">Product found</response>
  87. /// <response code="404">Product not found</response>
  88. [HttpGet("{id}", Name = "GetProductByIdAsync")]
  89. [ProducesResponseType(typeof(Product), 200)]
  90. [ProducesResponseType(typeof(Product), 404)]
  91. public async Task<IActionResult> GetProductByIdAsync(int id)
  92. {
  93. var stopwatch = new Stopwatch();
  94. try
  95. {
  96. stopwatch.Start();
  97. logger.LogInformation($"Getting product {id}...");
  98. var product = await database.GetAsync<Product>(id.ToString());
  99. if (product != null)
  100. {
  101. return new OkObjectResult(product);
  102. }
  103. var products = context.Products.FromSqlRaw(Resources.GetProduct, new SqlParameter
  104. {
  105. ParameterName = "@ProductID",
  106. Direction = ParameterDirection.Input,
  107. SqlDbType = SqlDbType.Int,
  108. Value = id
  109. });
  110. if (products.Any())
  111. {
  112. product = products.FirstOrDefault();
  113. var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);
  114. await database.SetAsync(idAsString, product);
  115. await database.SetAddAsync(Resources.RedisKeys, idAsString);
  116. logger.LogInformation($"Product with id = {product.ProductId} has been successfully retrieved.");
  117. return new OkObjectResult(product);
  118. }
  119. else
  120. {
  121. logger.LogWarning($"No product with id = {id} was found");
  122. return null;
  123. }
  124. }
  125. catch (Exception ex)
  126. {
  127. var errorMessage = MessageHelper.FormatException(ex);
  128. logger.LogError(errorMessage);
  129. return StatusCode(400, new { error = errorMessage });
  130. }
  131. finally
  132. {
  133. stopwatch.Stop();
  134. logger.LogInformation($"GetProductByIdAsync method completed in {stopwatch.ElapsedMilliseconds} ms.");
  135. }
  136. }
  137. /// <summary>
  138. /// Creates a new product.
  139. /// </summary>
  140. /// <remarks>
  141. /// </remarks>
  142. /// <param name="product">Product to create.</param>
  143. /// <returns>If the operation succeeds, it returns the newly created product.</returns>
  144. /// <response code="201">Product successfully created.</response>
  145. /// <response code="400">Product is null.</response>
  146. [HttpPost]
  147. [ProducesResponseType(typeof(Product), 201)]
  148. [ProducesResponseType(typeof(Product), 400)]
  149. public async Task<IActionResult> CreateProductAsync(Product product)
  150. {
  151. var stopwatch = new Stopwatch();
  152. try
  153. {
  154. stopwatch.Start();
  155. if (product == null)
  156. {
  157. logger.LogWarning("Product cannot be null.");
  158. return BadRequest();
  159. }
  160. var productIdParameter = new SqlParameter
  161. {
  162. ParameterName = "@ProductID",
  163. Direction = ParameterDirection.Output,
  164. SqlDbType = SqlDbType.Int
  165. };
  166. var result = await context.Database.ExecuteSqlRawAsync(Resources.AddProduct, new SqlParameter[] {
  167. productIdParameter,
  168. new SqlParameter
  169. {
  170. ParameterName = "@Name",
  171. Direction = ParameterDirection.Input,
  172. SqlDbType = SqlDbType.NVarChar,
  173. Size = 50,
  174. Value = product.Name
  175. },
  176. new SqlParameter
  177. {
  178. ParameterName = "@Category",
  179. Direction = ParameterDirection.Input,
  180. SqlDbType = SqlDbType.NVarChar,
  181. Size = 50,
  182. Value = product.Category
  183. },
  184. new SqlParameter
  185. {
  186. ParameterName = "@Price",
  187. Direction = ParameterDirection.Input,
  188. SqlDbType = SqlDbType.SmallMoney,
  189. Value = product.Price
  190. }
  191. });
  192. if (result ==1 && productIdParameter.Value != null)
  193. {
  194. product.ProductId = (int)productIdParameter.Value;
  195. var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);
  196. await database.SetAsync(idAsString, product);
  197. await database.SetAddAsync(Resources.RedisKeys, idAsString);
  198. logger.LogInformation($"Product with id = {product.ProductId} has been successfully created.");
  199. return CreatedAtRoute("GetProductByIdAsync", new { id = product.ProductId }, product);
  200. }
  201. return null;
  202. }
  203. catch (Exception ex)
  204. {
  205. var errorMessage = MessageHelper.FormatException(ex);
  206. logger.LogError(errorMessage);
  207. return StatusCode(400, new { error = errorMessage });
  208. }
  209. finally
  210. {
  211. stopwatch.Stop();
  212. logger.LogInformation($"CreateProductAsync method completed in {stopwatch.ElapsedMilliseconds} ms.");
  213. }
  214. }
  215. /// <summary>
  216. /// Updates a product.
  217. /// </summary>
  218. /// <param name="id">The id of the product.</param>
  219. /// <param name="product">Product to update.</param>
  220. /// <returns>No content.</returns>
  221. /// <response code="204">No content if the product is successfully updated.</response>
  222. /// <response code="404">If the product is not found.</response>
  223. [HttpPut("{id}")]
  224. [ProducesResponseType(typeof(Product), 204)]
  225. [ProducesResponseType(typeof(Product), 404)]
  226. public async Task<IActionResult> Update(int id, [FromBody] Product product)
  227. {
  228. var stopwatch = new Stopwatch();
  229. try
  230. {
  231. stopwatch.Start();
  232. if (product == null || product.ProductId != id)
  233. {
  234. logger.LogWarning("The product is null or its id is different from the id in the payload.");
  235. return BadRequest();
  236. }
  237. var result = await context.Database.ExecuteSqlRawAsync(Resources.UpdateProduct, new SqlParameter[] {
  238. new SqlParameter
  239. {
  240. ParameterName = "@ProductID",
  241. Direction = ParameterDirection.Input,
  242. SqlDbType = SqlDbType.Int,
  243. Value = product.ProductId
  244. },
  245. new SqlParameter
  246. {
  247. ParameterName = "@Name",
  248. Direction = ParameterDirection.Input,
  249. SqlDbType = SqlDbType.NVarChar,
  250. Size = 50,
  251. Value = product.Name
  252. },
  253. new SqlParameter
  254. {
  255. ParameterName = "@Category",
  256. Direction = ParameterDirection.Input,
  257. SqlDbType = SqlDbType.NVarChar,
  258. Size = 50,
  259. Value = product.Category
  260. },
  261. new SqlParameter
  262. {
  263. ParameterName = "@Price",
  264. Direction = ParameterDirection.Input,
  265. SqlDbType = SqlDbType.SmallMoney,
  266. Value = product.Price
  267. }
  268. });
  269. if (result == 1)
  270. {
  271. var idAsString = id.ToString(CultureInfo.InvariantCulture);
  272. await database.SetAsync(idAsString, product);
  273. await database.SetAddAsync(Resources.RedisKeys, idAsString);
  274. logger.LogInformation("Product with id = {ID} has been successfully updated.", product.ProductId);
  275. }
  276. return new NoContentResult();
  277. }
  278. catch (Exception ex)
  279. {
  280. var errorMessage = MessageHelper.FormatException(ex);
  281. logger.LogError(errorMessage);
  282. return StatusCode(400, new { error = errorMessage });
  283. }
  284. finally
  285. {
  286. stopwatch.Stop();
  287. logger.LogInformation($"Update method completed in {stopwatch.ElapsedMilliseconds} ms.");
  288. }
  289. }
  290. /// <summary>
  291. /// Deletes a specific product.
  292. /// </summary>
  293. /// <param name="id">The id of the product.</param>
  294. /// <returns>No content.</returns>
  295. /// <response code="202">No content if the product is successfully deleted.</response>
  296. /// <response code="404">If the product is not found.</response>
  297. [HttpDelete("{id}")]
  298. [ProducesResponseType(typeof(Product), 204)]
  299. [ProducesResponseType(typeof(Product), 404)]
  300. public async Task<IActionResult> Delete(string id)
  301. {
  302. var stopwatch = new Stopwatch();
  303. try
  304. {
  305. stopwatch.Start();
  306. var result = await context.Database.ExecuteSqlRawAsync(Resources.DeleteProduct, new SqlParameter[] {
  307. new SqlParameter
  308. {
  309. ParameterName = "@ProductID",
  310. Direction = ParameterDirection.Input,
  311. SqlDbType = SqlDbType.Int,
  312. Value = id
  313. }
  314. });
  315. if (result == 1)
  316. {
  317. var idAsString = id.ToString(CultureInfo.InvariantCulture);
  318. await database.KeyDeleteAsync(idAsString);
  319. await database.SetRemoveAsync(Resources.RedisKeys, idAsString);
  320. logger.LogInformation("Product with id = {ID} has been successfully deleted.", id);
  321. }
  322. return new NoContentResult();
  323. }
  324. catch (Exception ex)
  325. {
  326. var errorMessage = MessageHelper.FormatException(ex);
  327. logger.LogError(errorMessage);
  328. return StatusCode(400, new { error = errorMessage });
  329. }
  330. finally
  331. {
  332. stopwatch.Stop();
  333. logger.LogInformation($"Delete method completed in {stopwatch.ElapsedMilliseconds} ms.");
  334. }
  335. }
  336. #endregion
  337. }
  338. }

Deploy the code of the ASP.NET Core application

Once the Azure resources have been deployed to Azure (which can take about 10-12 minutes), you need to deploy the ASP.NET Core web application contained in the src folder to the newly created Azure App Service. You can customize and use the deploy-web-app-to-azure.yml GitHub Actions workflow under the .github\workflow folder to deploy the application to Azure App Service. As an alternative, you can use Visual Studio Code or Visual Studio to deploy the ASP.NET Core application to the Azure App Service created by the ARM template.

Test the Application

After creating the database and deploying the Web App, you can simply navigate to the URL of your Azure App Service to check if the application is up and running, as shown in the following figure.

Resources