From cbe1da300759825840165221da29e093ff095f09 Mon Sep 17 00:00:00 2001 From: John Luo Date: Mon, 3 Aug 2020 10:48:34 -0700 Subject: [PATCH 1/8] Convert DatabaseErrorPage middleware to exception filter --- .../src/MigrationsEndPointStartupFilter.cs | 21 + src/DefaultBuilder/src/WebHost.cs | 8 + .../src/DatabaseContextDetails.cs | 16 + .../src/DatabaseErrorPageExtensions.cs | 3 + .../src/DatabaseErrorPageMiddleware.cs | 82 +--- .../src/DatabaseExceptionHandler.cs | 85 ++++ ...ticsEntityFrameworkCoreLoggerExtensions.cs | 12 +- ...ContextDatabaseContextDetailsExtensions.cs | 94 ++++ ...ore.Diagnostics.EntityFrameworkCore.csproj | 1 + .../src/MigrationsEndPointMiddleware.cs | 38 +- .../src/Strings.resx | 23 +- .../src/Views/DatabaseErrorPage.Designer.cs | 404 +++++++++++++----- .../src/Views/DatabaseErrorPage.cshtml | 140 +++--- .../src/Views/DatabaseErrorPageModel.cs | 17 +- .../DatabaseErrorPageMiddlewareTest.cs | 28 +- .../MigrationsEndPointMiddlewareTest.cs | 4 +- .../test/UnitTests/DatabaseErrorPageTest.cs | 132 ++++-- .../test/UnitTests/Helpers/AssertHelpers.cs | 20 +- .../test/UnitTests/Helpers/StringHelpers.cs | 11 +- .../DatabaseErrorPageSample/Startup.cs | 2 + .../tools/RazorPageGenerator/Program.cs | 216 ++++++++++ .../RazorPageGenerator.csproj | 18 + .../RazorPageGeneratorResults.cs | 12 + 23 files changed, 1038 insertions(+), 349 deletions(-) create mode 100644 src/DefaultBuilder/src/MigrationsEndPointStartupFilter.cs create mode 100644 src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseContextDetails.cs create mode 100644 src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseExceptionHandler.cs create mode 100644 src/Middleware/Diagnostics.EntityFrameworkCore/src/HttpContextDatabaseContextDetailsExtensions.cs create mode 100644 src/Middleware/tools/RazorPageGenerator/Program.cs create mode 100644 src/Middleware/tools/RazorPageGenerator/RazorPageGenerator.csproj create mode 100644 src/Middleware/tools/RazorPageGenerator/RazorPageGeneratorResults.cs diff --git a/src/DefaultBuilder/src/MigrationsEndPointStartupFilter.cs b/src/DefaultBuilder/src/MigrationsEndPointStartupFilter.cs new file mode 100644 index 000000000000..71e6ebf3c218 --- /dev/null +++ b/src/DefaultBuilder/src/MigrationsEndPointStartupFilter.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Microsoft.AspNetCore +{ + internal class MigrationsEndPointStartupFilter : IStartupFilter + { + public Action Configure(Action next) + { + return app => + { + //app.UseMigrationsEndPoint(); + next(app); + }; + } + } +} diff --git a/src/DefaultBuilder/src/WebHost.cs b/src/DefaultBuilder/src/WebHost.cs index ff79107d3265..ae35f1b91ea3 100644 --- a/src/DefaultBuilder/src/WebHost.cs +++ b/src/DefaultBuilder/src/WebHost.cs @@ -5,6 +5,8 @@ using System.IO; using System.Reflection; using Microsoft.AspNetCore.Builder; +//using Microsoft.AspNetCore.Diagnostics; +//using Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore; using Microsoft.AspNetCore.HostFiltering; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.StaticWebAssets; @@ -259,6 +261,12 @@ internal static void ConfigureWebDefaults(IWebHostBuilder builder) } services.AddRouting(); + + //if (hostingContext.HostingEnvironment.IsDevelopment()) + //{ + // services.AddSingleton(); + // services.AddTransient(); + //} }) .UseIIS() .UseIISIntegration(); diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseContextDetails.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseContextDetails.cs new file mode 100644 index 000000000000..f583b668d519 --- /dev/null +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseContextDetails.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore +{ + internal class DatabaseContextDetails + { + public Type Type { get; set; } + public bool DatabaseExists { get; set; } + public bool PendingModelChanges { get; set; } + public IEnumerable PendingMigrations { get; set; } + } +} diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageExtensions.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageExtensions.cs index 5b8170735c99..3afa13174801 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageExtensions.cs +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageExtensions.cs @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Builder /// /// extension methods for the . /// + [Obsolete("This is obsolete and will be removed in a future version. Use DatabaseExceptionHandler instead.")] public static class DatabaseErrorPageExtensions { /// @@ -19,6 +20,7 @@ public static class DatabaseErrorPageExtensions /// /// The to register the middleware with. /// The same instance so that multiple calls can be chained. + [Obsolete("This is obsolete and will be removed in a future version. Use DatabaseExceptionHandler instead.")] public static IApplicationBuilder UseDatabaseErrorPage(this IApplicationBuilder app) { if (app == null) @@ -36,6 +38,7 @@ public static IApplicationBuilder UseDatabaseErrorPage(this IApplicationBuilder /// The to register the middleware with. /// A that specifies options for the middleware. /// The same instance so that multiple calls can be chained. + [Obsolete("This is obsolete and will be removed in a future version. Use DatabaseExceptionHandler instead.")] public static IApplicationBuilder UseDatabaseErrorPage( this IApplicationBuilder app, DatabaseErrorPageOptions options) { diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageMiddleware.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageMiddleware.cs index 8fea0d3ac110..e80a72bd30ed 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageMiddleware.cs +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseErrorPageMiddleware.cs @@ -56,6 +56,7 @@ public void Hold(Exception exception, Type contextType) /// consumes them to detect database related exception. /// /// The options to control what information is displayed on the error page. + [Obsolete("This is obsolete and will be removed in a future version. Use DatabaseExceptionHandler instead.")] public DatabaseErrorPageMiddleware( RequestDelegate next, ILoggerFactory loggerFactory, @@ -116,81 +117,18 @@ public virtual async Task Invoke(HttpContext httpContext) if (ShouldDisplayErrorPage(exception)) { var contextType = _localDiagnostic.Value.ContextType; - var context = (DbContext)httpContext.RequestServices.GetService(contextType); + var details = await httpContext.GetContextDetailsAsync(contextType, _logger); - if (context == null) + if (details != null && (details.PendingModelChanges || details.PendingMigrations.Count() > 0)) { - _logger.ContextNotRegisteredDatabaseErrorPageMiddleware(contextType.FullName); - } - else - { - var relationalDatabaseCreator = context.GetService() as IRelationalDatabaseCreator; - if (relationalDatabaseCreator == null) - { - _logger.NotRelationalDatabase(); - } - else + var page = new DatabaseErrorPage { - var databaseExists = await relationalDatabaseCreator.ExistsAsync(); - - if (databaseExists) - { - databaseExists = await relationalDatabaseCreator.HasTablesAsync(); - } - - var migrationsAssembly = context.GetService(); - var modelDiffer = context.GetService(); - - var snapshotModel = migrationsAssembly.ModelSnapshot?.Model; - if (snapshotModel is IConventionModel conventionModel) - { - var conventionSet = context.GetService().CreateConventionSet(); - - var typeMappingConvention = conventionSet.ModelFinalizingConventions.OfType().FirstOrDefault(); - if (typeMappingConvention != null) - { - typeMappingConvention.ProcessModelFinalizing(conventionModel.Builder, null); - } - - var relationalModelConvention = conventionSet.ModelFinalizedConventions.OfType().FirstOrDefault(); - if (relationalModelConvention != null) - { - snapshotModel = relationalModelConvention.ProcessModelFinalized(conventionModel); - } - } - - if (snapshotModel is IMutableModel mutableModel) - { - snapshotModel = mutableModel.FinalizeModel(); - } - - // HasDifferences will return true if there is no model snapshot, but if there is an existing database - // and no model snapshot then we don't want to show the error page since they are most likely targeting - // and existing database and have just misconfigured their model - - var pendingModelChanges - = (!databaseExists || migrationsAssembly.ModelSnapshot != null) - && modelDiffer.HasDifferences(snapshotModel?.GetRelationalModel(), context.Model.GetRelationalModel()); - - var pendingMigrations - = (databaseExists - ? await context.Database.GetPendingMigrationsAsync() - : context.Database.GetMigrations()) - .ToArray(); - - if (pendingModelChanges || pendingMigrations.Length > 0) - { - var page = new DatabaseErrorPage - { - Model = new DatabaseErrorPageModel( - contextType, exception, databaseExists, pendingModelChanges, pendingMigrations, _options) - }; - - await page.ExecuteAsync(httpContext); - - return; - } - } + Model = new DatabaseErrorPageModel(exception, new DatabaseContextDetails[] { details }, _options) + }; + + await page.ExecuteAsync(httpContext); + + return; } } } diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseExceptionHandler.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseExceptionHandler.cs new file mode 100644 index 000000000000..49cd7efc62f1 --- /dev/null +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DatabaseExceptionHandler.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.Views; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore +{ + public class DatabaseErrorHandler : IDeveloperPageExceptionFilter + { + private readonly ILogger _logger; + private readonly DatabaseErrorPageOptions _options; + + public DatabaseErrorHandler(ILogger logger, IOptions options) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task HandleExceptionAsync(ErrorContext errorContext, Func next) + { + if (errorContext.Exception is DbException) + { + try + { + // Look for DbContext classes registered in the service provider + // TODO: Decouple + var registeredContexts = errorContext.HttpContext.RequestServices.GetServices() + .Select(o => o.ContextType); + + if (registeredContexts.Any()) + { + var contextDetails = new List(); + + foreach (var registeredContext in registeredContexts) + { + var details = await errorContext.HttpContext.GetContextDetailsAsync(registeredContext, _logger); + + if (details != null) + { + contextDetails.Add(details); + } + } + + if (contextDetails.Any(c => c.PendingModelChanges || c.PendingMigrations.Any())) + { + var page = new DatabaseErrorPage + { + Model = new DatabaseErrorPageModel(errorContext.Exception, contextDetails, _options) + }; + + await page.ExecuteAsync(errorContext.HttpContext); + return; + } + } + } + catch (Exception e) + { + _logger.DatabaseErrorPageMiddlewareException(e); + } + + await next(errorContext); + } + else + { + await next(errorContext); + } + } + } +} diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DiagnosticsEntityFrameworkCoreLoggerExtensions.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DiagnosticsEntityFrameworkCoreLoggerExtensions.cs index 8128ac1bafd1..494213a90097 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/DiagnosticsEntityFrameworkCoreLoggerExtensions.cs +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/DiagnosticsEntityFrameworkCoreLoggerExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -14,11 +14,6 @@ internal static class DiagnosticsEntityFrameworkCoreLoggerExtensions new EventId(1, "NoContextType"), "No context type was specified. Ensure the form data from the request includes a 'context' value, specifying the context type name to apply migrations for."); - private static readonly Action _invalidContextType = LoggerMessage.Define( - LogLevel.Error, - new EventId(2, "InvalidContextType"), - "The context type '{ContextTypeName}' could not be loaded. Ensure this is the correct type name for the context you are trying to apply migrations for."); - private static readonly Action _contextNotRegistered = LoggerMessage.Define( LogLevel.Error, new EventId(3, "ContextNotRegistered"), @@ -85,11 +80,6 @@ public static void NoContextType(this ILogger logger) _noContextType(logger, null); } - public static void InvalidContextType(this ILogger logger, string contextTypeName) - { - _invalidContextType(logger, contextTypeName, null); - } - public static void ContextNotRegistered(this ILogger logger, string contextTypeName) { _contextNotRegistered(logger, contextTypeName, null); diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/HttpContextDatabaseContextDetailsExtensions.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/HttpContextDatabaseContextDetailsExtensions.cs new file mode 100644 index 000000000000..1554d0a93838 --- /dev/null +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/HttpContextDatabaseContextDetailsExtensions.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + + +namespace Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore +{ + internal static class HttpContextDatabaseContextDetailsExtensions + { + public static async Task GetContextDetailsAsync(this HttpContext httpContext, Type dbcontextType, ILogger logger) + { + // TODO: Decouple + var context = (DbContext)httpContext.RequestServices.GetService(dbcontextType); + // TODO: Decouple + var relationalDatabaseCreator = context.GetService() as IRelationalDatabaseCreator; + if (relationalDatabaseCreator == null) + { + logger.NotRelationalDatabase(); + } + else + { + var databaseExists = await relationalDatabaseCreator.ExistsAsync(); + + if (databaseExists) + { + databaseExists = await relationalDatabaseCreator.HasTablesAsync(); + } + + // TODO: Decouple + var migrationsAssembly = context.GetService(); + // TODO: Decouple + var modelDiffer = context.GetService(); + + var snapshotModel = migrationsAssembly.ModelSnapshot?.Model; + // TODO: Decouple + if (snapshotModel is IConventionModel conventionModel) + { + // TODO: Decouple + var conventionSet = context.GetService().CreateConventionSet(); + + // TODO: Decouple + var typeMappingConvention = conventionSet.ModelFinalizingConventions.OfType().FirstOrDefault(); + if (typeMappingConvention != null) + { + typeMappingConvention.ProcessModelFinalizing(conventionModel.Builder, null); + } + + // TODO: Decouple + var relationalModelConvention = conventionSet.ModelFinalizedConventions.OfType().FirstOrDefault(); + if (relationalModelConvention != null) + { + snapshotModel = relationalModelConvention.ProcessModelFinalized(conventionModel); + } + } + + // TODO: Decouple + if (snapshotModel is IMutableModel mutableModel) + { + snapshotModel = mutableModel.FinalizeModel(); + } + + // HasDifferences will return true if there is no model snapshot, but if there is an existing database + // and no model snapshot then we don't want to show the error page since they are most likely targeting + // and existing database and have just misconfigured their model + + return new DatabaseContextDetails + { + Type = dbcontextType, + DatabaseExists = databaseExists, + PendingModelChanges = (!databaseExists || migrationsAssembly.ModelSnapshot != null) + && modelDiffer.HasDifferences(snapshotModel?.GetRelationalModel(), context.Model.GetRelationalModel()), + PendingMigrations = databaseExists + ? await context.Database.GetPendingMigrationsAsync() + : context.Database.GetMigrations() + }; + } + + return null; + } + } +} diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj b/src/Middleware/Diagnostics.EntityFrameworkCore/src/Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj index 8d4631805982..36e5b5607934 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/MigrationsEndPointMiddleware.cs b/src/Middleware/Diagnostics.EntityFrameworkCore/src/MigrationsEndPointMiddleware.cs index b6c8b45a9ac3..98ba17e8e7b1 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/MigrationsEndPointMiddleware.cs +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/MigrationsEndPointMiddleware.cs @@ -2,11 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -72,23 +74,26 @@ public virtual async Task Invoke(HttpContext context) if (db != null) { + // TODO: Decouple + var dbName = db.GetType().FullName; try { - _logger.ApplyingMigrations(db.GetType().FullName); + _logger.ApplyingMigrations(dbName); + // TODO: Decouple await db.Database.MigrateAsync(); context.Response.StatusCode = (int)HttpStatusCode.NoContent; context.Response.Headers.Add("Pragma", new[] { "no-cache" }); context.Response.Headers.Add("Cache-Control", new[] { "no-cache,no-store" }); - _logger.MigrationsApplied(db.GetType().FullName); + _logger.MigrationsApplied(dbName); } catch (Exception ex) { - var message = Strings.FormatMigrationsEndPointMiddleware_Exception(db.GetType().FullName) + ex; + var message = Strings.FormatMigrationsEndPointMiddleware_Exception(dbName) + ex; - _logger.MigrationsEndPointMiddlewareException(db.GetType().FullName, ex); + _logger.MigrationsEndPointMiddlewareException(dbName, ex); throw new InvalidOperationException(message, ex); } @@ -114,31 +119,26 @@ private static async Task GetDbContext(HttpContext context, ILogger l return null; } - var contextType = Type.GetType(contextTypeName); + // TODO: Decouple + // Look for DbContext classes registered in the service provider + var registeredContexts = context.RequestServices.GetServices() + .Select(o => o.ContextType); - if (contextType == null) + if (!registeredContexts.Any(c => string.Equals(contextTypeName, c.AssemblyQualifiedName))) { - var message = Strings.FormatMigrationsEndPointMiddleware_InvalidContextType(contextTypeName); + var message = Strings.FormatMigrationsEndPointMiddleware_ContextNotRegistered(contextTypeName); - logger.InvalidContextType(contextTypeName); + logger.ContextNotRegistered(contextTypeName); await WriteErrorToResponse(context.Response, message); return null; } - var db = (DbContext)context.RequestServices.GetService(contextType); - - if (db == null) - { - var message = Strings.FormatMigrationsEndPointMiddleware_ContextNotRegistered(contextType.FullName); - - logger.ContextNotRegistered(contextType.FullName); - - await WriteErrorToResponse(context.Response, message); + var contextType = Type.GetType(contextTypeName); - return null; - } + // TODO: Decouple + var db = (DbContext)context.RequestServices.GetService(contextType); return db; } diff --git a/src/Middleware/Diagnostics.EntityFrameworkCore/src/Strings.resx b/src/Middleware/Diagnostics.EntityFrameworkCore/src/Strings.resx index 0c6bad1477b4..7d2abf8767e8 100644 --- a/src/Middleware/Diagnostics.EntityFrameworkCore/src/Strings.resx +++ b/src/Middleware/Diagnostics.EntityFrameworkCore/src/Strings.resx @@ -1,4 +1,4 @@ - +