From 877e21a4ffdbfbc44db92889834496e594aeb6d8 Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Tue, 7 Sep 2021 10:42:02 -0700 Subject: [PATCH] Add minimal option to webapi template (#36068) * Add minimal option to webapi template - Add "minimal" option to webapi project template - Factor Program.cs into multiple files and update template manifest to exclude/rename dependent on selected options - Updated controller and minimal versions to set endpoint/route name when EnableOpenAPI is true - Configure webapi template minimal option for VS display as "Use controllers" * Update template baselines & fix casing of option description * Fix template baseline tests issue * Update template baseline test to be more resilient Made the template baseline test more resilient by ensuring that all template arg options without values are added to the project key rather than a specific few. Args that have a value are still not added to the key. Keys are all tracked now to ensure uniqueness & an exception is thrown if they aren't. Renamed a few things for better clarity and easy of debugging too. * Make template baseline test project key disregard ordering * Update based on feedback - Change WeatherForecast to a record - Simplify method in test --- .../Shared/TemplatePackageInstaller.cs | 2 + .../.template.config/dotnetcli.host.json | 4 + .../.template.config/ide.host.json | 9 ++ .../.template.config/template.json | 43 +++++ .../Controllers/WeatherForecastController.cs | 16 +- ...gram.MinimalAPIs.OrgOrIndividualB2CAuth.cs | 147 ++++++++++++++++++ .../Program.MinimalAPIs.WindowsOrNoAuth.cs | 52 +++++++ src/ProjectTemplates/scripts/.gitignore | 2 + .../scripts/Run-WebApiMinimal-Locally.ps1 | 12 ++ src/ProjectTemplates/test/BaselineTest.cs | 87 ++++++----- .../test/template-baselines.json | 44 ++++++ 11 files changed, 380 insertions(+), 38 deletions(-) create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs create mode 100644 src/ProjectTemplates/scripts/Run-WebApiMinimal-Locally.ps1 diff --git a/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs b/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs index 7ffdbd1d9afd..157cfab1a809 100644 --- a/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs +++ b/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs @@ -111,6 +111,7 @@ private static async Task InstallTemplatePackages(ITestOutputHelper output) await VerifyCannotFindTemplateAsync(output, "web"); await VerifyCannotFindTemplateAsync(output, "webapp"); + await VerifyCannotFindTemplateAsync(output, "webapi"); await VerifyCannotFindTemplateAsync(output, "mvc"); await VerifyCannotFindTemplateAsync(output, "react"); await VerifyCannotFindTemplateAsync(output, "reactredux"); @@ -125,6 +126,7 @@ private static async Task InstallTemplatePackages(ITestOutputHelper output) await VerifyCanFindTemplate(output, "webapp"); await VerifyCanFindTemplate(output, "web"); + await VerifyCanFindTemplate(output, "webapi"); await VerifyCanFindTemplate(output, "react"); } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/dotnetcli.host.json index 382180645c81..9b97a6182066 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/dotnetcli.host.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/dotnetcli.host.json @@ -4,6 +4,10 @@ "UseLocalDB": { "longName": "use-local-db" }, + "UseMinimalAPIs": { + "longName": "use-minimal-apis", + "shortName": "minimal" + }, "AADInstance": { "longName": "aad-instance", "shortName": "" diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/ide.host.json index 68310fc667ad..2b3113af4e3b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/ide.host.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/ide.host.json @@ -43,6 +43,15 @@ "invertBoolean": true, "isVisible": true, "defaultValue": true + }, + { + "id": "UseMinimalAPIs", + "name": { + "text": "Use controllers (uncheck to use minimal APIs)" + }, + "invertBoolean": true, + "isVisible": true, + "defaultValue": true } ], "disableHttpsSymbol": "NoHttps" diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/template.json b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/template.json index ab893945383c..a4970ed06183 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/.template.config/template.json @@ -36,6 +36,39 @@ "exclude": [ "Properties/launchSettings.json" ] + }, + { + "condition": "(UseMinimalAPIs)", + "exclude": [ + "Controllers/WeatherForecastController.cs", + "Program.cs", + "WeatherForecast.cs" + ] + }, + { + "condition": "(UseMinimalAPIs && (NoAuth || WindowsAuth))", + "rename": { + "Program.MinimalAPIs.WindowsOrNoAuth.cs": "Program.cs" + }, + "exclude": [ + "Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs" + ] + }, + { + "condition": "(UseMinimalAPIs && (IndividualAuth || OrganizationalAuth))", + "rename": { + "Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs": "Program.cs" + }, + "exclude": [ + "Program.MinimalAPIs.WindowsOrNoAuth.cs" + ] + }, + { + "condition": "(UseControllers)", + "exclude": [ + "Program.MinimalAPIs.WindowsOrNoAuth.cs", + "Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs" + ] } ] } @@ -254,6 +287,12 @@ "defaultValue": "false", "description": "Whether to use LocalDB instead of SQLite. This option only applies if --auth Individual or --auth IndividualB2C is specified." }, + "UseMinimalAPIs": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "description": "Whether to use mininmal APIs instead of controllers." + }, "Framework": { "type": "parameter", "description": "The target framework for the project.", @@ -321,6 +360,10 @@ "EnableOpenAPI": { "type": "computed", "value": "(!DisableOpenAPI)" + }, + "UseControllers": { + "type": "computed", + "value": "(!UseMinimalAPIs)" } }, "primaryOutputs": [ diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Controllers/WeatherForecastController.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Controllers/WeatherForecastController.cs index da9c0b0e8eb7..545e1ba9365c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Controllers/WeatherForecastController.cs +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Controllers/WeatherForecastController.cs @@ -40,11 +40,15 @@ public class WeatherForecastController : ControllerBase public WeatherForecastController(ILogger logger, IDownstreamWebApi downstreamWebApi) { - _logger = logger; + _logger = logger; _downstreamWebApi = downstreamWebApi; } +#if (EnableOpenAPI) + [HttpGet(Name = "GetWeatherForecast")] +#else [HttpGet] +#endif public async Task> Get() { using var response = await _downstreamWebApi.CallWebApiForUserAsync("DownstreamApi").ConfigureAwait(false); @@ -74,11 +78,15 @@ public async Task> Get() public WeatherForecastController(ILogger logger, GraphServiceClient graphServiceClient) { - _logger = logger; + _logger = logger; _graphServiceClient = graphServiceClient; } +#if (EnableOpenAPI) + [HttpGet(Name = "GetWeatherForecast")] +#else [HttpGet] +#endif public async Task> Get() { var user = await _graphServiceClient.Me.Request().GetAsync(); @@ -97,7 +105,11 @@ public WeatherForecastController(ILogger logger) _logger = logger; } +#if (EnableOpenAPI) + [HttpGet(Name = "GetWeatherForecast")] +#else [HttpGet] +#endif public IEnumerable Get() { return Enumerable.Range(1, 5).Select(index => new WeatherForecast diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs new file mode 100644 index 000000000000..a5523a08c176 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.OrgOrIndividualB2CAuth.cs @@ -0,0 +1,147 @@ +#if (GenerateApi) +using System.Net.Http; +#endif +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +#if (GenerateGraph) +using Graph = Microsoft.Graph; +#endif +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.Resource; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +#if (OrganizationalAuth) +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) +#if (GenerateApiOrGraph) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() +#if (GenerateApi) + .AddDownstreamWebApi("DownstreamApi", builder.Configuration.GetSection("DownstreamApi")) +#endif +#if (GenerateGraph) + .AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi")) +#endif + .AddInMemoryTokenCaches(); +#else + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); +#endif +#elif (IndividualB2CAuth) +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) +#if (GenerateApi) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAdB2C")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddDownstreamWebApi("DownstreamApi", builder.Configuration.GetSection("DownstreamApi")) + .AddInMemoryTokenCaches(); +#else + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAdB2C")); +#endif +#endif +builder.Services.AddAuthorization(); + +#if (EnableOpenAPI) +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +#endif + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +#if (EnableOpenAPI) +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +#endif +#if (RequiresHttps) + +app.UseHttpsRedirection(); +#endif + +app.UseAuthentication(); +app.UseAuthorization(); + +var scopeRequiredByApi = app.Configuration["AzureAd:Scopes"]; +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +#if (GenerateApi) +app.MapGet("/weatherforecast", (HttpContext httpContext, IDownstreamWebApi downstreamWebApi) => +{ + httpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi); + + using var response = await downstreamWebApi.CallWebApiForUserAsync("DownstreamApi").ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.OK) + { + var apiResult = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + // Do something + } + else + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}: {error}"); + } + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateTime.Now.AddDays(index), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + + return forecast; +}) +#elseif (GenerateGraph) +app.MapGet("/weahterforecast", (HttpContext httpContext, GraphServiceClient graphServiceClient) => +{ + httpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi); + + var user = await _graphServiceClient.Me.Request().GetAsync(); + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateTime.Now.AddDays(index), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + + return forecast; +}) +#else +app.MapGet("/weatherforecast", (HttpContext httpContext) => +{ + httpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi); + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateTime.Now.AddDays(index), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +#endif +#if (EnableOpenAPI) +}) +.WithName("GetWeatherForecast") +.RequireAuthorization(); +#else +}) +.RequireAuthorization(); +#endif + +app.Run(); + +record WeatherForecast(DateTime Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs new file mode 100644 index 000000000000..bc5064e57ac0 --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Program.MinimalAPIs.WindowsOrNoAuth.cs @@ -0,0 +1,52 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +#if (EnableOpenAPI) +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +#endif + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +#if (EnableOpenAPI) +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +#endif +#if (RequiresHttps) + +app.UseHttpsRedirection(); +#endif + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateTime.Now.AddDays(index), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +#if (EnableOpenAPI) +}) +.WithName("GetWeatherForecast"); +#else +}); +#endif + +app.Run(); + +record WeatherForecast(DateTime Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/src/ProjectTemplates/scripts/.gitignore b/src/ProjectTemplates/scripts/.gitignore index ceb08c3caee9..60f1c8f0fd54 100644 --- a/src/ProjectTemplates/scripts/.gitignore +++ b/src/ProjectTemplates/scripts/.gitignore @@ -5,11 +5,13 @@ angular/ blazorserver/ blazorwasm/ mvc/ +mvcorgauth/ razor/ react/ reactredux/ web/ webapp/ webapi/ +webapimin/ worker/ grpc/ \ No newline at end of file diff --git a/src/ProjectTemplates/scripts/Run-WebApiMinimal-Locally.ps1 b/src/ProjectTemplates/scripts/Run-WebApiMinimal-Locally.ps1 new file mode 100644 index 000000000000..19325a7c7eca --- /dev/null +++ b/src/ProjectTemplates/scripts/Run-WebApiMinimal-Locally.ps1 @@ -0,0 +1,12 @@ +#!/usr/bin/env pwsh +#requires -version 4 + +[CmdletBinding(PositionalBinding = $false)] +param() + +Set-StrictMode -Version 2 +$ErrorActionPreference = 'Stop' + +. $PSScriptRoot\Test-Template.ps1 + +Test-Template "webapimin" "webapi -minimal" "Microsoft.DotNet.Web.ProjectTemplates.6.0.6.0.0-dev.nupkg" $false diff --git a/src/ProjectTemplates/test/BaselineTest.cs b/src/ProjectTemplates/test/BaselineTest.cs index 801b9b78582c..786b7d36dc6b 100644 --- a/src/ProjectTemplates/test/BaselineTest.cs +++ b/src/ProjectTemplates/test/BaselineTest.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Testing; using Newtonsoft.Json; @@ -17,20 +17,7 @@ namespace Templates.Test { public class BaselineTest : LoggedTest { - private static readonly Regex TemplateNameRegex = new Regex( - "new (?