diff --git a/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs b/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs index 20a2a00a8619..1a5db4033bfc 100644 --- a/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs +++ b/src/ProjectTemplates/Shared/TemplatePackageInstaller.cs @@ -109,6 +109,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"); @@ -123,6 +124,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 (?