From f3387d615621ef4b63936b8f15629a2e401adcba Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 13 Apr 2023 18:23:32 +1000 Subject: [PATCH 01/24] Return 400 bad request when IOException is thrown when ready form. (#47584) --- .../src/RequestDelegateFactory.cs | 14 ++++ .../test/RequestDelegateFactoryTests.cs | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index ec5487854ae0..f5ec91a7616e 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -1305,9 +1305,16 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { bodyValue = await httpContext.Request.ReadFromJsonAsync(jsonTypeInfo); } + catch (BadHttpRequestException ex) + { + Log.RequestBodyIOException(httpContext, ex); + httpContext.Response.StatusCode = ex.StatusCode; + return (null, false); + } catch (IOException ex) { Log.RequestBodyIOException(httpContext, ex); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (null, false); } catch (JsonException ex) @@ -1418,9 +1425,16 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, { formValue = await httpContext.Request.ReadFormAsync(); } + catch (BadHttpRequestException ex) + { + Log.RequestBodyIOException(httpContext, ex); + httpContext.Response.StatusCode = ex.StatusCode; + return (null, false); + } catch (IOException ex) { Log.RequestBodyIOException(httpContext, ex); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (null, false); } catch (InvalidDataException ex) diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 32b60a5adbca..91b776e88042 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -3557,6 +3557,72 @@ void TestAction(IFormFile file) Assert.Same(ioException, logMessage.Exception); } + [Fact] + public async Task RequestDelegateThrowsBadHttpRequestExceptionWhenReadingOversizeFormResultsIn413BadRequest() + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var exception = new BadHttpRequestException("Request body too large. The max request body size is [who cares] bytes.", 413); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(exception); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(@"Reading the request body failed with an IOException.", logMessage.Message); + Assert.Same(exception, logMessage.Exception); + Assert.Equal(413, httpContext.Response.StatusCode); + } + + [Fact] + public async Task RequestDelegateThrowsBadHttpRequestExceptionWhenReadingOversizeJsonBodyResultsIn413BadRequest() + { + var invoked = false; + + void TestAction(Todo todo) + { + invoked = true; + } + + var exception = new BadHttpRequestException("Request body too large. The max request body size is [who cares] bytes.", 413); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1000"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(exception); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(@"Reading the request body failed with an IOException.", logMessage.Message); + Assert.Same(exception, logMessage.Exception); + Assert.Equal(413, httpContext.Response.StatusCode); + } + [Fact] public async Task RequestDelegateLogsMalformedFormAsDebugAndSets400Response() { From 68ae6b0d8aa2f4a0ff189d5cedc741e32cc643d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Thu, 13 Apr 2023 11:55:43 +0200 Subject: [PATCH 02/24] [blazor] Configure mono runtime with startupMemoryCache and runtimeOptions from boot config (#47670) --- src/Components/Web.JS/src/Platform/BootConfig.ts | 2 ++ .../Web.JS/src/Platform/Mono/MonoPlatform.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Components/Web.JS/src/Platform/BootConfig.ts b/src/Components/Web.JS/src/Platform/BootConfig.ts index 29ac135842b3..17c3e6381a10 100644 --- a/src/Components/Web.JS/src/Platform/BootConfig.ts +++ b/src/Components/Web.JS/src/Platform/BootConfig.ts @@ -53,6 +53,8 @@ export interface BootJsonData { readonly cacheBootResources: boolean; readonly config: string[]; readonly icuDataMode: ICUDataMode; + readonly startupMemoryCache: boolean | null; + readonly runtimeOptions: string[] | null; // These properties are tacked on, and not found in the boot.json file modifiableAssemblies: string | null; diff --git a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts index d142bbee6007..b801e5e03959 100644 --- a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts +++ b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts @@ -356,7 +356,17 @@ function prepareRuntimeConfig(resourceLoader: WebAssemblyResourceLoader): Dotnet async function createRuntimeInstance(resourceLoader: WebAssemblyResourceLoader): Promise { const { dotnet } = await importDotnetJs(resourceLoader); const moduleConfig = prepareRuntimeConfig(resourceLoader); - (dotnet as any).withModuleConfig(moduleConfig); + const anyDotnet = (dotnet as any); + + anyDotnet.withModuleConfig(moduleConfig); + + if (resourceLoader.bootConfig.startupMemoryCache !== undefined) { + anyDotnet.withStartupMemoryCache(resourceLoader.bootConfig.startupMemoryCache); + } + + if (resourceLoader.bootConfig.runtimeOptions) { + anyDotnet.withRuntimeOptions(resourceLoader.bootConfig.runtimeOptions); + } const runtime = await dotnet.create(); const { MONO: mono, BINDING: binding, Module: module, setModuleImports, INTERNAL: mono_internal } = runtime; From a4d27a1979568b0687660924375b596720812e53 Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Thu, 13 Apr 2023 10:40:13 -0700 Subject: [PATCH 03/24] Add a .http file to API project template (#47676) * Add a .http file to API project template Fixes #47675 --- .../content/Api-CSharp/Company.ApiApplication1.http | 11 +++++++++++ .../test/Templates.Tests/BaselineTest.cs | 2 ++ .../test/Templates.Tests/template-baselines.json | 4 ++++ 3 files changed, 17 insertions(+) create mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Company.ApiApplication1.http diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Company.ApiApplication1.http b/src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Company.ApiApplication1.http new file mode 100644 index 000000000000..2df1c50ab72c --- /dev/null +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/Api-CSharp/Company.ApiApplication1.http @@ -0,0 +1,11 @@ +@Company.ApiApplication1_HostAddress = http://localhost:5000 + +GET {{Company.ApiApplication1_HostAddress}}/todos/ +Accept: application/json + +### + +GET {{Company.ApiApplication1_HostAddress}}/todos/1 +Accept: application/json + +### diff --git a/src/ProjectTemplates/test/Templates.Tests/BaselineTest.cs b/src/ProjectTemplates/test/Templates.Tests/BaselineTest.cs index 335c1e604164..f0fcd4175a72 100644 --- a/src/ProjectTemplates/test/Templates.Tests/BaselineTest.cs +++ b/src/ProjectTemplates/test/Templates.Tests/BaselineTest.cs @@ -75,6 +75,8 @@ public async Task Template_Produces_The_Right_Set_Of_FilesAsync(string arguments Project = await ProjectFactory.CreateProject(Output); await Project.RunDotNetNewRawAsync(arguments); + expectedFiles = expectedFiles.Select(f => f.Replace("{ProjectName}", Project.ProjectName)).ToArray(); + foreach (var file in expectedFiles) { AssertFileExists(Project.TemplateOutputDir, file, shouldExist: true); diff --git a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json index 19b1340d1aaa..5ce2881b37bd 100644 --- a/src/ProjectTemplates/test/Templates.Tests/template-baselines.json +++ b/src/ProjectTemplates/test/Templates.Tests/template-baselines.json @@ -534,6 +534,7 @@ "Files": [ "Todo.cs", "Program.cs", + "{ProjectName}.http", "Properties/launchSettings.json" ], "AuthOption": "None" @@ -544,6 +545,7 @@ "Files": [ "Todo.cs", "Program.cs", + "{ProjectName}.http", "Properties/launchSettings.json" ], "AuthOption": "None" @@ -554,6 +556,7 @@ "Files": [ "Todo.cs", "Program.cs", + "{ProjectName}.http", "Properties/launchSettings.json" ], "AuthOption": "None" @@ -564,6 +567,7 @@ "Files": [ "Todo.cs", "Program.cs", + "{ProjectName}.http", "Properties/launchSettings.json" ], "AuthOption": "None" From 7a0d0de38dbb729b823115d60d2d9ac691d8c292 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 13 Apr 2023 19:52:52 +0200 Subject: [PATCH 04/24] [Blazor] Specialize pipeline for MapRazorComponents (#47649) The current pipeline is common for MapRazorComponents and RazorComponentResults which add extra complexity to MapRazorComponents which we expect to be the common case. The change splits the pipeline in two, so that we can simplify the pipeline for MapRazorComponents, where we can make assumptions that are not possible in RazorComponentResult. This change introduces RazorComponentEndpointInvoker to capture all the required state that is needed for rendering the component endpoint and avoid additional closures from captured state caused by calling Dispatcher.InvokeAsync. We also guarantee that we only call `DispatchAsync` once at the root level to enter the synchronization context. Finally, we skip over all the render modes and preserve prerendered component state mode update, since that's not needed at this level. --- .../Builder/RazorComponentEndpointFactory.cs | 10 ++- .../Endpoints/src/RazorComponentEndpoint.cs | 79 ----------------- .../src/RazorComponentEndpointInvoker.cs | 78 +++++++++++++++++ .../EndpointHtmlRenderer.Prerendering.cs | 87 +++++++++++++------ .../Results/RazorComponentResultExecutor.cs | 63 +++++++++++++- ...cs => RazorComponentResultExecutorTest.cs} | 28 +++--- .../test/RazorComponentResultTest.cs | 4 +- 7 files changed, 225 insertions(+), 124 deletions(-) delete mode 100644 src/Components/Endpoints/src/RazorComponentEndpoint.cs create mode 100644 src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs rename src/Components/Endpoints/test/{RazorComponentEndpointTest.cs => RazorComponentResultExecutorTest.cs} (94%) diff --git a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs index 02731095e902..60fc5413faa6 100644 --- a/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs +++ b/src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs @@ -55,8 +55,16 @@ internal void AddEndpoints( // The display name is for debug purposes by endpoint routing. builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})"; - builder.RequestDelegate = RazorComponentEndpoint.CreateRouteDelegate(pageDefinition.Type); + builder.RequestDelegate = CreateRouteDelegate(pageDefinition.Type); endpoints.Add(builder.Build()); } + + private static RequestDelegate CreateRouteDelegate(Type componentType) + { + return httpContext => + { + return new RazorComponentEndpointInvoker(httpContext, componentType).RenderComponent(); + }; + } } diff --git a/src/Components/Endpoints/src/RazorComponentEndpoint.cs b/src/Components/Endpoints/src/RazorComponentEndpoint.cs deleted file mode 100644 index aecfc38804c3..000000000000 --- a/src/Components/Endpoints/src/RazorComponentEndpoint.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Buffers; -using System.Text; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.AspNetCore.Components.Endpoints; - -internal static class RazorComponentEndpoint -{ - public static RequestDelegate CreateRouteDelegate(Type componentType) - { - return httpContext => - { - httpContext.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; - return RenderComponentToResponse(httpContext, RenderMode.Static, componentType, componentParameters: null, preventStreamingRendering: false); - }; - } - - internal static Task RenderComponentToResponse( - HttpContext httpContext, - RenderMode renderMode, - Type componentType, - IReadOnlyDictionary? componentParameters, - bool preventStreamingRendering) - { - var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService(); - return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => - { - // We could pool these dictionary instances if we wanted, and possibly even the ParameterView - // backing buffers could come from a pool like they do during rendering. - var hostParameters = ParameterView.FromDictionary(new Dictionary - { - { nameof(RazorComponentEndpointHost.RenderMode), renderMode }, - { nameof(RazorComponentEndpointHost.ComponentType), componentType }, - { nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters }, - }); - - await using var writer = CreateResponseWriter(httpContext.Response.Body); - - // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, - // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host - // component takes care of switching into your desired render mode when it produces its own output. - var htmlContent = (EndpointHtmlRenderer.PrerenderedComponentHtmlContent)(await endpointHtmlRenderer.PrerenderComponentAsync( - httpContext, - typeof(RazorComponentEndpointHost), - RenderMode.Static, - hostParameters, - waitForQuiescence: preventStreamingRendering)); - - // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) - // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent - // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the - // renderer sync context and cause a batch that would get missed. - htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above - - if (!htmlContent.QuiescenceTask.IsCompleted) - { - await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer); - } - - // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying - // response asynchronously. In the absence of this line, the buffer gets synchronously written to the - // response as part of the Dispose which has a perf impact. - await writer.FlushAsync(); - }); - } - - private static TextWriter CreateResponseWriter(Stream bodyStream) - { - // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize - const int DefaultBufferSize = 16 * 1024; - return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); - } -} diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs new file mode 100644 index 000000000000..40c67538a276 --- /dev/null +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class RazorComponentEndpointInvoker +{ + private readonly HttpContext _context; + private readonly EndpointHtmlRenderer _renderer; + private readonly Type _componentType; + + public RazorComponentEndpointInvoker(HttpContext context, Type componentType) + { + _context = context; + _renderer = _context.RequestServices.GetRequiredService(); + _componentType = componentType; + } + + public Task RenderComponent() + { + return _renderer.Dispatcher.InvokeAsync(RenderComponentCore); + } + + private async Task RenderComponentCore() + { + _context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType; + + // We could pool these dictionary instances if we wanted, and possibly even the ParameterView + // backing buffers could come from a pool like they do during rendering. + var hostParameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(RazorComponentEndpointHost.RenderMode), RenderMode.Static }, + { nameof(RazorComponentEndpointHost.ComponentType), _componentType }, + { nameof(RazorComponentEndpointHost.ComponentParameters), null }, + }); + + await using var writer = CreateResponseWriter(_context.Response.Body); + + // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, + // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host + // component takes care of switching into your desired render mode when it produces its own output. + var htmlContent = await _renderer.RenderEndpointComponent( + _context, + typeof(RazorComponentEndpointHost), + hostParameters, + waitForQuiescence: false); + + // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) + // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent + // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the + // renderer sync context and cause a batch that would get missed. + htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above + + if (!htmlContent.QuiescenceTask.IsCompleted) + { + await _renderer.SendStreamingUpdatesAsync(_context, htmlContent.QuiescenceTask, writer); + } + + // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying + // response asynchronously. In the absence of this line, the buffer gets synchronously written to the + // response as part of the Dispose which has a perf impact. + await writer.FlushAsync(); + } + + private static TextWriter CreateResponseWriter(Stream bodyStream) + { + // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize + const int DefaultBufferSize = 16 * 1024; + return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + } +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index 3ca29cd3bbbf..f57b63d62e80 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -54,38 +54,71 @@ public async ValueTask PrerenderComponentAsync( _ => throw new ArgumentException(Resources.FormatUnsupportedRenderMode(prerenderMode), nameof(prerenderMode)), }; - if (waitForQuiescence) - { - // Full quiescence, i.e., all tasks completed regardless of streaming SSR - await result.QuiescenceTask; - } - else if (_nonStreamingPendingTasks.Count > 0) - { - // Just wait for quiescence of the non-streaming subtrees - await Task.WhenAll(_nonStreamingPendingTasks); - } + await WaitForResultReady(waitForQuiescence, result); return result; } catch (NavigationException navigationException) { - if (httpContext.Response.HasStarted) - { - // If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to - // communicate the redirection to the browser. - // If we are doing streaming SSR, this should not generally happen because if you navigate during the initial - // synchronous render, the response would not yet have started, and if you do so during some later async - // phase, we would already have exited this method since streaming SSR means not awaiting quiescence. - throw new InvalidOperationException( - "A navigation command was attempted during prerendering after the server already started sending the response. " + - "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + - "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands."); - } - else - { - httpContext.Response.Redirect(navigationException.Location); - return PrerenderedComponentHtmlContent.Empty; - } + return await HandleNavigationException(httpContext, navigationException); + } + } + + internal async ValueTask RenderEndpointComponent( + HttpContext httpContext, + Type componentType, + ParameterView parameters, + bool waitForQuiescence) + { + await InitializeStandardComponentServicesAsync(httpContext); + + try + { + var component = BeginRenderingComponent(componentType, parameters); + var result = new PrerenderedComponentHtmlContent(Dispatcher, component, null, null); + + await WaitForResultReady(waitForQuiescence, result); + + return result; + } + catch (NavigationException navigationException) + { + return await HandleNavigationException(httpContext, navigationException); + } + } + + private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedComponentHtmlContent result) + { + if (waitForQuiescence) + { + // Full quiescence, i.e., all tasks completed regardless of streaming SSR + await result.QuiescenceTask; + } + else if (_nonStreamingPendingTasks.Count > 0) + { + // Just wait for quiescence of the non-streaming subtrees + await Task.WhenAll(_nonStreamingPendingTasks); + } + } + + private static ValueTask HandleNavigationException(HttpContext httpContext, NavigationException navigationException) + { + if (httpContext.Response.HasStarted) + { + // If we're not doing streaming SSR, this has no choice but to be a fatal error because there's no way to + // communicate the redirection to the browser. + // If we are doing streaming SSR, this should not generally happen because if you navigate during the initial + // synchronous render, the response would not yet have started, and if you do so during some later async + // phase, we would already have exited this method since streaming SSR means not awaiting quiescence. + throw new InvalidOperationException( + "A navigation command was attempted during prerendering after the server already started sending the response. " + + "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + + "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands."); + } + else + { + httpContext.Response.Redirect(navigationException.Location); + return new ValueTask(PrerenderedComponentHtmlContent.Empty); } } diff --git a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs index 2e8090f95523..a8918a526d19 100644 --- a/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs +++ b/src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs @@ -1,7 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; +using System.Text; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -30,11 +35,67 @@ public virtual Task ExecuteAsync(HttpContext httpContext, RazorComponentResult r response.StatusCode = result.StatusCode.Value; } - return RazorComponentEndpoint.RenderComponentToResponse( + return RenderComponentToResponse( httpContext, result.RenderMode, result.ComponentType, result.Parameters, result.PreventStreamingRendering); } + + internal static Task RenderComponentToResponse( + HttpContext httpContext, + RenderMode renderMode, + Type componentType, + IReadOnlyDictionary? componentParameters, + bool preventStreamingRendering) + { + var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService(); + return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () => + { + // We could pool these dictionary instances if we wanted, and possibly even the ParameterView + // backing buffers could come from a pool like they do during rendering. + var hostParameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(RazorComponentEndpointHost.RenderMode), renderMode }, + { nameof(RazorComponentEndpointHost.ComponentType), componentType }, + { nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters }, + }); + + await using var writer = CreateResponseWriter(httpContext.Response.Body); + + // Note that we always use Static rendering mode for the top-level output from a RazorComponentResult, + // because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host + // component takes care of switching into your desired render mode when it produces its own output. + var htmlContent = (EndpointHtmlRenderer.PrerenderedComponentHtmlContent)(await endpointHtmlRenderer.PrerenderComponentAsync( + httpContext, + typeof(RazorComponentEndpointHost), + RenderMode.Static, + hostParameters, + waitForQuiescence: preventStreamingRendering)); + + // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context) + // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent + // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the + // renderer sync context and cause a batch that would get missed. + htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above + + if (!htmlContent.QuiescenceTask.IsCompleted) + { + await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer); + } + + // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying + // response asynchronously. In the absence of this line, the buffer gets synchronously written to the + // response as part of the Dispose which has a perf impact. + await writer.FlushAsync(); + }); + } + + private static TextWriter CreateResponseWriter(Stream bodyStream) + { + // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize + const int DefaultBufferSize = 16 * 1024; + return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + } } diff --git a/src/Components/Endpoints/test/RazorComponentEndpointTest.cs b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs similarity index 94% rename from src/Components/Endpoints/test/RazorComponentEndpointTest.cs rename to src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs index 44bd2eea0545..9943dfbaadcb 100644 --- a/src/Components/Endpoints/test/RazorComponentEndpointTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultExecutorTest.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; -public class RazorComponentEndpointTest +public class RazorComponentResultExecutorTest { [Fact] public async Task CanRenderComponentStatically() @@ -28,7 +28,7 @@ public async Task CanRenderComponentStatically() httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(SimpleComponent), @@ -49,7 +49,7 @@ public async Task PerformsStreamingRendering() httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingAsyncLoadingComponent), @@ -82,7 +82,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAComponentRen httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(DoubleRenderingStreamingAsyncComponent), @@ -115,7 +115,7 @@ public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAnAncestorAls httpContext.Response.Body = responseBody; // Act/Assert 1: Emits the initial pre-quiescent output to the response - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentWithChild), @@ -145,7 +145,7 @@ public async Task WaitsForQuiescenceIfPreventStreamingRenderingIsTrue() httpContext.Response.Body = responseBody; // Act/Assert: Doesn't complete until loading finishes - var completionTask = RazorComponentEndpoint.RenderComponentToResponse( + var completionTask = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingAsyncLoadingComponent), @@ -171,7 +171,7 @@ public async Task SupportsLayouts() httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentWithLayout), null, false); @@ -186,7 +186,7 @@ public async Task OnNavigationBeforeResponseStarted_Redirects() var httpContext = GetTestHttpContext(); // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentThatRedirectsSynchronously), null, false); @@ -205,7 +205,7 @@ public async Task OnNavigationAfterResponseStarted_WithStreamingOff_Throws() // Act var ex = await Assert.ThrowsAsync( - () => RazorComponentEndpoint.RenderComponentToResponse( + () => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatRedirectsAsynchronously), null, preventStreamingRendering: true)); @@ -222,7 +222,7 @@ public async Task OnNavigationAfterResponseStarted_WithStreamingOn_EmitsCommand( httpContext.Response.Body = responseBody; // Act - await RazorComponentEndpoint.RenderComponentToResponse( + await RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatRedirectsAsynchronously), null, preventStreamingRendering: false); @@ -239,7 +239,7 @@ public async Task OnUnhandledExceptionBeforeResponseStarted_Throws() var httpContext = GetTestHttpContext(); // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(ComponentThatThrowsSynchronously), null, false)); @@ -254,7 +254,7 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOff_Thro var httpContext = GetTestHttpContext(); // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatThrowsAsynchronously), null, preventStreamingRendering: true)); @@ -277,7 +277,7 @@ public async Task OnUnhandledExceptionAfterResponseStarted_WithStreamingOn_Emits : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'"; // Act - var ex = await Assert.ThrowsAsync(() => RazorComponentEndpoint.RenderComponentToResponse( + var ex = await Assert.ThrowsAsync(() => RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(StreamingComponentThatThrowsAsynchronously), null, preventStreamingRendering: false)); @@ -381,7 +381,7 @@ private VaryStreamingScenariosContext PrepareVaryStreamingScenariosTests() { nameof(VaryStreamingScenarios.WithinNestedNonstreamingRegionTask), withinNestedNonstreamingRegionTask.Task }, }; - var quiescence = RazorComponentEndpoint.RenderComponentToResponse( + var quiescence = RazorComponentResultExecutor.RenderComponentToResponse( httpContext, RenderMode.Static, typeof(VaryStreamingScenarios), parameters, preventStreamingRendering: false); diff --git a/src/Components/Endpoints/test/RazorComponentResultTest.cs b/src/Components/Endpoints/test/RazorComponentResultTest.cs index 028275577c6f..78c29bed0d48 100644 --- a/src/Components/Endpoints/test/RazorComponentResultTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultTest.cs @@ -40,7 +40,7 @@ public async Task CanRenderComponentStatically() { // Arrange var result = new RazorComponentResult(); - var httpContext = RazorComponentEndpointTest.GetTestHttpContext(); + var httpContext = RazorComponentResultExecutorTest.GetTestHttpContext(); var responseBody = new MemoryStream(); httpContext.Response.Body = responseBody; @@ -62,7 +62,7 @@ public async Task ResponseIncludesStatusCodeAndContentTypeAndHtml() StatusCode = 123, ContentType = "application/test-content-type", }; - var httpContext = RazorComponentEndpointTest.GetTestHttpContext(); + var httpContext = RazorComponentResultExecutorTest.GetTestHttpContext(); var responseBody = new MemoryStream(); httpContext.Response.Body = responseBody; From af939926543916cebbdb315e8390982fdc47f6ab Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 14 Apr 2023 07:00:50 +0800 Subject: [PATCH 05/24] Make heartbeat test tolerant of odd wait times (#47688) --- .../Kestrel/Core/test/HeartbeatTests.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Servers/Kestrel/Core/test/HeartbeatTests.cs b/src/Servers/Kestrel/Core/test/HeartbeatTests.cs index 994c898d0d53..07f5416c3a01 100644 --- a/src/Servers/Kestrel/Core/test/HeartbeatTests.cs +++ b/src/Servers/Kestrel/Core/test/HeartbeatTests.cs @@ -70,24 +70,28 @@ public async void HeartbeatLoopRunsWithSpecifiedInterval() Logger.LogInformation($"Starting heartbeat dispose."); } - // Interval timing isn't exact. For example, interval of 300ms results in split of 312.67ms. - // Under load the server might take a long time to resume. Provide tolerance for late resume. Assert.Collection(splits, ts => AssertApproxEqual(intervalMs, ts.TotalMilliseconds), ts => AssertApproxEqual(intervalMs, ts.TotalMilliseconds), ts => AssertApproxEqual(intervalMs, ts.TotalMilliseconds), ts => AssertApproxEqual(intervalMs, ts.TotalMilliseconds)); - static void AssertApproxEqual(double expectedValue, double value) + static void AssertApproxEqual(double intervalMs, double actualMs) { - if (value < expectedValue) + // Interval timing isn't exact on a slow computer. For example, interval of 300ms results in split between 300ms and 450ms. + // Under load the server might take a long time to resume. Provide tolerance for late resume. + + // Round value to nearest 50. Avoids error when wait time is slightly less than expected value. + var roundedActualMs = Math.Round(actualMs / 50.0) * 50.0; + + if (roundedActualMs < intervalMs) { - Assert.Fail($"{value} is smaller than wait time of {expectedValue}."); + Assert.Fail($"{roundedActualMs} is smaller than wait time of {intervalMs}."); } // Be tolerant of a much larger value. Heartbeat might not immediately resume if the server is under load. - if (value > expectedValue * 4) + if (roundedActualMs > intervalMs * 4) { - Assert.Fail($"{value} is much larger than wait time of {expectedValue}."); + Assert.Fail($"{roundedActualMs} is much larger than wait time of {intervalMs}."); } } } From 865508ee9b8ff61231cbec1350763524acd2940b Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Thu, 13 Apr 2023 23:56:17 +0000 Subject: [PATCH 06/24] [main] Update dependencies from dotnet/runtime (#47693) [main] Update dependencies from dotnet/runtime - Suppress warnings in DataProtection Tracked with https://github.com/dotnet/aspnetcore/issues/47695 - Update docker image used to bulid RPM installers - add util-linux for useradd - also add shadow-utils - add awk - Add more dependencies. Remove scl enable - Add missing dependencies - Cleanup dependencies - More cleanup --- eng/Version.Details.xml | 248 +++++++++--------- eng/Versions.props | 124 ++++----- eng/docker/rhel.Dockerfile | 12 +- ...ore.DataProtection.WarningSuppressions.xml | 6 +- .../XmlEncryption/CertificateXmlEncryptor.cs | 2 + .../XmlEncryption/EncryptedXmlDecryptor.cs | 2 + src/Installers/Rpm/Directory.Build.targets | 2 +- 7 files changed, 205 insertions(+), 191 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 133b17bf3253..b6aa8914f693 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -41,258 +41,258 @@ https://github.com/dotnet/efcore 596dcd6d7b7a67082fca25e6a832d252547ffa51 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 https://github.com/dotnet/source-build-externals 33edde07d61cf7606d76ada765335fb81f1cbb71 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 https://github.com/dotnet/xdt @@ -302,9 +302,9 @@ - + https://github.com/dotnet/runtime - 5117e1f78d2f4924181a9ed6d1fd525f27a9fc91 + 42c6bcf2076a52d256afcab5080caddb1fbeea98 https://github.com/dotnet/arcade diff --git a/eng/Versions.props b/eng/Versions.props index e15289aa4688..905c30976993 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -63,69 +63,69 @@ --> - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23212.4 - 8.0.0-preview.4.23212.4 + 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23213.5 8.0.0-preview.4.23212.3 8.0.0-preview.4.23212.3 diff --git a/eng/docker/rhel.Dockerfile b/eng/docker/rhel.Dockerfile index ad17cff96da2..bd0ffa4bff70 100644 --- a/eng/docker/rhel.Dockerfile +++ b/eng/docker/rhel.Dockerfile @@ -1,5 +1,15 @@ # Dockerfile that creates a container suitable to build dotnet-cli -FROM mcr.microsoft.com/dotnet-buildtools/prereqs:centos-7-rpmpkg +FROM mcr.microsoft.com/dotnet-buildtools/prereqs:cbl-mariner-2.0-fpm + +RUN tdnf update -y && \ + tdnf install -y \ + tar \ + ca-certificates \ + icu \ + awk \ + # Provides useradd, needed below + shadow-utils \ + rpm-build # Setup User to match Host User, and give superuser permissions ARG USER diff --git a/src/DataProtection/DataProtection/src/Microsoft.AspNetCore.DataProtection.WarningSuppressions.xml b/src/DataProtection/DataProtection/src/Microsoft.AspNetCore.DataProtection.WarningSuppressions.xml index 2c4a3e9144dc..b045ef5a3db0 100644 --- a/src/DataProtection/DataProtection/src/Microsoft.AspNetCore.DataProtection.WarningSuppressions.xml +++ b/src/DataProtection/DataProtection/src/Microsoft.AspNetCore.DataProtection.WarningSuppressions.xml @@ -1,17 +1,17 @@  - + ILLink IL2026 member - M:Microsoft.AspNetCore.DataProtection.Internal.DataProtectionHostedService.StartAsync(System.Threading.CancellationToken) + M:Microsoft.AspNetCore.DataProtection.XmlEncryption.CertificateXmlEncryptor.EncryptElement(System.Xml.Linq.XElement) ILLink IL2026 member - M:Microsoft.AspNetCore.DataProtection.Internal.KeyManagementOptionsSetup.Configure(Microsoft.AspNetCore.DataProtection.KeyManagement.KeyManagementOptions) + M:Microsoft.AspNetCore.DataProtection.XmlEncryption.EncryptedXmlDecryptor.EncryptedXmlWithCertificateKeys.#ctor(Microsoft.AspNetCore.DataProtection.XmlEncryption.XmlKeyDecryptionOptions,System.Xml.XmlDocument) \ No newline at end of file diff --git a/src/DataProtection/DataProtection/src/XmlEncryption/CertificateXmlEncryptor.cs b/src/DataProtection/DataProtection/src/XmlEncryption/CertificateXmlEncryptor.cs index 5f6e9f7ce550..c318e717702a 100644 --- a/src/DataProtection/DataProtection/src/XmlEncryption/CertificateXmlEncryptor.cs +++ b/src/DataProtection/DataProtection/src/XmlEncryption/CertificateXmlEncryptor.cs @@ -88,7 +88,9 @@ private XElement EncryptElement(XElement plaintextElement) var elementToEncrypt = (XmlElement)xmlDocument.DocumentElement!.FirstChild!; // Perform the encryption and update the document in-place. +#pragma warning disable IL2026 // TODO: https://github.com/dotnet/aspnetcore/issues/47695 var encryptedXml = new EncryptedXml(xmlDocument); +#pragma warning restore IL2026 var encryptedData = _encryptor.PerformEncryption(encryptedXml, elementToEncrypt); EncryptedXml.ReplaceElement(elementToEncrypt, encryptedData, content: false); diff --git a/src/DataProtection/DataProtection/src/XmlEncryption/EncryptedXmlDecryptor.cs b/src/DataProtection/DataProtection/src/XmlEncryption/EncryptedXmlDecryptor.cs index f6d1c8b65e54..43c3304833de 100644 --- a/src/DataProtection/DataProtection/src/XmlEncryption/EncryptedXmlDecryptor.cs +++ b/src/DataProtection/DataProtection/src/XmlEncryption/EncryptedXmlDecryptor.cs @@ -84,7 +84,9 @@ private sealed class EncryptedXmlWithCertificateKeys : EncryptedXml [RequiresDynamicCode("XmlDsigXsltTransform uses XslCompiledTransform which requires dynamic code.")] public EncryptedXmlWithCertificateKeys(XmlKeyDecryptionOptions? options, XmlDocument document) +#pragma warning disable IL2026 // TODO: https://github.com/dotnet/aspnetcore/issues/47695 : base(document) +#pragma warning restore IL2026 { _options = options; } diff --git a/src/Installers/Rpm/Directory.Build.targets b/src/Installers/Rpm/Directory.Build.targets index 465848e3356e..07d01bb46ed6 100644 --- a/src/Installers/Rpm/Directory.Build.targets +++ b/src/Installers/Rpm/Directory.Build.targets @@ -85,7 +85,7 @@ - + Date: Fri, 14 Apr 2023 02:06:14 +0100 Subject: [PATCH 07/24] Oxford comma edits I added several missing commas. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a336c5c81e6..a51928276edf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ASP.NET Core [![MIT License](https://img.shields.io/github/license/dotnet/aspnetcore?color=%230b0&style=flat-square)](https://github.com/dotnet/aspnetcore/blob/main/LICENSE.txt) [![Help Wanted](https://img.shields.io/github/issues/dotnet/aspnetcore/help%20wanted?color=%232EA043&label=help%20wanted&style=flat-square)](https://github.com/dotnet/aspnetcore/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) [![Good First Issues](https://img.shields.io/github/issues/dotnet/aspnetcore/good%20first%20issue?color=%23512BD4&label=good%20first%20issue&style=flat-square)](https://github.com/dotnet/aspnetcore/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) [![Discord](https://img.shields.io/discord/732297728826277939?style=flat-square&label=Discord&logo=discord&logoColor=white&color=7289DA)](https://aka.ms/dotnet-discord) -ASP.NET Core is an open-source and cross-platform framework for building modern cloud-based internet-connected applications, such as web apps, IoT apps and mobile backends. ASP.NET Core apps run on [.NET](https://dot.net), a free, cross-platform and open-source application runtime. It was architected to provide an optimized development framework for apps that are deployed to the cloud or run on-premises. It consists of modular components with minimal overhead, so you retain flexibility while constructing your solutions. You can develop and run your ASP.NET Core apps cross-platform on Windows, Mac and Linux. [Learn more about ASP.NET Core](https://learn.microsoft.com/aspnet/core/). +ASP.NET Core is an open-source and cross-platform framework for building modern cloud-based internet-connected applications, such as web apps, IoT apps, and mobile backends. ASP.NET Core apps run on [.NET](https://dot.net), a free, cross-platform, and open-source application runtime. It was architected to provide an optimized development framework for apps that are deployed to the cloud or run on-premises. It consists of modular components with minimal overhead, so you retain flexibility while constructing your solutions. You can develop and run your ASP.NET Core apps cross-platform on Windows, Mac, and Linux. [Learn more about ASP.NET Core](https://learn.microsoft.com/aspnet/core/). ## Get started From f1bbdd4ce9caf4542ade32e237cd381cbedd327e Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Thu, 13 Apr 2023 18:33:53 -0700 Subject: [PATCH 08/24] Make TLS & QUIC Pay-for-Play (Redux) (#47454) * Make TLS & QUIC pay-for-play `CreateSlimBuilder` now calls `UseKestrelSlim` (name TBD) to create a Kestrel server that doesn't automatically support TLS or QUIC. If you invoke `ListenOptions.UseHttps`, TLS will "just work" but, if you want to enable https using `ASPNETCORE_URLS`, you'll need to invoke the new `WebHostBuilder.UseHttpsConfiguration` extension method. Quic is enabled using the existing `WebHostBuilder.UseQuic` extension method. Squash: Break direct dependency of AddressBinder on ListenOptionsHttpsExtensions.UseHttps Factor out the part of TransportManager that depends on https Introduce KestrelServerOptions.HasServerCertificateOrSelector for convenience Factor TlsConfigurationLoader out of KestrelConfigurationLoader Introduce but don't consume IHttpsConfigurationHelper Consume IHttpsConfigurationHelper - tests failing Fix most tests Fix KestrelServerTests Fix remaining tests Respect IHttpsConfigurationHelper in ApplyDefaultCertificate Introduce UseKestrelSlim Delete unused TryUseHttps Enable HttpsConfiguration when UseHttps is called Introduce UseHttpsConfiguration Drop incomplete test implementation of IHttpsConfigurationHelper Tidy up test diffs Fix AOT trimming by moving enable call out of ctor Fix some tests Simplify HttpsConfigurationHelper ctor for more convenient testing Improve error message Don't declare Enabler transient Fix tests other than KestrelConfigurationLoaderTests Correct HttpsConfigurationHelper Add IEnabler interface to break direct dependency Restore UseKestrel call in WebHost.ConfigureWebDefaults Stop registering an https address in ApiTemplateTest boolean -> bool HttpsConfigurationHelper -> HttpsConfigurationService HttpsConfigurationService.Enable -> Initialize ITlsConfigurationLoader.ApplyHttpsDefaults -> ApplyHttpsConfiguration ITlsConfigurationLoader.UseHttps -> UseHttpsWithSni IHttpsConfigurationService.UseHttps -> UseHttpsWithDefaults Inline ITlsConfigurationLoader in IHttpsConfigurationService Document IHttpsConfigurationService Document new public APIs in WebHostBuilderKestrelExtensions Clean up TODOs Improve error text recommending UseQuic Co-authored-by: James Newton-King Add assert message Clarify comment on assert Fix typo in doc comment Co-authored-by: Aditya Mandaleeka Fix typo in doc comment Co-authored-by: Aditya Mandaleeka Fix typo in doc comment Co-authored-by: Aditya Mandaleeka Don't use regions Correct casing Replace record with readonly struct Test AddressBinder exception Test an endpoint address from config Test certificate loading Bonus: use dynamic ports to improve reliability Test Quic with UseKestrelSlim Test the interaction of UseHttps and UseHttpsConfiguration Test different UseHttps overloads Add more detail to doc comment Set TestOverrideDefaultCertificate in the tests that expect it * Improve assert message Co-authored-by: James Newton-King * Adopt MemberNotNullAttribute * Assert that members are non-null to suppress CS8774 * UseHttpsConfiguration -> UseKestrelHttpsConfiguration * UseKestrelSlim -> UseKestrelCore * Drop convenience overloads of UseKestrelCore * Use more explicit error strings in HttpsConfigurationService.EnsureInitialized --------- Co-authored-by: James Newton-King --- src/DefaultBuilder/src/WebHost.cs | 29 +- src/ProjectTemplates/Shared/Project.cs | 9 +- .../test/Templates.Tests/ApiTemplateTest.cs | 5 +- src/Servers/Kestrel/Core/src/CoreStrings.resx | 14 +- .../Core/src/HttpsConfigurationService.cs | 260 ++++++++++++++++++ .../Core/src/HttpsConnectionAdapterOptions.cs | 5 + .../Core/src/IHttpsConfigurationService.cs | 100 +++++++ .../Core/src/Internal/AddressBinder.cs | 24 +- .../Infrastructure/TransportManager.cs | 82 +----- .../Core/src/Internal/KestrelServerImpl.cs | 12 +- .../Core/src/Internal/SniOptionsSelector.cs | 2 +- .../Core/src/KestrelConfigurationLoader.cs | 187 ++----------- src/Servers/Kestrel/Core/src/KestrelServer.cs | 45 +++ .../Kestrel/Core/src/KestrelServerOptions.cs | 26 +- .../Core/src/ListenOptionsHttpsExtensions.cs | 21 +- .../Middleware/HttpsConnectionMiddleware.cs | 2 +- .../Core/src/TlsConfigurationLoader.cs | 205 ++++++++++++++ .../Kestrel/Core/test/AddressBinderTests.cs | 22 +- .../Core/test/KestrelServerOptionsTests.cs | 1 + .../Kestrel/Core/test/KestrelServerTests.cs | 20 +- .../Kestrel/src/PublicAPI.Unshipped.txt | 2 + .../src/WebHostBuilderKestrelExtensions.cs | 58 +++- .../Kestrel/test/HttpsConfigurationTests.cs | 237 ++++++++++++++++ .../test/KestrelConfigurationLoaderTests.cs | 2 + .../test/TransportTestHelpers/TestServer.cs | 4 +- .../HttpsConnectionMiddlewareTests.cs | 13 +- .../InMemory.FunctionalTests/HttpsTests.cs | 8 + .../TestTransport/TestServer.cs | 3 + .../Http3/Http3TlsTests.cs | 93 +++++++ 29 files changed, 1160 insertions(+), 331 deletions(-) create mode 100644 src/Servers/Kestrel/Core/src/HttpsConfigurationService.cs create mode 100644 src/Servers/Kestrel/Core/src/IHttpsConfigurationService.cs create mode 100644 src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs create mode 100644 src/Servers/Kestrel/Kestrel/test/HttpsConfigurationTests.cs diff --git a/src/DefaultBuilder/src/WebHost.cs b/src/DefaultBuilder/src/WebHost.cs index 7e21713ae774..0a9906ea415a 100644 --- a/src/DefaultBuilder/src/WebHost.cs +++ b/src/DefaultBuilder/src/WebHost.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -223,23 +224,31 @@ internal static void ConfigureWebDefaults(IWebHostBuilder builder) } }); - ConfigureWebDefaultsCore(builder, services => - { - services.AddRouting(); - }); + ConfigureWebDefaultsWorker( + builder.UseKestrel(ConfigureKestrel), + services => + { + services.AddRouting(); + }); builder .UseIIS() .UseIISIntegration(); } - internal static void ConfigureWebDefaultsCore(IWebHostBuilder builder, Action? configureRouting = null) + internal static void ConfigureWebDefaultsCore(IWebHostBuilder builder) { - builder.UseKestrel((builderContext, options) => - { - options.Configure(builderContext.Configuration.GetSection("Kestrel"), reloadOnChange: true); - }) - .ConfigureServices((hostingContext, services) => + ConfigureWebDefaultsWorker(builder.UseKestrelCore().ConfigureKestrel(ConfigureKestrel), configureRouting: null); + } + + private static void ConfigureKestrel(WebHostBuilderContext builderContext, KestrelServerOptions options) + { + options.Configure(builderContext.Configuration.GetSection("Kestrel"), reloadOnChange: true); + } + + private static void ConfigureWebDefaultsWorker(IWebHostBuilder builder, Action? configureRouting) + { + builder.ConfigureServices((hostingContext, services) => { // Fallback services.PostConfigure(options => diff --git a/src/ProjectTemplates/Shared/Project.cs b/src/ProjectTemplates/Shared/Project.cs index 58df70e03eba..6a910c504ac5 100644 --- a/src/ProjectTemplates/Shared/Project.cs +++ b/src/ProjectTemplates/Shared/Project.cs @@ -24,6 +24,7 @@ namespace Templates.Test.Helpers; [DebuggerDisplay("{ToString(),nq}")] public class Project : IDisposable { + private const string _urlsNoHttps = "http://127.0.0.1:0"; private const string _urls = "http://127.0.0.1:0;https://127.0.0.1:0"; public static string ArtifactsLogDir @@ -181,11 +182,11 @@ internal async Task RunDotNetBuildAsync(IDictionary packageOptio Assert.True(0 == result.ExitCode, ErrorMessages.GetFailedProcessMessage("build", this, result)); } - internal AspNetProcess StartBuiltProjectAsync(bool hasListeningUri = true, ILogger logger = null) + internal AspNetProcess StartBuiltProjectAsync(bool hasListeningUri = true, ILogger logger = null, bool noHttps = false) { var environment = new Dictionary { - ["ASPNETCORE_URLS"] = _urls, + ["ASPNETCORE_URLS"] = noHttps ? _urlsNoHttps : _urls, ["ASPNETCORE_ENVIRONMENT"] = "Development", ["ASPNETCORE_Logging__Console__LogLevel__Default"] = "Debug", ["ASPNETCORE_Logging__Console__LogLevel__System"] = "Debug", @@ -197,11 +198,11 @@ internal AspNetProcess StartBuiltProjectAsync(bool hasListeningUri = true, ILogg return new AspNetProcess(DevCert, Output, TemplateOutputDir, projectDll, environment, published: false, hasListeningUri: hasListeningUri, logger: logger); } - internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true, bool usePublishedAppHost = false) + internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true, bool usePublishedAppHost = false, bool noHttps = false) { var environment = new Dictionary { - ["ASPNETCORE_URLS"] = _urls, + ["ASPNETCORE_URLS"] = noHttps ? _urlsNoHttps : _urls, ["ASPNETCORE_Logging__Console__LogLevel__Default"] = "Debug", ["ASPNETCORE_Logging__Console__LogLevel__System"] = "Debug", ["ASPNETCORE_Logging__Console__LogLevel__Microsoft"] = "Debug", diff --git a/src/ProjectTemplates/test/Templates.Tests/ApiTemplateTest.cs b/src/ProjectTemplates/test/Templates.Tests/ApiTemplateTest.cs index 62bf4a7f3ad3..388e51f3a9ee 100644 --- a/src/ProjectTemplates/test/Templates.Tests/ApiTemplateTest.cs +++ b/src/ProjectTemplates/test/Templates.Tests/ApiTemplateTest.cs @@ -82,7 +82,8 @@ private async Task ApiTemplateCore(string languageOverride, string[] args = null await project.RunDotNetBuildAsync(); - using (var aspNetProcess = project.StartBuiltProjectAsync()) + // The minimal/slim/core scenario doesn't include TLS support, so tell `project` not to register an https address + using (var aspNetProcess = project.StartBuiltProjectAsync(noHttps: true)) { Assert.False( aspNetProcess.Process.HasExited, @@ -91,7 +92,7 @@ private async Task ApiTemplateCore(string languageOverride, string[] args = null await AssertEndpoints(aspNetProcess); } - using (var aspNetProcess = project.StartPublishedProjectAsync()) + using (var aspNetProcess = project.StartPublishedProjectAsync(noHttps: true)) { Assert.False( aspNetProcess.Process.HasExited, diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index 1d7f3f60bd85..2c7c3e8ef18d 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -722,4 +722,16 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l Failed to bind to http://[::]:{port} (IPv6Any). - \ No newline at end of file + + Call UseKestrelHttpsConfiguration() on IWebHostBuilder to enable loading HTTPS settings from configuration. + + + Call UseKestrelHttpsConfiguration() on IWebHostBuilder to enable loading the default server certificate from configuration. + + + Call UseKestrelHttpsConfiguration() on IWebHostBuilder to enable transport layer security for HTTP/3. + + + Call UseKestrelHttpsConfiguration() on IWebHostBuilder to automatically enable HTTPS when an https:// address is used. + + diff --git a/src/Servers/Kestrel/Core/src/HttpsConfigurationService.cs b/src/Servers/Kestrel/Core/src/HttpsConfigurationService.cs new file mode 100644 index 000000000000..8e6185d4b642 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/HttpsConfigurationService.cs @@ -0,0 +1,260 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Net; +using System.Net.Security; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core; + +/// +internal sealed class HttpsConfigurationService : IHttpsConfigurationService +{ + private readonly IInitializer? _initializer; + private bool _isInitialized; + + private TlsConfigurationLoader? _tlsConfigurationLoader; + private Action? _populateMultiplexedTransportFeatures; + private Func? _useHttpsWithDefaults; + + /// + /// Create an uninitialized . + /// To initialize it later, call . + /// + public HttpsConfigurationService() + { + } + + /// + /// Create an initialized . + /// + /// + /// In practice, won't be called until it's needed. + /// + public HttpsConfigurationService(IInitializer initializer) + { + _initializer = initializer; + } + + /// + // If there's an initializer, it *can* be initialized, even though it might not be yet. + // Use explicit interface implentation so we don't accidentally call it within this class. + bool IHttpsConfigurationService.IsInitialized => _isInitialized || _initializer is not null; + + /// + public void Initialize( + IHostEnvironment hostEnvironment, + ILogger serverLogger, + ILogger httpsLogger) + { + if (_isInitialized) + { + return; + } + + _isInitialized = true; + + _tlsConfigurationLoader = new TlsConfigurationLoader(hostEnvironment, serverLogger, httpsLogger); + _populateMultiplexedTransportFeatures = PopulateMultiplexedTransportFeaturesWorker; + _useHttpsWithDefaults = UseHttpsWithDefaultsWorker; + } + + /// + public void ApplyHttpsConfiguration( + HttpsConnectionAdapterOptions httpsOptions, + EndpointConfig endpoint, + KestrelServerOptions serverOptions, + CertificateConfig? defaultCertificateConfig, + ConfigurationReader configurationReader) + { + EnsureInitialized(CoreStrings.NeedHttpsConfigurationToApplyHttpsConfiguration); + _tlsConfigurationLoader.ApplyHttpsConfiguration(httpsOptions, endpoint, serverOptions, defaultCertificateConfig, configurationReader); + } + + /// + public ListenOptions UseHttpsWithSni(ListenOptions listenOptions, HttpsConnectionAdapterOptions httpsOptions, EndpointConfig endpoint) + { + // This doesn't get a distinct string since it won't actually throw - it's always called after ApplyHttpsConfiguration + EnsureInitialized(CoreStrings.NeedHttpsConfigurationToApplyHttpsConfiguration); + return _tlsConfigurationLoader.UseHttpsWithSni(listenOptions, httpsOptions, endpoint); + } + + /// + public CertificateAndConfig? LoadDefaultCertificate(ConfigurationReader configurationReader) + { + EnsureInitialized(CoreStrings.NeedHttpsConfigurationToLoadDefaultCertificate); + return _tlsConfigurationLoader.LoadDefaultCertificate(configurationReader); + } + + /// + public void PopulateMultiplexedTransportFeatures(FeatureCollection features, ListenOptions listenOptions) + { + EnsureInitialized(CoreStrings.NeedHttpsConfigurationToUseHttp3); + _populateMultiplexedTransportFeatures.Invoke(features, listenOptions); + } + + /// + public ListenOptions UseHttpsWithDefaults(ListenOptions listenOptions) + { + EnsureInitialized(CoreStrings.NeedHttpsConfigurationToBindHttpsAddresses); + return _useHttpsWithDefaults.Invoke(listenOptions); + } + + /// + /// If this instance has not been initialized, initialize it if possible and throw otherwise. + /// + /// If initialization is not possible. + [MemberNotNull(nameof(_useHttpsWithDefaults), nameof(_tlsConfigurationLoader), nameof(_populateMultiplexedTransportFeatures))] + private void EnsureInitialized(string uninitializedError) + { + if (!_isInitialized) + { + if (_initializer is not null) + { + _initializer.Initialize(this); + } + else + { + throw new InvalidOperationException(uninitializedError); + } + } + + Debug.Assert(_useHttpsWithDefaults is not null); + Debug.Assert(_tlsConfigurationLoader is not null); + Debug.Assert(_populateMultiplexedTransportFeatures is not null); + } + + /// + /// The initialized implementation of . + /// + internal static void PopulateMultiplexedTransportFeaturesWorker(FeatureCollection features, ListenOptions listenOptions) + { + // HttpsOptions or HttpsCallbackOptions should always be set in production, but it's not set for InMemory tests. + // The QUIC transport will check if TlsConnectionCallbackOptions is missing. + if (listenOptions.HttpsOptions != null) + { + var sslServerAuthenticationOptions = HttpsConnectionMiddleware.CreateHttp3Options(listenOptions.HttpsOptions); + features.Set(new TlsConnectionCallbackOptions + { + ApplicationProtocols = sslServerAuthenticationOptions.ApplicationProtocols ?? new List { SslApplicationProtocol.Http3 }, + OnConnection = (context, cancellationToken) => ValueTask.FromResult(sslServerAuthenticationOptions), + OnConnectionState = null, + }); + } + else if (listenOptions.HttpsCallbackOptions != null) + { + features.Set(new TlsConnectionCallbackOptions + { + ApplicationProtocols = new List { SslApplicationProtocol.Http3 }, + OnConnection = (context, cancellationToken) => + { + return listenOptions.HttpsCallbackOptions.OnConnection(new TlsHandshakeCallbackContext + { + ClientHelloInfo = context.ClientHelloInfo, + CancellationToken = cancellationToken, + State = context.State, + Connection = new ConnectionContextAdapter(context.Connection), + }); + }, + OnConnectionState = listenOptions.HttpsCallbackOptions.OnConnectionState, + }); + } + } + + /// + /// The initialized implementation of . + /// + internal static ListenOptions UseHttpsWithDefaultsWorker(ListenOptions listenOptions) + { + return listenOptions.UseHttps(); + } + + /// + /// TlsHandshakeCallbackContext.Connection is ConnectionContext but QUIC connection only implements BaseConnectionContext. + /// + private sealed class ConnectionContextAdapter : ConnectionContext + { + private readonly BaseConnectionContext _inner; + + public ConnectionContextAdapter(BaseConnectionContext inner) => _inner = inner; + + public override IDuplexPipe Transport + { + get => throw new NotSupportedException("Not supported by HTTP/3 connections."); + set => throw new NotSupportedException("Not supported by HTTP/3 connections."); + } + public override string ConnectionId + { + get => _inner.ConnectionId; + set => _inner.ConnectionId = value; + } + public override IFeatureCollection Features => _inner.Features; + public override IDictionary Items + { + get => _inner.Items; + set => _inner.Items = value; + } + public override EndPoint? LocalEndPoint + { + get => _inner.LocalEndPoint; + set => _inner.LocalEndPoint = value; + } + public override EndPoint? RemoteEndPoint + { + get => _inner.RemoteEndPoint; + set => _inner.RemoteEndPoint = value; + } + public override CancellationToken ConnectionClosed + { + get => _inner.ConnectionClosed; + set => _inner.ConnectionClosed = value; + } + public override ValueTask DisposeAsync() => _inner.DisposeAsync(); + } + + /// + /// Register an instance of this type to initialize registered instances of . + /// + internal interface IInitializer + { + /// + /// Invokes , passing appropriate arguments. + /// + void Initialize(IHttpsConfigurationService httpsConfigurationService); + } + + /// + internal sealed class Initializer : IInitializer + { + private readonly IHostEnvironment _hostEnvironment; + private readonly ILogger _serverLogger; + private readonly ILogger _httpsLogger; + + public Initializer( + IHostEnvironment hostEnvironment, + ILogger serverLogger, + ILogger httpsLogger) + { + _hostEnvironment = hostEnvironment; + _serverLogger = serverLogger; + _httpsLogger = httpsLogger; + } + + /// + public void Initialize(IHttpsConfigurationService httpsConfigurationService) + { + httpsConfigurationService.Initialize(_hostEnvironment, _serverLogger, _httpsLogger); + } + } +} + diff --git a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs index 8435bfa998a6..48b6629d0762 100644 --- a/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs +++ b/src/Servers/Kestrel/Core/src/HttpsConnectionAdapterOptions.cs @@ -55,6 +55,11 @@ public HttpsConnectionAdapterOptions() /// public Func? ServerCertificateSelector { get; set; } + /// + /// Convenient shorthand for a common check. + /// + internal bool HasServerCertificateOrSelector => ServerCertificate is not null || ServerCertificateSelector is not null; + /// /// Specifies the client certificate requirements for a HTTPS connection. Defaults to . /// diff --git a/src/Servers/Kestrel/Core/src/IHttpsConfigurationService.cs b/src/Servers/Kestrel/Core/src/IHttpsConfigurationService.cs new file mode 100644 index 000000000000..7e5a955719fd --- /dev/null +++ b/src/Servers/Kestrel/Core/src/IHttpsConfigurationService.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core; + +/// +/// An abstraction over various things that would prevent us from trimming TLS support in `CreateSlimBuilder` +/// scenarios. In normal usage, it will *always* be registered by only be if the +/// consumer explicitly opts into having HTTPS/TLS support. +/// +internal interface IHttpsConfigurationService +{ + /// + /// If this property returns false, then methods other than will throw. + /// The most obvious way to make this true is to call , but some implementations + /// may offer alternative mechanisms. + /// + bool IsInitialized { get; } + + /// + /// Replaces the implementations off all other methods with functioning (as opposed to throwing) versions. + /// + void Initialize( + IHostEnvironment hostEnvironment, + ILogger serverLogger, + ILogger httpsLogger); + + /// + /// Applies various configuration settings to and . + /// + /// + /// For use during configuration loading (esp in ). + /// + void ApplyHttpsConfiguration( + HttpsConnectionAdapterOptions httpsOptions, + EndpointConfig endpoint, + KestrelServerOptions serverOptions, + CertificateConfig? defaultCertificateConfig, + ConfigurationReader configurationReader); + + /// + /// Calls an appropriate overload of + /// on , with or without SNI, according to how is configured. + /// + /// Updated for convenient chaining. + /// + /// For use during configuration loading (esp in ). + /// + ListenOptions UseHttpsWithSni(ListenOptions listenOptions, HttpsConnectionAdapterOptions httpsOptions, EndpointConfig endpoint); + + /// + /// Retrieves the default or, failing that, developer certificate from . + /// + /// + /// For use during configuration loading (esp in ). + /// + CertificateAndConfig? LoadDefaultCertificate(ConfigurationReader configurationReader); + + /// + /// Updates with multiplexed transport (i.e. HTTP/3) features based on + /// the configuration of . + /// + /// + /// For use during endpoint binding (esp in ). + /// + void PopulateMultiplexedTransportFeatures(FeatureCollection features, ListenOptions listenOptions); + + /// + /// Calls + /// on . + /// + /// Updated for convenient chaining. + /// + /// For use during address binding (esp in ). + /// + ListenOptions UseHttpsWithDefaults(ListenOptions listenOptions); +} + +/// +/// A - pair. +/// +internal readonly struct CertificateAndConfig +{ + public readonly X509Certificate2 Certificate; + public readonly CertificateConfig CertificateConfig; + + public CertificateAndConfig(X509Certificate2 certificate, CertificateConfig certificateConfig) + { + Certificate = certificate; + CertificateConfig = certificateConfig; + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs b/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs index a2e872da3080..a9bd269acdd4 100644 --- a/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs +++ b/src/Servers/Kestrel/Core/src/Internal/AddressBinder.cs @@ -6,7 +6,6 @@ using System.Net; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; @@ -17,12 +16,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal; internal sealed class AddressBinder { // note this doesn't copy the ListenOptions[], only call this with an array that isn't mutated elsewhere - public static async Task BindAsync(ListenOptions[] listenOptions, AddressBindContext context, CancellationToken cancellationToken) + public static async Task BindAsync(ListenOptions[] listenOptions, AddressBindContext context, Func useHttps, CancellationToken cancellationToken) { var strategy = CreateStrategy( listenOptions, context.Addresses.ToArray(), - context.ServerAddressesFeature.PreferHostingUrls); + context.ServerAddressesFeature.PreferHostingUrls, + useHttps); // reset options. The actual used options and addresses will be populated // by the address binding feature @@ -32,7 +32,7 @@ public static async Task BindAsync(ListenOptions[] listenOptions, AddressBindCon await strategy.BindAsync(context, cancellationToken).ConfigureAwait(false); } - private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] addresses, bool preferAddresses) + private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] addresses, bool preferAddresses, Func useHttps) { var hasListenOptions = listenOptions.Length > 0; var hasAddresses = addresses.Length > 0; @@ -41,10 +41,10 @@ private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] { if (hasListenOptions) { - return new OverrideWithAddressesStrategy(addresses); + return new OverrideWithAddressesStrategy(addresses, useHttps); } - return new AddressesStrategy(addresses); + return new AddressesStrategy(addresses, useHttps); } else if (hasListenOptions) { @@ -58,7 +58,7 @@ private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] else if (hasAddresses) { // If no endpoints are configured directly using KestrelServerOptions, use those configured via the IServerAddressesFeature. - return new AddressesStrategy(addresses); + return new AddressesStrategy(addresses, useHttps); } else { @@ -162,8 +162,8 @@ public async Task BindAsync(AddressBindContext context, CancellationToken cancel private sealed class OverrideWithAddressesStrategy : AddressesStrategy { - public OverrideWithAddressesStrategy(IReadOnlyCollection addresses) - : base(addresses) + public OverrideWithAddressesStrategy(IReadOnlyCollection addresses, Func useHttps) + : base(addresses, useHttps) { } @@ -216,10 +216,12 @@ public virtual async Task BindAsync(AddressBindContext context, CancellationToke private class AddressesStrategy : IStrategy { protected readonly IReadOnlyCollection _addresses; + private readonly Func _useHttps; - public AddressesStrategy(IReadOnlyCollection addresses) + public AddressesStrategy(IReadOnlyCollection addresses, Func useHttps) { _addresses = addresses; + _useHttps = useHttps; } public virtual async Task BindAsync(AddressBindContext context, CancellationToken cancellationToken) @@ -231,7 +233,7 @@ public virtual async Task BindAsync(AddressBindContext context, CancellationToke if (https && !options.IsTls) { - options.UseHttps(); + _useHttps(options); } await options.BindAsync(context, cancellationToken).ConfigureAwait(false); diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/TransportManager.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/TransportManager.cs index 00505ae9e354..c5651c50d2fa 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/TransportManager.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/TransportManager.cs @@ -3,13 +3,9 @@ #nullable enable -using System.IO.Pipelines; using System.Net; -using System.Net.Security; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Server.Kestrel.Https; -using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; @@ -19,15 +15,18 @@ internal sealed class TransportManager private readonly List _transportFactories; private readonly List _multiplexedTransportFactories; + private readonly IHttpsConfigurationService _httpsConfigurationService; private readonly ServiceContext _serviceContext; public TransportManager( List transportFactories, List multiplexedTransportFactories, + IHttpsConfigurationService httpsConfigurationService, ServiceContext serviceContext) { _transportFactories = transportFactories; _multiplexedTransportFactories = multiplexedTransportFactories; + _httpsConfigurationService = httpsConfigurationService; _serviceContext = serviceContext; } @@ -72,36 +71,8 @@ public async Task BindAsync(EndPoint endPoint, MultiplexedConnectionDe var features = new FeatureCollection(); - // HttpsOptions or HttpsCallbackOptions should always be set in production, but it's not set for InMemory tests. - // The QUIC transport will check if TlsConnectionCallbackOptions is missing. - if (listenOptions.HttpsOptions != null) - { - var sslServerAuthenticationOptions = HttpsConnectionMiddleware.CreateHttp3Options(listenOptions.HttpsOptions); - features.Set(new TlsConnectionCallbackOptions - { - ApplicationProtocols = sslServerAuthenticationOptions.ApplicationProtocols ?? new List { SslApplicationProtocol.Http3 }, - OnConnection = (context, cancellationToken) => ValueTask.FromResult(sslServerAuthenticationOptions), - OnConnectionState = null, - }); - } - else if (listenOptions.HttpsCallbackOptions != null) - { - features.Set(new TlsConnectionCallbackOptions - { - ApplicationProtocols = new List { SslApplicationProtocol.Http3 }, - OnConnection = (context, cancellationToken) => - { - return listenOptions.HttpsCallbackOptions.OnConnection(new TlsHandshakeCallbackContext - { - ClientHelloInfo = context.ClientHelloInfo, - CancellationToken = cancellationToken, - State = context.State, - Connection = new ConnectionContextAdapter(context.Connection), - }); - }, - OnConnectionState = listenOptions.HttpsCallbackOptions.OnConnectionState, - }); - } + // Will throw an appropriate error if it's not enabled + _httpsConfigurationService.PopulateMultiplexedTransportFeatures(features, listenOptions); foreach (var multiplexedTransportFactory in _multiplexedTransportFactories) { @@ -124,49 +95,6 @@ private static bool CanBindFactory(EndPoint endPoint, IConnectionListenerFactory return selector?.CanBind(endPoint) ?? true; } - /// - /// TlsHandshakeCallbackContext.Connection is ConnectionContext but QUIC connection only implements BaseConnectionContext. - /// - private sealed class ConnectionContextAdapter : ConnectionContext - { - private readonly BaseConnectionContext _inner; - - public ConnectionContextAdapter(BaseConnectionContext inner) => _inner = inner; - - public override IDuplexPipe Transport - { - get => throw new NotSupportedException("Not supported by HTTP/3 connections."); - set => throw new NotSupportedException("Not supported by HTTP/3 connections."); - } - public override string ConnectionId - { - get => _inner.ConnectionId; - set => _inner.ConnectionId = value; - } - public override IFeatureCollection Features => _inner.Features; - public override IDictionary Items - { - get => _inner.Items; - set => _inner.Items = value; - } - public override EndPoint? LocalEndPoint - { - get => _inner.LocalEndPoint; - set => _inner.LocalEndPoint = value; - } - public override EndPoint? RemoteEndPoint - { - get => _inner.RemoteEndPoint; - set => _inner.RemoteEndPoint = value; - } - public override CancellationToken ConnectionClosed - { - get => _inner.ConnectionClosed; - set => _inner.ConnectionClosed = value; - } - public override ValueTask DisposeAsync() => _inner.DisposeAsync(); - } - private void StartAcceptLoop(IConnectionListener connectionListener, Func connectionDelegate, EndpointConfig? endpointConfig) where T : BaseConnectionContext { var transportConnectionManager = new TransportConnectionManager(_serviceContext.ConnectionManager); diff --git a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs index 9119b06f1586..fa068401a1e4 100644 --- a/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs +++ b/src/Servers/Kestrel/Core/src/Internal/KestrelServerImpl.cs @@ -23,6 +23,7 @@ internal sealed class KestrelServerImpl : IServer private readonly TransportManager _transportManager; private readonly List _transportFactories; private readonly List _multiplexedTransportFactories; + private readonly IHttpsConfigurationService _httpsConfigurationService; private readonly SemaphoreSlim _bindSemaphore = new SemaphoreSlim(initialCount: 1); private bool _hasStarted; @@ -36,9 +37,10 @@ public KestrelServerImpl( IOptions options, IEnumerable transportFactories, IEnumerable multiplexedFactories, + IHttpsConfigurationService httpsConfigurationService, ILoggerFactory loggerFactory, KestrelMetrics metrics) - : this(transportFactories, multiplexedFactories, CreateServiceContext(options, loggerFactory, diagnosticSource: null, metrics)) + : this(transportFactories, multiplexedFactories, httpsConfigurationService, CreateServiceContext(options, loggerFactory, diagnosticSource: null, metrics)) { } @@ -47,12 +49,14 @@ public KestrelServerImpl( internal KestrelServerImpl( IEnumerable transportFactories, IEnumerable multiplexedFactories, + IHttpsConfigurationService httpsConfigurationService, ServiceContext serviceContext) { ArgumentNullException.ThrowIfNull(transportFactories); _transportFactories = transportFactories.Reverse().ToList(); _multiplexedTransportFactories = multiplexedFactories.Reverse().ToList(); + _httpsConfigurationService = httpsConfigurationService; if (_transportFactories.Count == 0 && _multiplexedTransportFactories.Count == 0) { @@ -65,7 +69,7 @@ internal KestrelServerImpl( _serverAddresses = new ServerAddressesFeature(); Features.Set(_serverAddresses); - _transportManager = new TransportManager(_transportFactories, _multiplexedTransportFactories, ServiceContext); + _transportManager = new TransportManager(_transportFactories, _multiplexedTransportFactories, _httpsConfigurationService, ServiceContext); } private static ServiceContext CreateServiceContext(IOptions options, ILoggerFactory loggerFactory, DiagnosticSource? diagnosticSource, KestrelMetrics metrics) @@ -168,7 +172,7 @@ async Task OnBind(ListenOptions options, CancellationToken onBindCancellationTok // Quic isn't registered if it's not supported, throw if we can't fall back to 1 or 2 if (hasHttp3 && _multiplexedTransportFactories.Count == 0 && !(hasHttp1 || hasHttp2)) { - throw new InvalidOperationException("This platform doesn't support QUIC or HTTP/3."); + throw new InvalidOperationException("Unable to bind an HTTP/3 endpoint. This could be because QUIC has not been configured using UseQuic, or the platform doesn't support QUIC or HTTP/3."); } // Disable adding alt-svc header if endpoint has configured not to or there is no @@ -302,7 +306,7 @@ private async Task BindAsync(CancellationToken cancellationToken) Options.ConfigurationLoader?.Load(); - await AddressBinder.BindAsync(Options.GetListenOptions(), AddressBindContext!, cancellationToken).ConfigureAwait(false); + await AddressBinder.BindAsync(Options.GetListenOptions(), AddressBindContext!, _httpsConfigurationService.UseHttpsWithDefaults, cancellationToken).ConfigureAwait(false); _configChangedRegistration = reloadToken?.RegisterChangeCallback(TriggerRebind, this); } finally diff --git a/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs index 650d3112142e..c1606c0bb743 100644 --- a/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs +++ b/src/Servers/Kestrel/Core/src/Internal/SniOptionsSelector.cs @@ -54,7 +54,7 @@ public SniOptionsSelector( if (sslOptions.ServerCertificate is null) { - if (fallbackHttpsOptions.ServerCertificate is null && _fallbackServerCertificateSelector is null) + if (!fallbackHttpsOptions.HasServerCertificateOrSelector) { throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); } diff --git a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs index 6b519fd56c5b..fdc16bd7cc45 100644 --- a/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs +++ b/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs @@ -1,21 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using Microsoft.AspNetCore.Certificates.Generation; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; -using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates; using Microsoft.AspNetCore.Server.Kestrel.Https; -using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.Kestrel; @@ -24,26 +16,24 @@ namespace Microsoft.AspNetCore.Server.Kestrel; /// public class KestrelConfigurationLoader { + private readonly IHttpsConfigurationService _httpsConfigurationService; + private bool _loaded; internal KestrelConfigurationLoader( KestrelServerOptions options, IConfiguration configuration, - IHostEnvironment hostEnvironment, - bool reloadOnChange, - ILogger logger, - ILogger httpsLogger) + IHttpsConfigurationService httpsConfigurationService, + bool reloadOnChange) { - Options = options ?? throw new ArgumentNullException(nameof(options)); - Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - HostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment)); - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - HttpsLogger = httpsLogger ?? throw new ArgumentNullException(nameof(logger)); + Options = options; + Configuration = configuration; ReloadOnChange = reloadOnChange; ConfigurationReader = new ConfigurationReader(configuration); - CertificateConfigLoader = new CertificateConfigLoader(hostEnvironment, logger); + + _httpsConfigurationService = httpsConfigurationService; } /// @@ -62,14 +52,8 @@ internal KestrelConfigurationLoader( /// internal bool ReloadOnChange { get; } - private IHostEnvironment HostEnvironment { get; } - private ILogger Logger { get; } - private ILogger HttpsLogger { get; } - private ConfigurationReader ConfigurationReader { get; set; } - private ICertificateConfigLoader CertificateConfigLoader { get; } - private IDictionary> EndpointConfigurations { get; } = new Dictionary>(0, StringComparer.OrdinalIgnoreCase); @@ -278,7 +262,11 @@ public void Load() ConfigurationReader = new ConfigurationReader(Configuration); - LoadDefaultCert(); + if (_httpsConfigurationService.IsInitialized && _httpsConfigurationService.LoadDefaultCertificate(ConfigurationReader) is CertificateAndConfig certPair) + { + DefaultCertificate = certPair.Certificate; + DefaultCertificateConfig = certPair.CertificateConfig; + } foreach (var endpoint in ConfigurationReader.Endpoints) { @@ -307,42 +295,8 @@ public void Load() if (https) { - // Defaults - Options.ApplyHttpsDefaults(httpsOptions); - - if (endpoint.SslProtocols.HasValue) - { - httpsOptions.SslProtocols = endpoint.SslProtocols.Value; - } - else - { - // Ensure endpoint is reloaded if it used the default protocol and the SslProtocols changed. - endpoint.SslProtocols = ConfigurationReader.EndpointDefaults.SslProtocols; - } - - if (endpoint.ClientCertificateMode.HasValue) - { - httpsOptions.ClientCertificateMode = endpoint.ClientCertificateMode.Value; - } - else - { - // Ensure endpoint is reloaded if it used the default mode and the ClientCertificateMode changed. - endpoint.ClientCertificateMode = ConfigurationReader.EndpointDefaults.ClientCertificateMode; - } - - // A cert specified directly on the endpoint overrides any defaults. - var (serverCert, fullChain) = CertificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name); - httpsOptions.ServerCertificate = serverCert ?? httpsOptions.ServerCertificate; - httpsOptions.ServerCertificateChain = fullChain ?? httpsOptions.ServerCertificateChain; - - if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) - { - // Fallback - Options.ApplyDefaultCertificate(httpsOptions); - - // Ensure endpoint is reloaded if it used the default certificate and the certificate changed. - endpoint.Certificate = DefaultCertificateConfig; - } + // Throws an appropriate exception if https configuration isn't enabled + _httpsConfigurationService.ApplyHttpsConfiguration(httpsOptions, endpoint, Options, DefaultCertificateConfig, ConfigurationReader); } // Now that defaults have been loaded, we can compare to the currently bound endpoints to see if the config changed. @@ -370,30 +324,12 @@ public void Load() } // EndpointDefaults or configureEndpoint may have added an https adapter. - if (https && !listenOptions.IsTls) + if (https) { - if (endpoint.Sni.Count == 0) - { - if (httpsOptions.ServerCertificate == null && httpsOptions.ServerCertificateSelector == null) - { - throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); - } - - listenOptions.UseHttps(httpsOptions); - } - else - { - var sniOptionsSelector = new SniOptionsSelector(endpoint.Name, endpoint.Sni, CertificateConfigLoader, - httpsOptions, listenOptions.Protocols, HttpsLogger); - var tlsCallbackOptions = new TlsHandshakeCallbackOptions() - { - OnConnection = SniOptionsSelector.OptionsCallback, - HandshakeTimeout = httpsOptions.HandshakeTimeout, - OnConnectionState = sniOptionsSelector, - }; - - listenOptions.UseHttps(tlsCallbackOptions); - } + // This would throw if it were invoked without https configuration having been enabled, + // but that won't happen because ApplyHttpsConfiguration would throw above under those + // circumstances. + _httpsConfigurationService.UseHttpsWithSni(listenOptions, httpsOptions, endpoint); } listenOptions.EndpointConfig = endpoint; @@ -411,87 +347,4 @@ public void Load() return (endpointsToStop, endpointsToStart); } - - private void LoadDefaultCert() - { - if (ConfigurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig)) - { - var (defaultCert, _ /* cert chain */) = CertificateConfigLoader.LoadCertificate(defaultCertConfig, "Default"); - if (defaultCert != null) - { - DefaultCertificateConfig = defaultCertConfig; - DefaultCertificate = defaultCert; - } - } - else - { - var (certificate, certificateConfig) = FindDeveloperCertificateFile(); - if (certificate != null) - { - Logger.LocatedDevelopmentCertificate(certificate); - DefaultCertificateConfig = certificateConfig; - DefaultCertificate = certificate; - } - } - } - - private (X509Certificate2?, CertificateConfig?) FindDeveloperCertificateFile() - { - string? certificatePath = null; - if (ConfigurationReader.Certificates.TryGetValue("Development", out var certificateConfig) && - certificateConfig.Path == null && - certificateConfig.Password != null && - TryGetCertificatePath(out certificatePath) && - File.Exists(certificatePath)) - { - try - { - var certificate = new X509Certificate2(certificatePath, certificateConfig.Password); - - if (IsDevelopmentCertificate(certificate)) - { - return (certificate, certificateConfig); - } - } - catch (CryptographicException) - { - Logger.FailedToLoadDevelopmentCertificate(certificatePath); - } - } - else if (!string.IsNullOrEmpty(certificatePath)) - { - Logger.FailedToLocateDevelopmentCertificateFile(certificatePath); - } - - return (null, null); - } - - private static bool IsDevelopmentCertificate(X509Certificate2 certificate) - { - if (!string.Equals(certificate.Subject, "CN=localhost", StringComparison.Ordinal)) - { - return false; - } - - foreach (var ext in certificate.Extensions) - { - if (string.Equals(ext.Oid?.Value, CertificateManager.AspNetHttpsOid, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private bool TryGetCertificatePath([NotNullWhen(true)] out string? path) - { - // See https://github.com/aspnet/Hosting/issues/1294 - var appData = Environment.GetEnvironmentVariable("APPDATA"); - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null; - basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null); - path = basePath != null ? Path.Combine(basePath, $"{HostEnvironment.ApplicationName}.pfx") : null; - return path != null; - } } diff --git a/src/Servers/Kestrel/Core/src/KestrelServer.cs b/src/Servers/Kestrel/Core/src/KestrelServer.cs index 1a31cf04d35f..c65552f9a06f 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServer.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServer.cs @@ -5,7 +5,11 @@ using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; @@ -31,6 +35,7 @@ public KestrelServer(IOptions options, IConnectionListener options, new[] { transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)) }, Array.Empty(), + new SimpleHttpsConfigurationService(), loggerFactory, new KestrelMetrics(new DummyMeterFactory())); } @@ -70,4 +75,44 @@ private sealed class DummyMeterFactory : IMeterFactory public Meter CreateMeter(MeterOptions options) => new Meter(options.Name, options.Version); } + + private sealed class SimpleHttpsConfigurationService : IHttpsConfigurationService + { + public bool IsInitialized => true; + + public void Initialize(IHostEnvironment hostEnvironment, ILogger serverLogger, ILogger httpsLogger) + { + // Already initialized + } + + public void PopulateMultiplexedTransportFeatures(FeatureCollection features, ListenOptions listenOptions) + { + HttpsConfigurationService.PopulateMultiplexedTransportFeaturesWorker(features, listenOptions); + } + + public ListenOptions UseHttpsWithDefaults(ListenOptions listenOptions) + { + return HttpsConfigurationService.UseHttpsWithDefaultsWorker(listenOptions); + } + + public void ApplyHttpsConfiguration( + HttpsConnectionAdapterOptions httpsOptions, + EndpointConfig endpoint, + KestrelServerOptions serverOptions, + CertificateConfig? defaultCertificateConfig, + ConfigurationReader configurationReader) + { + throw new NotImplementedException(); // Not actually required by this impl + } + + public ListenOptions UseHttpsWithSni(ListenOptions listenOptions, HttpsConnectionAdapterOptions httpsOptions, EndpointConfig endpoint) + { + throw new NotImplementedException(); // Not actually required by this impl + } + + public CertificateAndConfig? LoadDefaultCertificate(ConfigurationReader configurationReader) + { + throw new NotImplementedException(); // Not actually required by this impl + } + } } diff --git a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs index 6bfeaef62115..b44e3b33adcf 100644 --- a/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs +++ b/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs @@ -250,11 +250,15 @@ internal void ApplyHttpsDefaults(HttpsConnectionAdapterOptions httpsOptions) internal void ApplyDefaultCertificate(HttpsConnectionAdapterOptions httpsOptions) { - if (httpsOptions.ServerCertificate != null || httpsOptions.ServerCertificateSelector != null) + if (httpsOptions.HasServerCertificateOrSelector) { return; } + // It's important (and currently true) that we don't reach here with https configuration uninitialized because + // we might incorrectly favor the development certificate over one specified by the user. + Debug.Assert(ApplicationServices.GetRequiredService().IsInitialized, "HTTPS configuration should have been enabled"); + if (TestOverrideDefaultCertificate is X509Certificate2 certificateFromTest) { httpsOptions.ServerCertificate = certificateFromTest; @@ -278,6 +282,19 @@ internal void ApplyDefaultCertificate(HttpsConnectionAdapterOptions httpsOptions httpsOptions.ServerCertificate = DevelopmentCertificate; } + internal void EnableHttpsConfiguration() + { + var httpsConfigurationService = ApplicationServices.GetRequiredService(); + + if (!httpsConfigurationService.IsInitialized) + { + var hostEnvironment = ApplicationServices.GetRequiredService(); + var logger = ApplicationServices.GetRequiredService>(); + var httpsLogger = ApplicationServices.GetRequiredService>(); + httpsConfigurationService.Initialize(hostEnvironment, logger, httpsLogger); + } + } + internal void Serialize(Utf8JsonWriter writer) { writer.WritePropertyName(nameof(AllowSynchronousIO)); @@ -392,11 +409,8 @@ public KestrelConfigurationLoader Configure(IConfiguration config, bool reloadOn throw new InvalidOperationException($"{nameof(ApplicationServices)} must not be null. This is normally set automatically via {nameof(IConfigureOptions)}."); } - var hostEnvironment = ApplicationServices.GetRequiredService(); - var logger = ApplicationServices.GetRequiredService>(); - var httpsLogger = ApplicationServices.GetRequiredService>(); - - var loader = new KestrelConfigurationLoader(this, config, hostEnvironment, reloadOnChange, logger, httpsLogger); + var httpsConfigurationService = ApplicationServices.GetRequiredService(); + var loader = new KestrelConfigurationLoader(this, config, httpsConfigurationService, reloadOnChange); ConfigurationLoader = loader; return loader; } diff --git a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs index c69c37c71b13..2ea73a318584 100644 --- a/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs +++ b/src/Servers/Kestrel/Core/src/ListenOptionsHttpsExtensions.cs @@ -163,12 +163,15 @@ public static ListenOptions UseHttps(this ListenOptions listenOptions, Action /// Configure Kestrel to use HTTPS. This does not use default certificates or other defaults specified via config or /// . diff --git a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs index de065dc3e9be..8cf4f843798a 100644 --- a/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs +++ b/src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs @@ -57,7 +57,7 @@ public HttpsConnectionMiddleware(ConnectionDelegate next, HttpsConnectionAdapter { ArgumentNullException.ThrowIfNull(options); - if (options.ServerCertificate == null && options.ServerCertificateSelector == null) + if (!options.HasServerCertificateOrSelector) { throw new ArgumentException(CoreStrings.ServerCertificateRequired, nameof(options)); } diff --git a/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs b/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs new file mode 100644 index 000000000000..087b06639483 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/TlsConfigurationLoader.cs @@ -0,0 +1,205 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.AspNetCore.Server.Kestrel.Https.Internal; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core; + +/// +/// An abstraction over the parts of that would prevent us from trimming TLS support +/// in `CreateSlimBuilder` scenarios. Managed by . +/// +internal sealed class TlsConfigurationLoader +{ + private readonly ICertificateConfigLoader _certificateConfigLoader; + private readonly string _applicationName; + private readonly ILogger _serverLogger; + private readonly ILogger _httpsLogger; + + public TlsConfigurationLoader( + IHostEnvironment hostEnvironment, + ILogger serverLogger, + ILogger httpsLogger) + { + _certificateConfigLoader = new CertificateConfigLoader(hostEnvironment, serverLogger); + _applicationName = hostEnvironment.ApplicationName; + _serverLogger = serverLogger; + _httpsLogger = httpsLogger; + } + + /// + /// Applies various configuration settings to and . + /// + public void ApplyHttpsConfiguration( + HttpsConnectionAdapterOptions httpsOptions, + EndpointConfig endpoint, + KestrelServerOptions serverOptions, + CertificateConfig? defaultCertificateConfig, + ConfigurationReader configurationReader) + { + serverOptions.ApplyHttpsDefaults(httpsOptions); + + if (endpoint.SslProtocols.HasValue) + { + httpsOptions.SslProtocols = endpoint.SslProtocols.Value; + } + else + { + // Ensure endpoint is reloaded if it used the default protocol and the SslProtocols changed. + endpoint.SslProtocols = configurationReader.EndpointDefaults.SslProtocols; + } + + if (endpoint.ClientCertificateMode.HasValue) + { + httpsOptions.ClientCertificateMode = endpoint.ClientCertificateMode.Value; + } + else + { + // Ensure endpoint is reloaded if it used the default mode and the ClientCertificateMode changed. + endpoint.ClientCertificateMode = configurationReader.EndpointDefaults.ClientCertificateMode; + } + + // A cert specified directly on the endpoint overrides any defaults. + var (serverCert, fullChain) = _certificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name); + httpsOptions.ServerCertificate = serverCert ?? httpsOptions.ServerCertificate; + httpsOptions.ServerCertificateChain = fullChain ?? httpsOptions.ServerCertificateChain; + + if (!httpsOptions.HasServerCertificateOrSelector) + { + // Fallback + serverOptions.ApplyDefaultCertificate(httpsOptions); + + // Ensure endpoint is reloaded if it used the default certificate and the certificate changed. + endpoint.Certificate = defaultCertificateConfig; + } + } + + /// + /// Calls an appropriate overload of + /// on , with or without SNI, according to how is configured. + /// + /// Updated for convenient chaining. + public ListenOptions UseHttpsWithSni( + ListenOptions listenOptions, + HttpsConnectionAdapterOptions httpsOptions, + EndpointConfig endpoint) + { + if (listenOptions.IsTls) + { + return listenOptions; + } + + if (endpoint.Sni.Count == 0) + { + if (!httpsOptions.HasServerCertificateOrSelector) + { + throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound); + } + + return listenOptions.UseHttps(httpsOptions); + } + + var sniOptionsSelector = new SniOptionsSelector(endpoint.Name, endpoint.Sni, _certificateConfigLoader, + httpsOptions, listenOptions.Protocols, _httpsLogger); + var tlsCallbackOptions = new TlsHandshakeCallbackOptions() + { + OnConnection = SniOptionsSelector.OptionsCallback, + HandshakeTimeout = httpsOptions.HandshakeTimeout, + OnConnectionState = sniOptionsSelector, + }; + + return listenOptions.UseHttps(tlsCallbackOptions); + } + + /// + /// Retrieves the default or, failing that, developer certificate from . + /// + public CertificateAndConfig? LoadDefaultCertificate(ConfigurationReader configurationReader) + { + if (configurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig)) + { + var (defaultCert, _ /* cert chain */) = _certificateConfigLoader.LoadCertificate(defaultCertConfig, "Default"); + if (defaultCert != null) + { + return new CertificateAndConfig(defaultCert, defaultCertConfig); + } + } + else if (FindDeveloperCertificateFile(configurationReader) is CertificateAndConfig pair) + { + _serverLogger.LocatedDevelopmentCertificate(pair.Certificate); + return pair; + } + + return null; + } + + private CertificateAndConfig? FindDeveloperCertificateFile(ConfigurationReader configurationReader) + { + string? certificatePath = null; + if (configurationReader.Certificates.TryGetValue("Development", out var certificateConfig) && + certificateConfig.Path == null && + certificateConfig.Password != null && + TryGetCertificatePath(_applicationName, out certificatePath) && + File.Exists(certificatePath)) + { + try + { + var certificate = new X509Certificate2(certificatePath, certificateConfig.Password); + + if (IsDevelopmentCertificate(certificate)) + { + return new CertificateAndConfig(certificate, certificateConfig); + } + } + catch (CryptographicException) + { + _serverLogger.FailedToLoadDevelopmentCertificate(certificatePath); + } + } + else if (!string.IsNullOrEmpty(certificatePath)) + { + _serverLogger.FailedToLocateDevelopmentCertificateFile(certificatePath); + } + + return null; + } + + private static bool IsDevelopmentCertificate(X509Certificate2 certificate) + { + if (!string.Equals(certificate.Subject, "CN=localhost", StringComparison.Ordinal)) + { + return false; + } + + foreach (var ext in certificate.Extensions) + { + if (string.Equals(ext.Oid?.Value, CertificateManager.AspNetHttpsOid, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool TryGetCertificatePath(string applicationName, [NotNullWhen(true)] out string? path) + { + // See https://github.com/aspnet/Hosting/issues/1294 + var appData = Environment.GetEnvironmentVariable("APPDATA"); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null; + basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null); + path = basePath != null ? Path.Combine(basePath, $"{applicationName}.pfx") : null; + return path != null; + } +} diff --git a/src/Servers/Kestrel/Core/test/AddressBinderTests.cs b/src/Servers/Kestrel/Core/test/AddressBinderTests.cs index 4a0cd1602315..6946da8f91cc 100644 --- a/src/Servers/Kestrel/Core/test/AddressBinderTests.cs +++ b/src/Servers/Kestrel/Core/test/AddressBinderTests.cs @@ -1,28 +1,22 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.IO; using System.Net; using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; -using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; public class AddressBinderTests { + private readonly Func _noopUseHttps = l => l; + [Theory] [InlineData("http://10.10.10.10:5000/", "10.10.10.10", 5000)] [InlineData("http://[::1]:5000", "::1", 5000)] @@ -172,7 +166,7 @@ public async Task WrapsAddressInUseExceptionAsIOException() endpoint => throw new AddressInUseException("already in use")); await Assert.ThrowsAsync(() => - AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, CancellationToken.None)); + AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, CancellationToken.None)); } [Fact] @@ -193,7 +187,7 @@ public void LogsWarningWhenHostingAddressesAreOverridden() logger, endpoint => Task.CompletedTask); - var bindTask = AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, CancellationToken.None); + var bindTask = AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, CancellationToken.None); Assert.True(bindTask.IsCompletedSuccessfully); var log = Assert.Single(logger.Messages); @@ -221,7 +215,7 @@ public void LogsInformationWhenKestrelAddressesAreOverridden() addressBindContext.ServerAddressesFeature.PreferHostingUrls = true; - var bindTask = AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, CancellationToken.None); + var bindTask = AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, CancellationToken.None); Assert.True(bindTask.IsCompletedSuccessfully); var log = Assert.Single(logger.Messages); @@ -247,7 +241,7 @@ public async Task FlowsCancellationTokenToCreateBinddingCallback() }); await Assert.ThrowsAsync(() => - AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, new CancellationToken(true))); + AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, new CancellationToken(true))); } [Theory] @@ -284,7 +278,7 @@ public async Task FallbackToIPv4WhenIPv6AnyBindFails(string address) return Task.CompletedTask; }); - await AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, CancellationToken.None); + await AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, CancellationToken.None); Assert.True(ipV4Attempt, "Should have attempted to bind to IPAddress.Any"); Assert.True(ipV6Attempt, "Should have attempted to bind to IPAddress.IPv6Any"); @@ -315,7 +309,7 @@ public async Task DefaultAddressBinderBindsToHttpPort5000() return Task.CompletedTask; }); - await AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, CancellationToken.None); + await AddressBinder.BindAsync(options.GetListenOptions(), addressBindContext, _noopUseHttps, CancellationToken.None); Assert.Contains(endpoints, e => e.IPEndPoint.Port == 5000 && !e.IsTls); } diff --git a/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs index d213bdc2e5e5..624022316441 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerOptionsTests.cs @@ -84,6 +84,7 @@ public void CanCallListenAfterConfigure() serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of()); options.ApplicationServices = serviceCollection.BuildServiceProvider(); options.Configure(); diff --git a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs index c6054f8639f6..a6415f735e77 100644 --- a/src/Servers/Kestrel/Core/test/KestrelServerTests.cs +++ b/src/Servers/Kestrel/Core/test/KestrelServerTests.cs @@ -28,9 +28,15 @@ public class KestrelServerTests { private KestrelServerOptions CreateServerOptions() { + // It's not actually going to be used - we just need to satisfy the check in ApplyDefaultCertificate + var mockHttpsConfig = new Mock(); + mockHttpsConfig.Setup(m => m.IsInitialized).Returns(true); + var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() .AddSingleton(new KestrelMetrics(new TestMeterFactory())) + .AddSingleton(Mock.Of()) + .AddSingleton(mockHttpsConfig.Object) .AddLogging() .BuildServiceProvider(); return serverOptions; @@ -287,10 +293,20 @@ private static KestrelServerImpl CreateKestrelServer( ILoggerFactory loggerFactory = null, KestrelMetrics metrics = null) { + var httpsConfigurationService = new HttpsConfigurationService(); + if (options?.ApplicationServices is IServiceProvider serviceProvider) + { + httpsConfigurationService.Initialize( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService>()); + } + return new KestrelServerImpl( Options.Create(options), transportFactories, multiplexedFactories, + httpsConfigurationService, loggerFactory ?? new LoggerFactory(new[] { new KestrelTestLoggerProvider() }), metrics ?? new KestrelMetrics(new TestMeterFactory())); } @@ -714,7 +730,7 @@ public void StartingServerInitializesHeartbeat() testContext.Log, Heartbeat.Interval); - using (var server = new KestrelServerImpl(new[] { new MockTransportFactory() }, Array.Empty(), testContext)) + using (var server = new KestrelServerImpl(new[] { new MockTransportFactory() }, Array.Empty(), new HttpsConfigurationService(), testContext)) { Assert.Null(testContext.DateHeaderValueManager.GetDateHeaderValues()); @@ -768,6 +784,7 @@ public async Task ReloadsOnConfigurationChangeWhenOptedIn() serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of()); var options = new KestrelServerOptions { @@ -905,6 +922,7 @@ public async Task DoesNotReloadOnConfigurationChangeByDefault() serviceCollection.AddSingleton(Mock.Of()); serviceCollection.AddSingleton(Mock.Of>()); serviceCollection.AddSingleton(Mock.Of>()); + serviceCollection.AddSingleton(Mock.Of()); var options = new KestrelServerOptions { diff --git a/src/Servers/Kestrel/Kestrel/src/PublicAPI.Unshipped.txt b/src/Servers/Kestrel/Kestrel/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..1b0f75d6addc 100644 --- a/src/Servers/Kestrel/Kestrel/src/PublicAPI.Unshipped.txt +++ b/src/Servers/Kestrel/Kestrel/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +static Microsoft.AspNetCore.Hosting.WebHostBuilderKestrelExtensions.UseKestrelHttpsConfiguration(this Microsoft.AspNetCore.Hosting.IWebHostBuilder! hostBuilder) -> Microsoft.AspNetCore.Hosting.IWebHostBuilder! +static Microsoft.AspNetCore.Hosting.WebHostBuilderKestrelExtensions.UseKestrelCore(this Microsoft.AspNetCore.Hosting.IWebHostBuilder! hostBuilder) -> Microsoft.AspNetCore.Hosting.IWebHostBuilder! diff --git a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs index f4563b222809..7186715a6c5f 100644 --- a/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs +++ b/src/Servers/Kestrel/Kestrel/src/WebHostBuilderKestrelExtensions.cs @@ -19,6 +19,28 @@ namespace Microsoft.AspNetCore.Hosting; /// public static class WebHostBuilderKestrelExtensions { + /// + /// In scenarios, it may be necessary to explicitly + /// opt in to certain HTTPS functionality. For example, if ASPNETCORE_URLS includes + /// an https:// address, will enable configuration + /// of HTTPS on that endpoint. + /// + /// Has no effect in scenarios. + /// + /// + /// The Microsoft.AspNetCore.Hosting.IWebHostBuilder to configure. + /// + /// + /// The Microsoft.AspNetCore.Hosting.IWebHostBuilder. + /// + public static IWebHostBuilder UseKestrelHttpsConfiguration(this IWebHostBuilder hostBuilder) + { + return hostBuilder.ConfigureServices(services => + { + services.AddSingleton(); + }); + } + /// /// Specify Kestrel as the server to be used by the web host. /// @@ -29,6 +51,33 @@ public static class WebHostBuilderKestrelExtensions /// The Microsoft.AspNetCore.Hosting.IWebHostBuilder. /// public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder) + { + return hostBuilder + .UseKestrelCore() + .UseKestrelHttpsConfiguration() + .UseQuic(options => + { + // Configure server defaults to match client defaults. + // https://github.com/dotnet/runtime/blob/a5f3676cc71e176084f0f7f1f6beeecd86fbeafc/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectHelper.cs#L118-L119 + options.DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled; + options.DefaultCloseErrorCode = (long)Http3ErrorCode.NoError; + }); + } + + /// + /// Specify Kestrel as the server to be used by the web host. + /// Includes less automatic functionality than to make trimming more effective + /// (e.g. for Native AOT scenarios). If the host ends up depending on + /// some of the absent functionality, a best-effort attempt will be made to enable it on-demand. Failing that, an + /// exception with an informative error message will be raised when the host is started. + /// + /// + /// The Microsoft.AspNetCore.Hosting.IWebHostBuilder to configure. + /// + /// + /// The Microsoft.AspNetCore.Hosting.IWebHostBuilder. + /// + public static IWebHostBuilder UseKestrelCore(this IWebHostBuilder hostBuilder) { hostBuilder.ConfigureServices(services => { @@ -36,18 +85,11 @@ public static IWebHostBuilder UseKestrel(this IWebHostBuilder hostBuilder) services.TryAddSingleton(); services.AddTransient, KestrelServerOptionsSetup>(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); - hostBuilder.UseQuic(options => - { - // Configure server defaults to match client defaults. - // https://github.com/dotnet/runtime/blob/a5f3676cc71e176084f0f7f1f6beeecd86fbeafc/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectHelper.cs#L118-L119 - options.DefaultStreamErrorCode = (long)Http3ErrorCode.RequestCancelled; - options.DefaultCloseErrorCode = (long)Http3ErrorCode.NoError; - }); - if (OperatingSystem.IsWindows()) { hostBuilder.UseNamedPipes(); diff --git a/src/Servers/Kestrel/Kestrel/test/HttpsConfigurationTests.cs b/src/Servers/Kestrel/Kestrel/test/HttpsConfigurationTests.cs new file mode 100644 index 000000000000..cb27ce5bc210 --- /dev/null +++ b/src/Servers/Kestrel/Kestrel/test/HttpsConfigurationTests.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Server.Kestrel.Tests; + +public class HttpsConfigurationTests +{ + [Theory] + [InlineData("http://127.0.0.1:0", true)] + [InlineData("http://127.0.0.1:0", false)] + [InlineData("https://127.0.0.1:0", true)] + [InlineData("https://127.0.0.1:0", false)] + public async Task BindAddressFromSetting(string address, bool useKestrelHttpsConfiguration) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + serverOptions.TestOverrideDefaultCertificate = new X509Certificate2(Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx"), "testPassword"); + }) + .Configure(app => { }); + + // This is what ASPNETCORE_URLS would populate + hostBuilder.UseSetting(WebHostDefaults.ServerUrlsKey, address); + + if (useKestrelHttpsConfiguration) + { + hostBuilder.UseKestrelHttpsConfiguration(); + } + + var host = hostBuilder.Build(); + + Assert.Single(host.ServerFeatures.Get().Addresses, address); + + if (address.StartsWith("https", StringComparison.OrdinalIgnoreCase) && !useKestrelHttpsConfiguration) + { + Assert.Throws(host.Run); + } + else + { + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + } + } + + [Fact] + public void NoFallbackToHttpAddress() + { + const string httpAddress = "http://127.0.0.1:0"; + const string httpsAddress = "https://localhost:5001"; + + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .Configure(app => { }); + + // This is what ASPNETCORE_URLS would populate + hostBuilder.UseSetting(WebHostDefaults.ServerUrlsKey, $"{httpAddress};{httpsAddress}"); + + var host = hostBuilder.Build(); + + Assert.Equal(new[] { httpAddress, httpsAddress }, host.ServerFeatures.Get().Addresses); + + Assert.Throws(host.Run); + } + + [Theory] + [InlineData("http://127.0.0.1:0", true)] + [InlineData("http://127.0.0.1:0", false)] + [InlineData("https://127.0.0.1:0", true)] + [InlineData("https://127.0.0.1:0", false)] + public async Task BindAddressFromEndpoint(string address, bool useKestrelHttpsConfiguration) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:end1:Url", address), + new KeyValuePair("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx")), + new KeyValuePair("Certificates:Default:Password", "testPassword"), + }).Build(); + serverOptions.Configure(config); + }) + .Configure(app => { }); + + if (useKestrelHttpsConfiguration) + { + hostBuilder.UseKestrelHttpsConfiguration(); + } + + var host = hostBuilder.Build(); + + if (address.StartsWith("https", StringComparison.OrdinalIgnoreCase) && !useKestrelHttpsConfiguration) + { + Assert.Throws(host.Run); + } + else + { + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LoadDefaultCertificate(bool useKestrelHttpsConfiguration) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx")), + new KeyValuePair("Certificates:Default:Password", "testPassword"), + }).Build(); + serverOptions.Configure(config); + }) + .Configure(app => { }); + + if (useKestrelHttpsConfiguration) + { + hostBuilder.UseKestrelHttpsConfiguration(); + } + + var host = hostBuilder.Build(); + + // There's no exception for specifying a default cert when https config is enabled + await host.StartAsync(); + await host.StopAsync(); + } + + [Theory] + [InlineData("http://127.0.0.1:0", true)] + [InlineData("http://127.0.0.1:0", false)] + [InlineData("https://127.0.0.1:0", true)] + [InlineData("https://127.0.0.1:0", false)] + public async Task LoadEndpointCertificate(string address, bool useKestrelHttpsConfiguration) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:end1:Url", address), + new KeyValuePair("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx")), + new KeyValuePair("Certificates:Default:Password", "testPassword"), + }).Build(); + serverOptions.Configure(config); + }) + .Configure(app => { }); + + if (useKestrelHttpsConfiguration) + { + hostBuilder.UseKestrelHttpsConfiguration(); + } + + var host = hostBuilder.Build(); + + if (address.StartsWith("https", StringComparison.OrdinalIgnoreCase) && !useKestrelHttpsConfiguration) + { + Assert.Throws(host.Run); + } + else + { + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + } + } + + [Fact] + public async Task UseHttpsJustWorks() + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + serverOptions.TestOverrideDefaultCertificate = new X509Certificate2(Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx"), "testPassword"); + + serverOptions.ListenAnyIP(0, listenOptions => + { + listenOptions.UseHttps(); + }); + }) + .Configure(app => { }); + + var host = hostBuilder.Build(); + + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + + Assert.True(host.Services.GetRequiredService().IsInitialized); + } + + [Fact] + public async Task UseHttpsMayNotImplyUseKestrelHttpsConfiguration() + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(0, listenOptions => + { + listenOptions.UseHttps(new HttpsConnectionAdapterOptions() + { + ServerCertificate = new X509Certificate2(Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx"), "testPassword"), + }); + }); + }) + .Configure(app => { }); + + var host = hostBuilder.Build(); + + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + + // This is more documentary than normative + Assert.False(host.Services.GetRequiredService().IsInitialized); + } +} diff --git a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs index 8f1f535d381d..5b033b4952ef 100644 --- a/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs +++ b/src/Servers/Kestrel/Kestrel/test/KestrelConfigurationLoaderTests.cs @@ -27,6 +27,8 @@ private KestrelServerOptions CreateServerOptions() .AddLogging() .AddSingleton(env) .AddSingleton(new KestrelMetrics(new TestMeterFactory())) + .AddSingleton() + .AddSingleton() .BuildServiceProvider(); return serverOptions; } diff --git a/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs b/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs index 5d73e667c5a3..27c6eac5b731 100644 --- a/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs +++ b/src/Servers/Kestrel/shared/test/TransportTestHelpers/TestServer.cs @@ -84,6 +84,8 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(this); services.AddSingleton(context.LoggerFactory); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { // Manually configure options on the TestServiceContext. @@ -94,7 +96,7 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(), Array.Empty(), context); + return new KestrelServerImpl(sp.GetServices(), Array.Empty(), sp.GetRequiredService(), context); }); configureServices(services); }) diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs index 4557297b8a67..b5c62a11a3b6 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs @@ -35,9 +35,15 @@ public class HttpsConnectionMiddlewareTests : LoggedTest private static KestrelServerOptions CreateServerOptions() { + var env = new Mock(); + env.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); + var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() .AddLogging() + .AddSingleton() + .AddSingleton() + .AddSingleton(env.Object) .AddSingleton(new KestrelMetrics(new TestMeterFactory())) .BuildServiceProvider(); return serverOptions; @@ -73,14 +79,9 @@ public async Task CanReadAndWriteWithHttpsConnectionMiddlewareWithPemCertificate ["Certificates:Default:Password"] = "aspnetcore", }).Build(); - var env = new Mock(); - env.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); - var options = CreateServerOptions(); - var logger = options.ApplicationServices.GetRequiredService>(); - var httpsLogger = options.ApplicationServices.GetRequiredService>(); - var loader = new KestrelConfigurationLoader(options, configuration, env.Object, reloadOnChange: false, logger, httpsLogger); + var loader = new KestrelConfigurationLoader(options, configuration, options.ApplicationServices.GetRequiredService(), reloadOnChange: false); options.ConfigurationLoader = loader; // Since we're constructing it explicitly, we have to hook it up explicitly loader.Load(); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs index 6d39bff9dfb7..819def76e5a2 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsTests.cs @@ -21,10 +21,12 @@ using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Metrics; using Microsoft.Extensions.Options; +using Moq; using Xunit; namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests; @@ -35,9 +37,15 @@ public class HttpsTests : LoggedTest private static KestrelServerOptions CreateServerOptions() { + // It's not actually going to be used - we just need to satisfy the check in ApplyDefaultCertificate + var mockHttpsConfig = new Mock(); + mockHttpsConfig.Setup(m => m.IsInitialized).Returns(true); + var serverOptions = new KestrelServerOptions(); serverOptions.ApplicationServices = new ServiceCollection() .AddLogging() + .AddSingleton(mockHttpsConfig.Object) + .AddSingleton(Mock.Of()) .AddSingleton(new KestrelMetrics(new TestMeterFactory())) .BuildServiceProvider(); return serverOptions; diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs index ee9e1efa529d..21349b65de6a 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs @@ -87,6 +87,8 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(this); services.AddSingleton(context.LoggerFactory); services.AddSingleton(context.Metrics); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { @@ -95,6 +97,7 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action(), + sp.GetRequiredService(), context); }); }); diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3TlsTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3TlsTests.cs index 6226bf1660c0..8efd8e2789bd 100644 --- a/src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3TlsTests.cs +++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3TlsTests.cs @@ -10,6 +10,8 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Testing; +using Microsoft.CSharp.RuntimeBinder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Xunit; @@ -329,6 +331,97 @@ public async Task TlsHandshakeCallbackOptions_Invoked() await host.StopAsync().DefaultTimeout(); } + [ConditionalTheory] + [MsQuicSupported] + [InlineData(true, true, true)] + [InlineData(true, true, false)] + [InlineData(true, false, false)] + [InlineData(false, true, true)] + [InlineData(false, true, false)] + [InlineData(false, false, false)] + public async Task UseKestrelCore_CodeBased(bool useQuic, bool useHttps, bool useHttpsEnablesHttpsConfiguration) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + serverOptions.ListenAnyIP(0, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http3; + if (useHttps) + { + if (useHttpsEnablesHttpsConfiguration) + { + listenOptions.UseHttps(httpsOptions => + { + httpsOptions.ServerCertificate = TestResources.GetTestCertificate(); + }); + } + else + { + // Specifically choose an overload that doesn't enable https configuration + listenOptions.UseHttps(new HttpsConnectionAdapterOptions + { + ServerCertificate = TestResources.GetTestCertificate() + }); + } + } + }); + }) + .Configure(app => { }); + + if (useQuic) + { + hostBuilder.UseQuic(); + } + + var host = hostBuilder.Build(); + + if (useHttps && useHttpsEnablesHttpsConfiguration && useQuic) + { + // Binding succeeds + await host.StartAsync(); + await host.StopAsync(); + } + else + { + // This *could* work for `useHttps && !useHttpsEnablesHttpsConfiguration` if `UseQuic` implied `UseKestrelHttpsConfiguration` + Assert.Throws(host.Run); + } + } + + [ConditionalTheory] + [MsQuicSupported] + [InlineData(true)] + [InlineData(false)] + public void UseKestrelCore_ConfigurationBased(bool useQuic) + { + var hostBuilder = new WebHostBuilder() + .UseKestrelCore() + .ConfigureKestrel(serverOptions => + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Endpoints:end1:Url", "https://127.0.0.1:0"), + new KeyValuePair("Endpoints:end1:Protocols", "Http3"), + new KeyValuePair("Certificates:Default:Path", Path.Combine("shared", "TestCertificates", "aspnetdevcert.pfx")), + new KeyValuePair("Certificates:Default:Password", "testPassword"), + }).Build(); + serverOptions.Configure(config); + }) + .Configure(app => { }); + + if (useQuic) + { + hostBuilder.UseQuic(); + } + + var host = hostBuilder.Build(); + + // This *could* work (in some cases) if `UseQuic` implied `UseKestrelHttpsConfiguration` + Assert.Throws(host.Run); + } + private IHostBuilder CreateHostBuilder(RequestDelegate requestDelegate, HttpProtocols? protocol = null, Action configureKestrel = null) { return HttpHelpers.CreateHostBuilder(AddTestLogging, requestDelegate, protocol, configureKestrel); From e53379914e7ed68eab66f5dac5c8682ac6f3545e Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 01:54:23 +0000 Subject: [PATCH 09/24] Update dependencies from https://github.com/dotnet/arcade build 20230411.8 (#47672) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 20 ++++++++++---------- eng/Versions.props | 6 +++--- global.json | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index b6aa8914f693..d70bb9bc0a8e 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -306,26 +306,26 @@ https://github.com/dotnet/runtime 42c6bcf2076a52d256afcab5080caddb1fbeea98 - + https://github.com/dotnet/arcade - 1db5a29a3a9c8982c784cbe00f44ea1dbed7b680 + 17d9eee32f20a6af0ebb620254a22f601d159578 - + https://github.com/dotnet/arcade - 1db5a29a3a9c8982c784cbe00f44ea1dbed7b680 + 17d9eee32f20a6af0ebb620254a22f601d159578 - + https://github.com/dotnet/arcade - 1db5a29a3a9c8982c784cbe00f44ea1dbed7b680 + 17d9eee32f20a6af0ebb620254a22f601d159578 - + https://github.com/dotnet/arcade - 1db5a29a3a9c8982c784cbe00f44ea1dbed7b680 + 17d9eee32f20a6af0ebb620254a22f601d159578 - + https://github.com/dotnet/arcade - 1db5a29a3a9c8982c784cbe00f44ea1dbed7b680 + 17d9eee32f20a6af0ebb620254a22f601d159578 diff --git a/eng/Versions.props b/eng/Versions.props index 905c30976993..32f7aeddf9a2 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -136,9 +136,9 @@ 8.0.0-preview.4.23212.3 8.0.0-preview.4.23212.3 - 8.0.0-beta.23207.2 - 8.0.0-beta.23207.2 - 8.0.0-beta.23207.2 + 8.0.0-beta.23211.8 + 8.0.0-beta.23211.8 + 8.0.0-beta.23211.8 8.0.0-alpha.1.23203.1 diff --git a/global.json b/global.json index b47b28b52817..a3af2639e138 100644 --- a/global.json +++ b/global.json @@ -27,7 +27,7 @@ }, "msbuild-sdks": { "Yarn.MSBuild": "1.22.10", - "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23207.2", - "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23207.2" + "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.23211.8", + "Microsoft.DotNet.Helix.Sdk": "8.0.0-beta.23211.8" } } From 31d335b8562aa6d9e137676ef41368e82355e6da Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 14 Apr 2023 16:04:24 +0800 Subject: [PATCH 10/24] Diaganostics and metrics clean up (#47679) --- .../src/Internal/HostingApplication.cs | 7 +- .../Internal/HostingApplicationDiagnostics.cs | 22 +---- ...ivityFeature.cs => HttpActivityFeature.cs} | 4 +- .../Hosting/test/HostingApplicationTests.cs | 66 ++++++++++----- .../Infrastructure/KestrelConnectionOfT.cs | 5 +- .../KestrelMetricsTests.cs | 80 +++++++++++++++++++ .../TestTransport/TestServer.cs | 5 +- 7 files changed, 145 insertions(+), 44 deletions(-) rename src/Hosting/Hosting/src/Internal/{ActivityFeature.cs => HttpActivityFeature.cs} (79%) diff --git a/src/Hosting/Hosting/src/Internal/HostingApplication.cs b/src/Hosting/Hosting/src/Internal/HostingApplication.cs index b880a7d3106b..b94f2e9b8b89 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplication.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplication.cs @@ -129,7 +129,10 @@ public Activity? Activity { if (HttpActivityFeature is null) { - HttpActivityFeature = new ActivityFeature(value!); + if (value != null) + { + HttpActivityFeature = new HttpActivityFeature(value); + } } else { @@ -143,7 +146,7 @@ public Activity? Activity internal bool HasDiagnosticListener { get; set; } public bool EventLogOrMetricsEnabled { get; set; } - internal IHttpActivityFeature? HttpActivityFeature; + internal HttpActivityFeature? HttpActivityFeature; internal HttpMetricsTagsFeature? MetricsTagsFeature; public void Reset() diff --git a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs index fb24de39de1c..4d4c4a8a4a20 100644 --- a/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs +++ b/src/Hosting/Hosting/src/Internal/HostingApplicationDiagnostics.cs @@ -55,15 +55,8 @@ public void BeginRequest(HttpContext httpContext, HostingApplication.Context con if (_eventSource.IsEnabled() || _metrics.IsEnabled()) { context.EventLogOrMetricsEnabled = true; - if (httpContext.Features.Get() is HttpMetricsTagsFeature feature) - { - context.MetricsTagsFeature = feature; - } - else - { - context.MetricsTagsFeature ??= new HttpMetricsTagsFeature(); - httpContext.Features.Set(context.MetricsTagsFeature); - } + context.MetricsTagsFeature ??= new HttpMetricsTagsFeature(); + httpContext.Features.Set(context.MetricsTagsFeature); startTimestamp = Stopwatch.GetTimestamp(); @@ -80,16 +73,9 @@ public void BeginRequest(HttpContext httpContext, HostingApplication.Context con context.Activity = StartActivity(httpContext, loggingEnabled, diagnosticListenerActivityCreationEnabled, out var hasDiagnosticListener); context.HasDiagnosticListener = hasDiagnosticListener; - if (context.Activity is Activity activity) + if (context.Activity != null) { - if (httpContext.Features.Get() is IHttpActivityFeature feature) - { - feature.Activity = activity; - } - else - { - httpContext.Features.Set(context.HttpActivityFeature); - } + httpContext.Features.Set(context.HttpActivityFeature); } } diff --git a/src/Hosting/Hosting/src/Internal/ActivityFeature.cs b/src/Hosting/Hosting/src/Internal/HttpActivityFeature.cs similarity index 79% rename from src/Hosting/Hosting/src/Internal/ActivityFeature.cs rename to src/Hosting/Hosting/src/Internal/HttpActivityFeature.cs index f89495c78bc9..d7fd773fa8fc 100644 --- a/src/Hosting/Hosting/src/Internal/ActivityFeature.cs +++ b/src/Hosting/Hosting/src/Internal/HttpActivityFeature.cs @@ -9,9 +9,9 @@ namespace Microsoft.AspNetCore.Hosting; /// /// Default implementation for . /// -internal sealed class ActivityFeature : IHttpActivityFeature +internal sealed class HttpActivityFeature : IHttpActivityFeature { - internal ActivityFeature(Activity activity) + internal HttpActivityFeature(Activity activity) { Activity = activity; } diff --git a/src/Hosting/Hosting/test/HostingApplicationTests.cs b/src/Hosting/Hosting/test/HostingApplicationTests.cs index a0154713d137..d3e9e2623b09 100644 --- a/src/Hosting/Hosting/test/HostingApplicationTests.cs +++ b/src/Hosting/Hosting/test/HostingApplicationTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Hosting.Fakes; @@ -108,6 +109,39 @@ static void AssertRequestDuration(Measurement measurement, string protoc } } + [Fact] + public void IHttpMetricsTagsFeatureNotUsedFromFeatureCollection() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var meterRegistry = new TestMeterRegistry(meterFactory.Meters); + var hostingApplication = CreateApplication(meterFactory: meterFactory); + var httpContext = new DefaultHttpContext(); + var meter = meterFactory.Meters.Single(); + + using var requestDurationRecorder = new InstrumentRecorder(meterRegistry, HostingMetrics.MeterName, "request-duration"); + using var currentRequestsRecorder = new InstrumentRecorder(meterRegistry, HostingMetrics.MeterName, "current-requests"); + + // Act/Assert + Assert.Equal(HostingMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + // This feature will be overidden by hosting. Hosting is the owner of the feature and is resposible for setting it. + var overridenFeature = new TestHttpMetricsTagsFeature(); + httpContext.Features.Set(overridenFeature); + + var context = hostingApplication.CreateContext(httpContext.Features); + var contextFeature = httpContext.Features.Get(); + + Assert.NotNull(contextFeature); + Assert.NotEqual(overridenFeature, contextFeature); + } + + private sealed class TestHttpMetricsTagsFeature : IHttpMetricsTagsFeature + { + public ICollection> Tags { get; } = new Collection>(); + } + [Fact] public void DisposeContextDoesNotClearHttpContextIfDefaultHttpContextFactoryUsed() { @@ -204,7 +238,9 @@ public void IHttpActivityFeatureIsPopulated() var initialActivity = Activity.Current; // Create nested dummy Activity - using var _ = dummySource.StartActivity("DummyActivity"); + using var dummyActivity = dummySource.StartActivity("DummyActivity"); + Assert.NotNull(dummyActivity); + Assert.Equal(Activity.Current, dummyActivity); Assert.Same(initialActivity, activityFeature.Activity); Assert.Null(activityFeature.Activity.ParentId); @@ -221,8 +257,7 @@ private class TestHttpActivityFeature : IHttpActivityFeature } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspnetcore/issues/38736")] - public void IHttpActivityFeatureIsAssignedToIfItExists() + public void IHttpActivityFeatureNotUsedFromFeatureCollection() { var testSource = new ActivitySource(Path.GetRandomFileName()); var dummySource = new ActivitySource(Path.GetRandomFileName()); @@ -236,26 +271,19 @@ public void IHttpActivityFeatureIsAssignedToIfItExists() var hostingApplication = CreateApplication(activitySource: testSource); var httpContext = new DefaultHttpContext(); - httpContext.Features.Set(new TestHttpActivityFeature()); - var context = hostingApplication.CreateContext(httpContext.Features); - var activityFeature = context.HttpContext.Features.Get(); - Assert.NotNull(activityFeature); - Assert.IsType(activityFeature); - Assert.NotNull(activityFeature.Activity); - Assert.Equal(HostingApplicationDiagnostics.ActivityName, activityFeature.Activity.DisplayName); - var initialActivity = Activity.Current; + // This feature will be overidden by hosting. Hosting is the owner of the feature and is resposible for setting it. + var overridenFeature = new TestHttpActivityFeature(); + httpContext.Features.Set(overridenFeature); - // Create nested dummy Activity - using var _ = dummySource.StartActivity("DummyActivity"); + var context = hostingApplication.CreateContext(httpContext.Features); - Assert.Same(initialActivity, activityFeature.Activity); - Assert.Null(activityFeature.Activity.ParentId); - Assert.Equal(activityFeature.Activity.Id, Activity.Current.ParentId); - Assert.NotEqual(Activity.Current, activityFeature.Activity); + var contextFeature = context.HttpContext.Features.Get(); + Assert.NotNull(contextFeature); + Assert.NotNull(contextFeature.Activity); + Assert.Equal(HostingApplicationDiagnostics.ActivityName, contextFeature.Activity.DisplayName); - // Act/Assert - hostingApplication.DisposeContext(context, null); + Assert.NotEqual(overridenFeature, contextFeature); } [Fact] diff --git a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs index 22c84ae704ce..2cb095ce3258 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Infrastructure/KestrelConnectionOfT.cs @@ -45,10 +45,10 @@ internal async Task ExecuteAsync() if (metricsConnectionDurationEnabled) { - startTimestamp = Stopwatch.GetTimestamp(); - metricsTagsFeature = new ConnectionMetricsTagsFeature(); connectionContext.Features.Set(metricsTagsFeature); + + startTimestamp = Stopwatch.GetTimestamp(); } try @@ -99,6 +99,7 @@ internal async Task ExecuteAsync() private sealed class ConnectionMetricsTagsFeature : IConnectionMetricsTagsFeature { public ICollection> Tags => TagsList; + public List> TagsList { get; } = new List>(); } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs index 656b854bdc1f..b2b29966c1a9 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/KestrelMetricsTests.cs @@ -88,6 +88,86 @@ await connection.ReceiveEnd( Assert.Collection(queuedConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); } + [Fact] + public async Task Http1Connection_IHttpConnectionTagsFeatureIgnoreFeatureSetOnTransport() + { + var sync = new SyncPoint(); + ConnectionContext currentConnectionContext = null; + + var listenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, 0)); + listenOptions.Use(next => + { + return async connectionContext => + { + currentConnectionContext = connectionContext; + + connectionContext.Features.Get().Tags.Add(new KeyValuePair("custom", "value!")); + + // Wait for the test to verify the connection has started. + await sync.WaitToContinue(); + + await next(connectionContext); + }; + }); + + var testMeterFactory = new TestMeterFactory(); + using var connectionDuration = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "connection-duration"); + using var currentConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "current-connections"); + using var queuedConnections = new InstrumentRecorder(new TestMeterRegistry(testMeterFactory.Meters), "Microsoft.AspNetCore.Server.Kestrel", "queued-connections"); + + var serviceContext = new TestServiceContext(LoggerFactory, metrics: new KestrelMetrics(testMeterFactory)); + + var sendString = "POST / HTTP/1.0\r\nContent-Length: 12\r\n\r\nHello World?"; + + await using var server = new TestServer(EchoApp, serviceContext, listenOptions); + + // This feature will be overidden by Kestrel. Kestrel is the owner of the feature and is resposible for setting it. + var overridenFeature = new TestConnectionMetricsTagsFeature(); + overridenFeature.Tags.Add(new KeyValuePair("test", "Value!")); + + using (var connection = server.CreateConnection(featuresAction: features => + { + features.Set(overridenFeature); + })) + { + await connection.Send(sendString); + + // Wait for connection to start on the server. + await sync.WaitForSyncPoint(); + + Assert.NotEqual(overridenFeature, currentConnectionContext.Features.Get()); + + Assert.Empty(connectionDuration.GetMeasurements()); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0")); + + // Signal that connection can continue. + sync.Continue(); + + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + "Connection: close", + $"Date: {serviceContext.DateHeaderValue}", + "", + "Hello World?"); + + await connection.WaitForConnectionClose(); + } + + Assert.Collection(connectionDuration.GetMeasurements(), m => + { + AssertDuration(m, "127.0.0.1:0"); + Assert.Equal("value!", (string)m.Tags.ToArray().Single(t => t.Key == "custom").Value); + Assert.Empty(m.Tags.ToArray().Where(t => t.Key == "test")); + }); + Assert.Collection(currentConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + Assert.Collection(queuedConnections.GetMeasurements(), m => AssertCount(m, 1, "127.0.0.1:0"), m => AssertCount(m, -1, "127.0.0.1:0")); + } + + private sealed class TestConnectionMetricsTagsFeature : IConnectionMetricsTagsFeature + { + public ICollection> Tags { get; } = new List>(); + } + [Fact] public async Task Http1Connection_Error() { diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs index 21349b65de6a..89ae70e7c550 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/TestTransport/TestServer.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.DependencyInjection; @@ -112,9 +113,11 @@ public TestServer(RequestDelegate app, TestServiceContext context, Action featuresAction = null) { var transportConnection = new InMemoryTransportConnection(_memoryPool, Context.Log, Context.Scheduler); + featuresAction?.Invoke(transportConnection.Features); + _transportFactory.AddConnection(transportConnection); return new InMemoryConnection(transportConnection, encoding); } From 0fd5fbb1ebd4aebcafa6f006363e8f696d7d5bf2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 10:07:01 -0700 Subject: [PATCH 11/24] [main] (deps): Bump src/submodules/googletest (#47707) Bumps [src/submodules/googletest](https://github.com/google/googletest) from `057b4e9` to `12a5852`. - [Release notes](https://github.com/google/googletest/releases) - [Commits](https://github.com/google/googletest/compare/057b4e904fd754135dc19ff557c14036fd316425...12a5852e451baabc79c63a86c634912c563d57bc) --- updated-dependencies: - dependency-name: src/submodules/googletest dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/submodules/googletest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/submodules/googletest b/src/submodules/googletest index 057b4e904fd7..12a5852e451b 160000 --- a/src/submodules/googletest +++ b/src/submodules/googletest @@ -1 +1 @@ -Subproject commit 057b4e904fd754135dc19ff557c14036fd316425 +Subproject commit 12a5852e451baabc79c63a86c634912c563d57bc From 19fa256046d62ac06d6c4e9bf0980306ec8a6d03 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 17:48:48 +0000 Subject: [PATCH 12/24] Update dependencies from https://github.com/dotnet/runtime build 20230414.1 (#47711) [main] Update dependencies from dotnet/runtime --- eng/Version.Details.xml | 248 ++++++++++++++++++++-------------------- eng/Versions.props | 124 ++++++++++---------- 2 files changed, 186 insertions(+), 186 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index d70bb9bc0a8e..c4a4ab30edce 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -41,258 +41,258 @@ https://github.com/dotnet/efcore 596dcd6d7b7a67082fca25e6a832d252547ffa51 - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a https://github.com/dotnet/source-build-externals 33edde07d61cf7606d76ada765335fb81f1cbb71 - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a https://github.com/dotnet/xdt @@ -302,9 +302,9 @@ - + https://github.com/dotnet/runtime - 42c6bcf2076a52d256afcab5080caddb1fbeea98 + 2d9cb2d33f6f89f0a1a01782f2e474051bb2894a https://github.com/dotnet/arcade diff --git a/eng/Versions.props b/eng/Versions.props index 32f7aeddf9a2..3053c2a9836d 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -63,69 +63,69 @@ --> - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 - 8.0.0-preview.4.23213.5 - 8.0.0-preview.4.23213.5 + 8.0.0-preview.4.23214.1 + 8.0.0-preview.4.23214.1 8.0.0-preview.4.23212.3 8.0.0-preview.4.23212.3 From 11a53b37a1b8e0c8bb522095f38017fcbceccc60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 18:39:03 +0000 Subject: [PATCH 13/24] [main] (deps): Bump src/submodules/spa-templates (#47706) Bumps [src/submodules/spa-templates](https://github.com/dotnet/spa-templates) from `fed4822` to `3c41fe2`. - [Release notes](https://github.com/dotnet/spa-templates/releases) - [Commits](https://github.com/dotnet/spa-templates/compare/fed4822a72e3332672c2b43f4b635f78f5a81c2d...3c41fe26f7c51fc85fbc97b92871765467dd533c) --- updated-dependencies: - dependency-name: src/submodules/spa-templates dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/submodules/spa-templates | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/submodules/spa-templates b/src/submodules/spa-templates index fed4822a72e3..3c41fe26f7c5 160000 --- a/src/submodules/spa-templates +++ b/src/submodules/spa-templates @@ -1 +1 @@ -Subproject commit fed4822a72e3332672c2b43f4b635f78f5a81c2d +Subproject commit 3c41fe26f7c51fc85fbc97b92871765467dd533c From fdd5c923fa8bb0a2ff97400287fa8a5a26ab9220 Mon Sep 17 00:00:00 2001 From: Matt Mitchell Date: Fri, 14 Apr 2023 11:40:58 -0700 Subject: [PATCH 14/24] Update CSS parser commit (#47712) Match up with what will be built in source-build. Includes https://github.com/dotnet/cssparser/compare/d6d86bcd8c162b1ae22ef00955ff748d028dd0ee...0d59611784841735a7778a67aa6e9d8d000c861f --- eng/Versions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Versions.props b/eng/Versions.props index 3053c2a9836d..5259eaf10fc4 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -219,7 +219,7 @@ 3.3.3 1.1.2-beta1.22531.1 1.1.2-beta1.22531.1 - 1.0.0-20200708.1 + 1.0.0-20230414.1 6.15.1 6.15.1 6.15.1 From 66731e272977f405993522388520578cc0c8ab3a Mon Sep 17 00:00:00 2001 From: Sven Boemer Date: Fri, 14 Apr 2023 14:15:19 -0700 Subject: [PATCH 15/24] Remove dependencies that exist in base image (#47696) --- eng/docker/rhel.Dockerfile | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/eng/docker/rhel.Dockerfile b/eng/docker/rhel.Dockerfile index bd0ffa4bff70..870a69d33b1c 100644 --- a/eng/docker/rhel.Dockerfile +++ b/eng/docker/rhel.Dockerfile @@ -1,16 +1,6 @@ # Dockerfile that creates a container suitable to build dotnet-cli FROM mcr.microsoft.com/dotnet-buildtools/prereqs:cbl-mariner-2.0-fpm -RUN tdnf update -y && \ - tdnf install -y \ - tar \ - ca-certificates \ - icu \ - awk \ - # Provides useradd, needed below - shadow-utils \ - rpm-build - # Setup User to match Host User, and give superuser permissions ARG USER ARG USER_ID From 8968058c9e5fdfdd1242426a03dc80609997edab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Apr 2023 23:27:49 +0000 Subject: [PATCH 16/24] Sync shared code from runtime (#47684) --- ...AspNetCore.Server.HttpSys.FunctionalTests.csproj | 2 +- .../IIS.FunctionalTests/IIS.FunctionalTests.csproj | 2 +- .../IIS/IIS/test/IIS.LongTests/IIS.LongTests.csproj | 2 +- .../IIS.NewHandler.FunctionalTests.csproj | 2 +- .../IIS.NewShim.FunctionalTests.csproj | 2 +- .../IISExpress.FunctionalTests.csproj | 2 +- .../Kestrel/samples/http2cat/http2cat.csproj | 2 +- src/Shared/Hpack/Obsoletions.cs | 13 +++++++++++++ src/Shared/runtime/Obsoletions.cs | 13 ------------- .../Microsoft.AspNetCore.Shared.Tests.csproj | 1 + 10 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 src/Shared/Hpack/Obsoletions.cs delete mode 100644 src/Shared/runtime/Obsoletions.cs diff --git a/src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj b/src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj index 27d3add58dd3..08276e6a23fd 100644 --- a/src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj +++ b/src/Servers/HttpSys/test/FunctionalTests/Microsoft.AspNetCore.Server.HttpSys.FunctionalTests.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Servers/IIS/IIS/test/IIS.FunctionalTests/IIS.FunctionalTests.csproj b/src/Servers/IIS/IIS/test/IIS.FunctionalTests/IIS.FunctionalTests.csproj index 159eb0359e21..8ed1c6634ec8 100644 --- a/src/Servers/IIS/IIS/test/IIS.FunctionalTests/IIS.FunctionalTests.csproj +++ b/src/Servers/IIS/IIS/test/IIS.FunctionalTests/IIS.FunctionalTests.csproj @@ -29,7 +29,7 @@ - + diff --git a/src/Servers/IIS/IIS/test/IIS.LongTests/IIS.LongTests.csproj b/src/Servers/IIS/IIS/test/IIS.LongTests/IIS.LongTests.csproj index 5ab3cdd0b60b..a81c3fec2029 100644 --- a/src/Servers/IIS/IIS/test/IIS.LongTests/IIS.LongTests.csproj +++ b/src/Servers/IIS/IIS/test/IIS.LongTests/IIS.LongTests.csproj @@ -25,7 +25,7 @@ - + diff --git a/src/Servers/IIS/IIS/test/IIS.NewHandler.FunctionalTests/IIS.NewHandler.FunctionalTests.csproj b/src/Servers/IIS/IIS/test/IIS.NewHandler.FunctionalTests/IIS.NewHandler.FunctionalTests.csproj index ecca2b77471c..e38af72c179b 100644 --- a/src/Servers/IIS/IIS/test/IIS.NewHandler.FunctionalTests/IIS.NewHandler.FunctionalTests.csproj +++ b/src/Servers/IIS/IIS/test/IIS.NewHandler.FunctionalTests/IIS.NewHandler.FunctionalTests.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Servers/IIS/IIS/test/IIS.NewShim.FunctionalTests/IIS.NewShim.FunctionalTests.csproj b/src/Servers/IIS/IIS/test/IIS.NewShim.FunctionalTests/IIS.NewShim.FunctionalTests.csproj index cf87933172da..40e30287b319 100644 --- a/src/Servers/IIS/IIS/test/IIS.NewShim.FunctionalTests/IIS.NewShim.FunctionalTests.csproj +++ b/src/Servers/IIS/IIS/test/IIS.NewShim.FunctionalTests/IIS.NewShim.FunctionalTests.csproj @@ -28,7 +28,7 @@ - + diff --git a/src/Servers/IIS/IIS/test/IISExpress.FunctionalTests/IISExpress.FunctionalTests.csproj b/src/Servers/IIS/IIS/test/IISExpress.FunctionalTests/IISExpress.FunctionalTests.csproj index 91a683979153..15ad8db11a2f 100644 --- a/src/Servers/IIS/IIS/test/IISExpress.FunctionalTests/IISExpress.FunctionalTests.csproj +++ b/src/Servers/IIS/IIS/test/IISExpress.FunctionalTests/IISExpress.FunctionalTests.csproj @@ -30,7 +30,7 @@ - + diff --git a/src/Servers/Kestrel/samples/http2cat/http2cat.csproj b/src/Servers/Kestrel/samples/http2cat/http2cat.csproj index 2b7b045beebd..ba9b19a9e2e2 100644 --- a/src/Servers/Kestrel/samples/http2cat/http2cat.csproj +++ b/src/Servers/Kestrel/samples/http2cat/http2cat.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Shared/Hpack/Obsoletions.cs b/src/Shared/Hpack/Obsoletions.cs new file mode 100644 index 000000000000..6543bcabbb2c --- /dev/null +++ b/src/Shared/Hpack/Obsoletions.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System; + +// Copied from runtime, referenced by shared code. +internal static class Obsoletions +{ + internal const string SharedUrlFormat = "https://aka.ms/dotnet-warnings/{0}"; + + internal const string LegacyFormatterImplMessage = "This API supports obsolete formatter-based serialization. It should not be called or extended by application code."; + internal const string LegacyFormatterImplDiagId = "SYSLIB0051"; +} diff --git a/src/Shared/runtime/Obsoletions.cs b/src/Shared/runtime/Obsoletions.cs deleted file mode 100644 index d51213ccd6e3..000000000000 --- a/src/Shared/runtime/Obsoletions.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace System -{ - internal static class Obsoletions - { - internal const string SharedUrlFormat = "https://aka.ms/dotnet-warnings/{0}"; - - internal const string LegacyFormatterImplMessage = "This API supports obsolete formatter-based serialization. It should not be called or extended by application code."; - internal const string LegacyFormatterImplDiagId = "SYSLIB0051"; - } -} diff --git a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj index 678f8f81516e..21babaf54745 100644 --- a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj +++ b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj @@ -16,6 +16,7 @@ + From 4038965d69c30f0977e112ba312c68627437ea37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Rylek?= Date: Tue, 18 Apr 2023 00:43:41 +0200 Subject: [PATCH 17/24] Workaround for ASP.NET composite image per #84860 (#47747) In our work on enabling composite Docker containers on Linux we hit a problem caused by the fact that the SDK uses and publishes its own version of the managed assembly Microsoft.Extensions.Logging.Abstractions clashing with the version of the assembly in the composite image. As we're doing our best for publishing the Docker container in Preview 4, I propose merging in this simple workaround as a stopgap change; I have created a tracking issues for fixing this cleanly by fixing SDK to start using the version of the assembly from the aspnetcore repo instead of using its own copy. Thanks Tomas --- .../src/Microsoft.AspNetCore.App.Runtime.csproj | 8 ++++++++ .../App.Runtime/src/PartialCompositeAssemblyList.txt | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Framework/App.Runtime/src/Microsoft.AspNetCore.App.Runtime.csproj b/src/Framework/App.Runtime/src/Microsoft.AspNetCore.App.Runtime.csproj index 518f07ee7811..bfe21a37fca2 100644 --- a/src/Framework/App.Runtime/src/Microsoft.AspNetCore.App.Runtime.csproj +++ b/src/Framework/App.Runtime/src/Microsoft.AspNetCore.App.Runtime.csproj @@ -503,13 +503,20 @@ This package is an internal implementation of the .NET Core SDK and is not meant + + + + + + + @@ -517,6 +524,7 @@ This package is an internal implementation of the .NET Core SDK and is not meant + diff --git a/src/Framework/App.Runtime/src/PartialCompositeAssemblyList.txt b/src/Framework/App.Runtime/src/PartialCompositeAssemblyList.txt index e40f15c28ccf..200849406e54 100644 --- a/src/Framework/App.Runtime/src/PartialCompositeAssemblyList.txt +++ b/src/Framework/App.Runtime/src/PartialCompositeAssemblyList.txt @@ -30,7 +30,6 @@ Microsoft.AspNetCore.Server.Kestrel Microsoft.AspNetCore.Server.Kestrel.Transport.Quic System.Net.Quic System.Diagnostics.Tracing -Microsoft.Extensions.Logging.Abstractions Microsoft.Extensions.Features Microsoft.AspNetCore.Http.Abstractions Microsoft.AspNetCore.Http From 0e4f4bac9da46cbee904a3a2c18dc4c3ac37afc0 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 17 Apr 2023 18:18:12 -0700 Subject: [PATCH 18/24] Add support for logging/throwing exceptions in RDG (#47657) * Add support for logging/throwing exceptions in RDG * Fix event names, structured logs, and excessive allocation * Remove unused vars, fix allocation, and update logger * Use LogOrThrowExceptionHelper class --- ...tCore.Http.RequestDelegateGenerator.csproj | 1 + .../gen/RequestDelegateGenerator.cs | 5 + .../gen/RequestDelegateGeneratorSources.cs | 179 +++- .../Emitters/EmitterContext.cs | 1 + .../Emitters/EmitterExtensions.cs | 32 + .../Emitters/EndpointEmitter.cs | 15 +- .../Emitters/EndpointJsonResponseEmitter.cs | 3 +- .../Emitters/EndpointParameterEmitter.cs | 18 +- .../gen/StaticRouteHandlerModel/Endpoint.cs | 4 + .../EndpointParameter.cs | 11 +- ...icrosoft.AspNetCore.Http.Extensions.csproj | 5 +- .../src/RequestDelegateFactory.cs | 51 +- .../test/RequestDelegateFactoryTests.cs | 851 +----------------- ...MapAction_BindAsync_Snapshot.generated.txt | 198 ++++ ...Param_ComplexReturn_Snapshot.generated.txt | 180 +++- ...Header_ComplexTypeArrayParam.generated.txt | 157 +++- ...der_NullableStringArrayParam.generated.txt | 145 +++ ...licitHeader_StringArrayParam.generated.txt | 145 +++ ...tQuery_ComplexTypeArrayParam.generated.txt | 157 +++- ...ery_NullableStringArrayParam.generated.txt | 144 +++ ...plicitQuery_StringArrayParam.generated.txt | 144 +++ ...eParam_SimpleReturn_Snapshot.generated.txt | 150 +++ ...Source_SimpleReturn_Snapshot.generated.txt | 164 ++++ ...tQuery_ComplexTypeArrayParam.generated.txt | 159 +++- ...ery_NullableStringArrayParam.generated.txt | 144 +++ ...gArrayParam_EmptyQueryValues.generated.txt | 144 +++ ...ngArrayParam_QueryNotPresent.generated.txt | 144 +++ ...plicitQuery_StringArrayParam.generated.txt | 144 +++ ...ce_HandlesBothJsonAndService.generated.txt | 180 +++- ...pecialTypeParam_StringReturn.generated.txt | 144 +++ ...ipleStringParam_StringReturn.generated.txt | 150 +++ ...aram_StringReturn_WithFilter.generated.txt | 144 +++ ...omplexTypeParam_StringReturn.generated.txt | 160 +++- ...SingleEnumParam_StringReturn.generated.txt | 160 +++- ...ngValueProvided_StringReturn.generated.txt | 146 +++ ...String_AndBody_ShouldUseBody.generated.txt | 178 +++- ...hArrayQueryString_ShouldFail.generated.txt | 177 +++- ...pAction_NoParam_StringReturn.generated.txt | 144 +++ ...tion_WithParams_StringReturn.generated.txt | 144 +++ .../RequestDelegateCreationTestBase.cs | 2 +- .../RequestDelegateCreationTests.BindAsync.cs | 9 + .../RequestDelegateCreationTests.JsonBody.cs | 49 + ...DelegateCreationTests.JsonBodyOrService.cs | 53 +- .../RequestDelegateCreationTests.Logging.cs | 646 +++++++++++++ ...stDelegateCreationTests.QueryParameters.cs | 102 ++- .../RuntimeCreationTests.cs | 119 +++ .../RequestDelegateGenerator/SharedTypes.cs | 67 +- src/Shared/RequestDelegateCreationMessages.cs | 46 + src/Shared/RoslynUtils/SymbolExtensions.cs | 8 + 49 files changed, 5341 insertions(+), 982 deletions(-) create mode 100644 src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterExtensions.cs create mode 100644 src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Logging.cs create mode 100644 src/Shared/RequestDelegateCreationMessages.cs diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj index 81b69189f424..c723bd1a4d71 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs b/src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs index 6d053ecc9d8c..aef3df320120 100644 --- a/src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs +++ b/src/Http/Http.Extensions/gen/RequestDelegateGenerator.cs @@ -85,6 +85,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) codeWriter.StartBlock(); codeWriter.WriteLine($"var handler = ({endpoint!.EmitHandlerDelegateType(considerOptionality: true)})del;"); codeWriter.WriteLine("EndpointFilterDelegate? filteredInvocation = null;"); + if (endpoint!.EmitterContext.RequiresLoggingHelper || endpoint!.EmitterContext.HasJsonBodyOrService || endpoint!.Response?.IsSerializableJsonResponse(out var _) is true) + { + codeWriter.WriteLine("var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices;"); + } + endpoint!.EmitLoggingPreamble(codeWriter); endpoint!.EmitRouteOrQueryResolver(codeWriter); endpoint!.EmitJsonBodyOrServiceResolver(codeWriter); endpoint!.Response?.EmitJsonPreparation(codeWriter); diff --git a/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs b/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs index ac0ee789c232..423147e474a1 100644 --- a/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs +++ b/src/Http/Http.Extensions/gen/RequestDelegateGeneratorSources.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis.CSharp; namespace Microsoft.AspNetCore.Http.RequestDelegateGenerator; internal static class RequestDelegateGeneratorSources @@ -19,8 +20,8 @@ internal static class RequestDelegateGeneratorSources public static string GeneratedCodeAttribute => $@"[System.CodeDom.Compiler.GeneratedCodeAttribute(""{typeof(RequestDelegateGeneratorSources).Assembly.FullName}"", ""{typeof(RequestDelegateGeneratorSources).Assembly.GetName().Version}"")]"; - public static string TryResolveBodyAsyncMethod => """ - private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, bool allowEmpty) + public static string TryResolveBodyAsyncMethod => $$""" + private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, bool allowEmpty, string parameterTypeName, string parameterName, bool isInferred = false) { var feature = httpContext.Features.Get(); @@ -28,6 +29,7 @@ internal static class RequestDelegateGeneratorSources { if (!httpContext.Request.HasJsonContentType()) { + logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return (false, default); } @@ -36,17 +38,34 @@ internal static class RequestDelegateGeneratorSources var bodyValue = await httpContext.Request.ReadFromJsonAsync(); if (!allowEmpty && bodyValue == null) { + if (!isInferred) + { + logOrThrowExceptionHelper.RequiredParameterNotProvided(parameterTypeName, parameterName, "body"); + } + else + { + logOrThrowExceptionHelper.ImplicitBodyNotProvided(parameterName); + } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, bodyValue); } return (true, bodyValue); } - catch (IOException) + catch (BadHttpRequestException badHttpRequestException) { + logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); + httpContext.Response.StatusCode = badHttpRequestException.StatusCode; return (false, default); } - catch (System.Text.Json.JsonException) + catch (IOException ioException) { + logOrThrowExceptionHelper.RequestBodyIOException(ioException); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (false, default); + } + catch (System.Text.Json.JsonException jsonException) + { + logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, default); } @@ -96,7 +115,7 @@ private static Task WriteToResponseAsync(T? value, HttpContext httpContext, J """; public static string ResolveJsonBodyOrServiceMethod => """ - private static Func> ResolveJsonBodyOrService(IServiceProviderIsService? serviceProviderIsService = null) + private static Func> ResolveJsonBodyOrService(LogOrThrowExceptionHelper logOrThrowExceptionHelper, string parameterTypeName, string parameterName, IServiceProviderIsService? serviceProviderIsService = null) { if (serviceProviderIsService is not null) { @@ -105,10 +124,155 @@ private static Task WriteToResponseAsync(T? value, HttpContext httpContext, J return static (httpContext, isOptional) => new ValueTask<(bool, T?)>((true, httpContext.RequestServices.GetService())); } } - return static (httpContext, isOptional) => TryResolveBodyAsync(httpContext, isOptional); + return (httpContext, isOptional) => TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, isOptional, parameterTypeName, parameterName, isInferred: true); } """; + public static string LogOrThrowExceptionHelperClass => $$""" + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("{{typeof(RequestDelegateGenerator)}}"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.RequestBodyIOExceptionEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.RequestBodyIOExceptionEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.RequestBodyIOExceptionMessage, true)}}); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidJsonRequestBodyExceptionMessage, true)}}, parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.InvalidJsonRequestBodyEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidJsonRequestBodyEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidJsonRequestBodyLogMessage, true)}}); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ParameterBindingFailedExceptionMessage, true)}}, parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.ParameterBindingFailedEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ParameterBindingFailedEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ParameterBindingFailedLogMessage, true)}}); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.RequiredParameterNotProvidedExceptionMessage, true)}}, parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.RequiredParameterNotProvidedEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.RequiredParameterNotProvidedEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.RequiredParameterNotProvidedLogMessage, true)}}); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ImplicitBodyNotProvidedExceptionMessage, true)}}, parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.ImplicitBodyNotProvidedEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ImplicitBodyNotProvidedEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.ImplicitBodyNotProvidedLogMessage, true)}}); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedJsonContentTypeExceptionMessage, true)}}, contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.UnexpectedJsonContentTypeEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedJsonContentTypeEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedJsonContentTypeLogMessage, true)}}); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedFormContentTypeExceptionMessage, true)}}, contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.UnexpectedFormContentTypeEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedFormContentTypeLogEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.UnexpectedFormContentTypeLogMessage, true)}}); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidFormRequestBodyExceptionMessage, true)}}, parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId({{RequestDelegateCreationLogging.InvalidFormRequestBodyEventId}}, {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidFormRequestBodyEventName, true)}}), {{SymbolDisplay.FormatLiteral(RequestDelegateCreationLogging.InvalidFormRequestBodyLogMessage, true)}}); + } +"""; + public static string GetGeneratedRouteBuilderExtensionsSource(string genericThunks, string thunks, string endpoints, string helperMethods) => $$""" {{SourceHeader}} @@ -153,6 +317,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -199,6 +364,8 @@ private static Task ExecuteObjectResult(object? obj, HttpContext httpContext) {{helperMethods}} } + +{{LogOrThrowExceptionHelperClass}} } """; private static string GetGenericThunks(string genericThunks) => genericThunks != string.Empty ? $$""" diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterContext.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterContext.cs index f7868e6a8968..40bcda9e675f 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterContext.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterContext.cs @@ -10,4 +10,5 @@ internal sealed class EmitterContext public bool HasBindAsync { get; set; } public bool HasParsable { get; set; } public bool HasJsonResponse { get; set; } + public bool RequiresLoggingHelper { get; set; } } diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterExtensions.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterExtensions.cs new file mode 100644 index 000000000000..f6feb5428dff --- /dev/null +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EmitterExtensions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; + +internal static class EmitterExtensions +{ + public static string ToMessageString(this EndpointParameter endpointParameter) => endpointParameter.Source switch + { + EndpointParameterSource.Header => "header", + EndpointParameterSource.Query => "query string", + EndpointParameterSource.RouteOrQuery => "route or query string", + EndpointParameterSource.BindAsync => endpointParameter.BindMethod == BindabilityMethod.BindAsync + ? $"{endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)}.BindAsync(HttpContext)" + : $"{endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)}.BindAsync(HttpContext, ParameterInfo)", + _ => "unknown" + }; + + public static bool IsSerializableJsonResponse(this EndpointResponse endpointResponse, [NotNullWhen(true)] out ITypeSymbol? responseTypeSymbol) + { + responseTypeSymbol = null; + if (endpointResponse is { IsSerializable: true, ResponseType: { } responseType }) + { + responseTypeSymbol = responseType; + return true; + } + return false; + } +} diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointEmitter.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointEmitter.cs index e77ac23dd257..3afd53339090 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointEmitter.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointEmitter.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using System.Globalization; using System.IO; using System.Linq; @@ -70,14 +72,23 @@ public static void EmitJsonBodyOrServiceResolver(this Endpoint endpoint, CodeWri { if (!serviceProviderEmitted) { - codeWriter.WriteLine("var serviceProviderIsService = options?.ServiceProvider?.GetService();"); + codeWriter.WriteLine("var serviceProviderIsService = serviceProvider?.GetService();"); serviceProviderEmitted = true; } codeWriter.Write($@"var {parameter.SymbolName}_JsonBodyOrServiceResolver = "); - codeWriter.WriteLine($"ResolveJsonBodyOrService<{parameter.Type.ToDisplayString(EmitterConstants.DisplayFormat)}>(serviceProviderIsService);"); + var shortParameterTypeName = parameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + codeWriter.WriteLine($"ResolveJsonBodyOrService<{parameter.Type.ToDisplayString(EmitterConstants.DisplayFormat)}>(logOrThrowExceptionHelper, {SymbolDisplay.FormatLiteral(shortParameterTypeName, true)}, {SymbolDisplay.FormatLiteral(parameter.SymbolName, true)}, serviceProviderIsService);"); } } } + public static void EmitLoggingPreamble(this Endpoint endpoint, CodeWriter codeWriter) + { + if (endpoint.EmitterContext.RequiresLoggingHelper) + { + codeWriter.WriteLine("var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options);"); + } + } + public static string EmitArgumentList(this Endpoint endpoint) => string.Join(", ", endpoint.Parameters.Select(p => p.EmitArgument())); } diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs index 8a6e31cfd84d..826c6c268230 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointJsonResponseEmitter.cs @@ -7,11 +7,10 @@ internal static class EndpointJsonResponseEmitter { internal static void EmitJsonPreparation(this EndpointResponse endpointResponse, CodeWriter codeWriter) { - if (endpointResponse is { IsSerializable: true, ResponseType: {} responseType }) + if (endpointResponse.IsSerializableJsonResponse(out var responseType)) { var typeName = responseType.ToDisplayString(EmitterConstants.DisplayFormat); - codeWriter.WriteLine("var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices;"); codeWriter.WriteLine("var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions;"); codeWriter.WriteLine($"var jsonTypeInfo = (JsonTypeInfo<{typeName}>)serializerOptions.GetTypeInfo(typeof({typeName}));"); } diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs index 621899fb611c..d279fc3eaa4d 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs @@ -2,10 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Reflection.Metadata; +using System.Globalization; using Microsoft.AspNetCore.Analyzers.Infrastructure; using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel.Emitters; @@ -33,7 +34,7 @@ internal static void EmitQueryOrHeaderParameterPreparation(this EndpointParamete } else if (endpointParameter.IsOptional) { - codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = {endpointParameter.EmitAssigningCodeResult()}.Count > 0 ? (string?){endpointParameter.EmitAssigningCodeResult()} : null;"); + codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = {endpointParameter.EmitAssigningCodeResult()}.Count > 0 ? (string?){endpointParameter.EmitAssigningCodeResult()} : {endpointParameter.DefaultValue};"); } else if (endpointParameter.IsStringValues) { @@ -44,6 +45,7 @@ internal static void EmitQueryOrHeaderParameterPreparation(this EndpointParamete codeWriter.WriteLine($"if (StringValues.IsNullOrEmpty({endpointParameter.EmitAssigningCodeResult()}))"); codeWriter.StartBlock(); codeWriter.WriteLine("wasParamCheckFailure = true;"); + codeWriter.WriteLine($@"logOrThrowExceptionHelper.RequiredParameterNotProvided({SymbolDisplay.FormatLiteral(endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(endpointParameter.SymbolName, true)}, {SymbolDisplay.FormatLiteral(endpointParameter.ToMessageString(), true)});"); codeWriter.EndBlock(); codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = (string?){endpointParameter.EmitAssigningCodeResult()};"); } @@ -107,6 +109,7 @@ internal static void EmitRouteParameterPreparation(this EndpointParameter endpoi codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} == null)"); codeWriter.StartBlock(); codeWriter.WriteLine("wasParamCheckFailure = true;"); + codeWriter.WriteLine($@"logOrThrowExceptionHelper.RequiredParameterNotProvided({SymbolDisplay.FormatLiteral(endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(endpointParameter.SymbolName, true)}, {SymbolDisplay.FormatLiteral(endpointParameter.ToMessageString(), true)});"); codeWriter.EndBlock(); } @@ -127,13 +130,17 @@ internal static void EmitRouteOrQueryParameterPreparation(this EndpointParameter } else if (endpointParameter.IsOptional) { - codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = {endpointParameter.EmitAssigningCodeResult()}.Count > 0 ? (string?){endpointParameter.EmitAssigningCodeResult()} : null;"); + // For non-string parameters, the TryParse logic takes care of setting the default value fallback. + // Strings don't undergo the TryParse treatment so we set the default value here. + var fallback = endpointParameter.Type.SpecialType == SpecialType.System_String ? endpointParameter.DefaultValue : "null"; + codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = {endpointParameter.EmitAssigningCodeResult()}.Count > 0 ? (string?){endpointParameter.EmitAssigningCodeResult()} : {fallback};"); } else { codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} is StringValues {{ Count: 0 }})"); codeWriter.StartBlock(); codeWriter.WriteLine("wasParamCheckFailure = true;"); + codeWriter.WriteLine($@"logOrThrowExceptionHelper.RequiredParameterNotProvided({SymbolDisplay.FormatLiteral(endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(endpointParameter.SymbolName, true)}, {SymbolDisplay.FormatLiteral(endpointParameter.ToMessageString(), true)});"); codeWriter.EndBlock(); codeWriter.WriteLine($"var {endpointParameter.EmitTempArgument()} = (string?){endpointParameter.EmitAssigningCodeResult()};"); } @@ -148,7 +155,8 @@ internal static void EmitJsonBodyParameterPreparationString(this EndpointParamet // Invoke TryResolveBody method to parse JSON and set // status codes on exceptions. - var assigningCode = $"await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync<{endpointParameter.Type.ToDisplayString(EmitterConstants.DisplayFormat)}>(httpContext, {(endpointParameter.IsOptional ? "true" : "false")})"; + var shortParameterTypeName = endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat); + var assigningCode = $"await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync<{endpointParameter.Type.ToDisplayString(EmitterConstants.DisplayFormat)}>(httpContext, logOrThrowExceptionHelper, {(endpointParameter.IsOptional ? "true" : "false")}, {SymbolDisplay.FormatLiteral(shortParameterTypeName, true)}, {SymbolDisplay.FormatLiteral(endpointParameter.SymbolName, true)})"; var resolveBodyResult = $"{endpointParameter.SymbolName}_resolveBodyResult"; codeWriter.WriteLine($"var {resolveBodyResult} = {assigningCode};"); codeWriter.WriteLine($"var {endpointParameter.EmitHandlerArgument()} = {resolveBodyResult}.Item2;"); @@ -211,9 +219,9 @@ internal static void EmitBindAsyncPreparation(this EndpointParameter endpointPar else { codeWriter.WriteLine($"{unwrappedTypeString} {endpointParameter.EmitHandlerArgument()};"); - codeWriter.WriteLine($"if ((object?){endpointParameter.EmitTempArgument()} == null)"); codeWriter.StartBlock(); + codeWriter.WriteLine($@"logOrThrowExceptionHelper.RequiredParameterNotProvided({SymbolDisplay.FormatLiteral(endpointParameter.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(endpointParameter.SymbolName, true)}, {SymbolDisplay.FormatLiteral(endpointParameter.ToMessageString(), true)});"); codeWriter.WriteLine("wasParamCheckFailure = true;"); codeWriter.WriteLine($"{endpointParameter.EmitHandlerArgument()} = default!;"); codeWriter.EndBlock(); diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Endpoint.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Endpoint.cs index 74f93dbd3202..46bca271cd54 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Endpoint.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/Endpoint.cs @@ -48,6 +48,7 @@ public Endpoint(IInvocationOperation operation, WellKnownTypes wellKnownTypes, S if (method.Parameters.Length == 0) { + EmitterContext.RequiresLoggingHelper = false; return; } @@ -91,6 +92,9 @@ public Endpoint(IInvocationOperation operation, WellKnownTypes wellKnownTypes, S EmitterContext.HasRouteOrQuery = Parameters.Any(parameter => parameter.Source == EndpointParameterSource.RouteOrQuery); EmitterContext.HasBindAsync = Parameters.Any(parameter => parameter.Source == EndpointParameterSource.BindAsync); EmitterContext.HasParsable = Parameters.Any(parameter => parameter.IsParsable); + EmitterContext.RequiresLoggingHelper = !Parameters.All(parameter => + parameter.Source == EndpointParameterSource.SpecialType || + parameter is { IsArray: true, ElementType.SpecialType: SpecialType.System_String, Source: EndpointParameterSource.Query }); } public string HttpMethod { get; } diff --git a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/EndpointParameter.cs b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/EndpointParameter.cs index 0bc127f43d7b..6a6412a90605 100644 --- a/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/EndpointParameter.cs +++ b/src/Http/Http.Extensions/gen/StaticRouteHandlerModel/EndpointParameter.cs @@ -3,11 +3,13 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; using Microsoft.AspNetCore.Analyzers.Infrastructure; using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure; using Microsoft.AspNetCore.App.Analyzers.Infrastructure; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using WellKnownType = Microsoft.AspNetCore.App.Analyzers.Infrastructure.WellKnownTypeData.WellKnownType; namespace Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; @@ -22,6 +24,7 @@ public EndpointParameter(Endpoint endpoint, IParameterSymbol parameter, WellKnow Ordinal = parameter.Ordinal; Source = EndpointParameterSource.Unknown; IsOptional = parameter.IsOptional(); + DefaultValue = parameter.GetDefaultValueString(); IsArray = TryGetArrayElementType(parameter, out var elementType); ElementType = elementType; @@ -137,6 +140,7 @@ private static bool ShouldDisableInferredBodyParameters(string httpMethod) public int Ordinal { get; } public bool IsOptional { get; } public bool IsArray { get; set; } + public string DefaultValue { get; set; } public EndpointParameterSource Source { get; } @@ -236,7 +240,7 @@ private bool TryGetParsability(IParameterSymbol parameter, WellKnownTypes wellKn writer.EndBlock(); writer.WriteLine($$"""else if (string.IsNullOrEmpty({{inputArgument}}))"""); writer.StartBlock(); - writer.WriteLine($$"""{{outputArgument}} = null;"""); + writer.WriteLine($$"""{{outputArgument}} = {{DefaultValue}};"""); writer.EndBlock(); writer.WriteLine("else"); writer.StartBlock(); @@ -255,6 +259,7 @@ private bool TryGetParsability(IParameterSymbol parameter, WellKnownTypes wellKn writer.WriteLine($$"""if (!string.IsNullOrEmpty({{inputArgument}}))"""); writer.StartBlock(); writer.WriteLine("wasParamCheckFailure = true;"); + writer.WriteLine($@"logOrThrowExceptionHelper.RequiredParameterNotProvided({SymbolDisplay.FormatLiteral(Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(SymbolName, true)}, {SymbolDisplay.FormatLiteral(this.ToMessageString(), true)});"); writer.EndBlock(); writer.EndBlock(); } @@ -262,8 +267,12 @@ private bool TryGetParsability(IParameterSymbol parameter, WellKnownTypes wellKn { writer.WriteLine($$"""if (!{{preferredTryParseInvocation(inputArgument, outputArgument)}})"""); writer.StartBlock(); + writer.WriteLine($"if (!string.IsNullOrEmpty({inputArgument}))"); + writer.StartBlock(); + writer.WriteLine($@"logOrThrowExceptionHelper.ParameterBindingFailed({SymbolDisplay.FormatLiteral(Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), true)}, {SymbolDisplay.FormatLiteral(SymbolName, true)}, {inputArgument});"); writer.WriteLine("wasParamCheckFailure = true;"); writer.EndBlock(); + writer.EndBlock(); } }; } diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index b69cffc801cd..53fd4156e278 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -14,6 +14,7 @@ + @@ -25,8 +26,8 @@ - - + + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index f5ec91a7616e..7b24f3f42996 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -2359,131 +2359,110 @@ private static class RequestDelegateFactoryConstants private static partial class Log { - private const string InvalidJsonRequestBodyMessage = @"Failed to read parameter ""{ParameterType} {ParameterName}"" from the request body as JSON."; - private const string InvalidJsonRequestBodyExceptionMessage = @"Failed to read parameter ""{0} {1}"" from the request body as JSON."; - - private const string ParameterBindingFailedLogMessage = @"Failed to bind parameter ""{ParameterType} {ParameterName}"" from ""{SourceValue}""."; - private const string ParameterBindingFailedExceptionMessage = @"Failed to bind parameter ""{0} {1}"" from ""{2}""."; - - private const string RequiredParameterNotProvidedLogMessage = @"Required parameter ""{ParameterType} {ParameterName}"" was not provided from {Source}."; - private const string RequiredParameterNotProvidedExceptionMessage = @"Required parameter ""{0} {1}"" was not provided from {2}."; - - private const string UnexpectedJsonContentTypeLogMessage = @"Expected a supported JSON media type but got ""{ContentType}""."; - private const string UnexpectedJsonContentTypeExceptionMessage = @"Expected a supported JSON media type but got ""{0}""."; - - private const string ImplicitBodyNotProvidedLogMessage = @"Implicit body inferred for parameter ""{ParameterName}"" but no body was provided. Did you mean to use a Service instead?"; - private const string ImplicitBodyNotProvidedExceptionMessage = @"Implicit body inferred for parameter ""{0}"" but no body was provided. Did you mean to use a Service instead?"; - - private const string InvalidFormRequestBodyMessage = @"Failed to read parameter ""{ParameterType} {ParameterName}"" from the request body as form."; - private const string InvalidFormRequestBodyExceptionMessage = @"Failed to read parameter ""{0} {1}"" from the request body as form."; - - private const string UnexpectedFormContentTypeLogMessage = @"Expected a supported form media type but got ""{ContentType}""."; - private const string UnexpectedFormContentTypeExceptionMessage = @"Expected a supported form media type but got ""{0}""."; - // This doesn't take a shouldThrow parameter because an IOException indicates an aborted request rather than a "bad" request so // a BadHttpRequestException feels wrong. The client shouldn't be able to read the Developer Exception Page at any rate. public static void RequestBodyIOException(HttpContext httpContext, IOException exception) => RequestBodyIOException(GetLogger(httpContext), exception); - [LoggerMessage(1, LogLevel.Debug, "Reading the request body failed with an IOException.", EventName = "RequestBodyIOException")] + [LoggerMessage(RequestDelegateCreationLogging.RequestBodyIOExceptionEventId, LogLevel.Debug, RequestDelegateCreationLogging.RequestBodyIOExceptionMessage, EventName = RequestDelegateCreationLogging.RequestBodyIOExceptionEventName)] private static partial void RequestBodyIOException(ILogger logger, IOException exception); public static void InvalidJsonRequestBody(HttpContext httpContext, string parameterTypeName, string parameterName, Exception exception, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, InvalidJsonRequestBodyExceptionMessage, parameterTypeName, parameterName); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.InvalidJsonRequestBodyExceptionMessage, parameterTypeName, parameterName); throw new BadHttpRequestException(message, exception); } InvalidJsonRequestBody(GetLogger(httpContext), parameterTypeName, parameterName, exception); } - [LoggerMessage(2, LogLevel.Debug, InvalidJsonRequestBodyMessage, EventName = "InvalidJsonRequestBody")] + [LoggerMessage(RequestDelegateCreationLogging.InvalidJsonRequestBodyEventId, LogLevel.Debug, RequestDelegateCreationLogging.InvalidJsonRequestBodyLogMessage, EventName = RequestDelegateCreationLogging.InvalidJsonRequestBodyEventName)] private static partial void InvalidJsonRequestBody(ILogger logger, string parameterType, string parameterName, Exception exception); public static void ParameterBindingFailed(HttpContext httpContext, string parameterTypeName, string parameterName, string sourceValue, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, ParameterBindingFailedExceptionMessage, parameterTypeName, parameterName, sourceValue); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.ParameterBindingFailedExceptionMessage, parameterTypeName, parameterName, sourceValue); throw new BadHttpRequestException(message); } ParameterBindingFailed(GetLogger(httpContext), parameterTypeName, parameterName, sourceValue); } - [LoggerMessage(3, LogLevel.Debug, ParameterBindingFailedLogMessage, EventName = "ParameterBindingFailed")] + [LoggerMessage(RequestDelegateCreationLogging.ParameterBindingFailedEventId, LogLevel.Debug, RequestDelegateCreationLogging.ParameterBindingFailedLogMessage, EventName = RequestDelegateCreationLogging.ParameterBindingFailedEventName)] private static partial void ParameterBindingFailed(ILogger logger, string parameterType, string parameterName, string sourceValue); public static void RequiredParameterNotProvided(HttpContext httpContext, string parameterTypeName, string parameterName, string source, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, RequiredParameterNotProvidedExceptionMessage, parameterTypeName, parameterName, source); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.RequiredParameterNotProvidedExceptionMessage, parameterTypeName, parameterName, source); throw new BadHttpRequestException(message); } RequiredParameterNotProvided(GetLogger(httpContext), parameterTypeName, parameterName, source); } - [LoggerMessage(4, LogLevel.Debug, RequiredParameterNotProvidedLogMessage, EventName = "RequiredParameterNotProvided")] + [LoggerMessage(RequestDelegateCreationLogging.RequiredParameterNotProvidedEventId, LogLevel.Debug, RequestDelegateCreationLogging.RequiredParameterNotProvidedLogMessage, EventName = RequestDelegateCreationLogging.RequiredParameterNotProvidedEventName)] private static partial void RequiredParameterNotProvided(ILogger logger, string parameterType, string parameterName, string source); public static void ImplicitBodyNotProvided(HttpContext httpContext, string parameterName, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, ImplicitBodyNotProvidedExceptionMessage, parameterName); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.ImplicitBodyNotProvidedExceptionMessage, parameterName); throw new BadHttpRequestException(message); } ImplicitBodyNotProvided(GetLogger(httpContext), parameterName); } - [LoggerMessage(5, LogLevel.Debug, ImplicitBodyNotProvidedLogMessage, EventName = "ImplicitBodyNotProvided")] + [LoggerMessage(RequestDelegateCreationLogging.ImplicitBodyNotProvidedEventId, LogLevel.Debug, RequestDelegateCreationLogging.ImplicitBodyNotProvidedLogMessage, EventName = RequestDelegateCreationLogging.ImplicitBodyNotProvidedEventName)] private static partial void ImplicitBodyNotProvided(ILogger logger, string parameterName); public static void UnexpectedJsonContentType(HttpContext httpContext, string? contentType, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, UnexpectedJsonContentTypeExceptionMessage, contentType); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.UnexpectedJsonContentTypeExceptionMessage, contentType); throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); } UnexpectedJsonContentType(GetLogger(httpContext), contentType ?? "(none)"); } - [LoggerMessage(6, LogLevel.Debug, UnexpectedJsonContentTypeLogMessage, EventName = "UnexpectedContentType")] + [LoggerMessage(RequestDelegateCreationLogging.UnexpectedJsonContentTypeEventId, LogLevel.Debug, RequestDelegateCreationLogging.UnexpectedJsonContentTypeLogMessage, EventName = RequestDelegateCreationLogging.UnexpectedJsonContentTypeEventName)] private static partial void UnexpectedJsonContentType(ILogger logger, string contentType); public static void UnexpectedNonFormContentType(HttpContext httpContext, string? contentType, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, UnexpectedFormContentTypeExceptionMessage, contentType); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.UnexpectedFormContentTypeExceptionMessage, contentType); throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); } UnexpectedNonFormContentType(GetLogger(httpContext), contentType ?? "(none)"); } - [LoggerMessage(7, LogLevel.Debug, UnexpectedFormContentTypeLogMessage, EventName = "UnexpectedNonFormContentType")] + [LoggerMessage(RequestDelegateCreationLogging.UnexpectedFormContentTypeEventId, LogLevel.Debug, RequestDelegateCreationLogging.UnexpectedFormContentTypeLogMessage, EventName = RequestDelegateCreationLogging.UnexpectedFormContentTypeLogEventName)] private static partial void UnexpectedNonFormContentType(ILogger logger, string contentType); public static void InvalidFormRequestBody(HttpContext httpContext, string parameterTypeName, string parameterName, Exception exception, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, InvalidFormRequestBodyExceptionMessage, parameterTypeName, parameterName); + var message = string.Format(CultureInfo.InvariantCulture, RequestDelegateCreationLogging.InvalidFormRequestBodyExceptionMessage, parameterTypeName, parameterName); throw new BadHttpRequestException(message, exception); } InvalidFormRequestBody(GetLogger(httpContext), parameterTypeName, parameterName, exception); } - [LoggerMessage(8, LogLevel.Debug, InvalidFormRequestBodyMessage, EventName = "InvalidFormRequestBody")] + [LoggerMessage(RequestDelegateCreationLogging.InvalidFormRequestBodyEventId, LogLevel.Debug, RequestDelegateCreationLogging.InvalidFormRequestBodyLogMessage, EventName = RequestDelegateCreationLogging.InvalidFormRequestBodyEventName)] private static partial void InvalidFormRequestBody(ILogger logger, string parameterType, string parameterName, Exception exception); private static ILogger GetLogger(HttpContext httpContext) diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 91b776e88042..031dd3b8e956 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -768,48 +768,6 @@ public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStr Assert.Equal(new StringValues(new[] { "7", "8", "9" }), httpContext.Items["form"]!); } - [Fact] - public async Task RequestDelegateLogsStringValuesFromExplicitQueryStringSourceForUnpresentedValuesFailuresAsDebugAndSets400Response() - { - var invoked = false; - - var httpContext = CreateHttpContext(); - httpContext.Request.Form = new FormCollection(null); - - var factoryResult = RequestDelegateFactory.Create((HttpContext context, - [FromHeader(Name = "foo")] StringValues headerValues, - [FromQuery(Name = "bar")] StringValues queryValues, - [FromForm(Name = "form")] StringValues formValues) => - { - invoked = true; - }); - - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - var logs = TestSink.Writes.ToArray(); - - Assert.Equal(3, logs.Length); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); - Assert.Equal(LogLevel.Debug, logs[0].LogLevel); - Assert.Equal(@"Required parameter ""StringValues headerValues"" was not provided from header.", logs[0].Message); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); - Assert.Equal(LogLevel.Debug, logs[1].LogLevel); - Assert.Equal(@"Required parameter ""StringValues queryValues"" was not provided from query string.", logs[1].Message); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[2].EventId); - Assert.Equal(LogLevel.Debug, logs[2].LogLevel); - Assert.Equal(@"Required parameter ""StringValues formValues"" was not provided from form.", logs[2].Message); - } - [Fact] public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStringSourceForUnpresentedValues() { @@ -932,111 +890,6 @@ public void CreateThrowsInvalidOperationExceptionGivenUnnamedArgument() Assert.Equal("Encountered a parameter of type 'System.Runtime.CompilerServices.Closure' without a name. Parameters must have a name.", ex.Message); } - [Fact] - public async Task RequestDelegateLogsTryParsableFailuresAsDebugAndSets400Response() - { - var invoked = false; - - void TestAction([FromRoute] int tryParsable, [FromRoute] int tryParsable2) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.RouteValues["tryParsable"] = "invalid!"; - httpContext.Request.RouteValues["tryParsable2"] = "invalid again!"; - - var factoryResult = RequestDelegateFactory.Create(TestAction); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - var logs = TestSink.Writes.ToArray(); - - Assert.Equal(2, logs.Length); - - Assert.Equal(new EventId(3, "ParameterBindingFailed"), logs[0].EventId); - Assert.Equal(LogLevel.Debug, logs[0].LogLevel); - Assert.Equal(@"Failed to bind parameter ""int tryParsable"" from ""invalid!"".", logs[0].Message); - - Assert.Equal(new EventId(3, "ParameterBindingFailed"), logs[1].EventId); - Assert.Equal(LogLevel.Debug, logs[1].LogLevel); - Assert.Equal(@"Failed to bind parameter ""int tryParsable2"" from ""invalid again!"".", logs[1].Message); - } - - [Fact] - public async Task RequestDelegateThrowsForTryParsableFailuresIfThrowOnBadRequest() - { - var invoked = false; - - void TestAction([FromRoute] int tryParsable, [FromRoute] int tryParsable2) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.RouteValues["tryParsable"] = "invalid!"; - httpContext.Request.RouteValues["tryParsable2"] = "invalid again!"; - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true }); - var requestDelegate = factoryResult.RequestDelegate; - - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Failed to bind parameter ""int tryParsable"" from ""invalid!"".", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - } - - [Fact] - public async Task RequestDelegateThrowsForTryParsableFailuresIfThrowOnBadRequestWithArrays() - { - var invoked = false; - - void TestAction([FromQuery] int[] values) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.Query = new QueryCollection(new Dictionary() - { - ["values"] = new(new[] { "1", "NAN", "3" }) - }); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true, DisableInferBodyFromParameters = true }); - var requestDelegate = factoryResult.RequestDelegate; - - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Failed to bind parameter ""int[] values"" from ""NAN"".", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - } - [Fact] public async Task RequestDelegateThrowsForTryParsableFailuresIfThrowOnBadRequestWithNonOptionalArrays() { @@ -1072,130 +925,6 @@ void StoreNullableIntArray(HttpContext httpContext, int?[] values) Assert.Equal(400, badHttpRequestException.StatusCode); } - [Fact] - public async Task RequestDelegateLogsBindAsyncFailuresAndSets400Response() - { - // Not supplying any headers will cause the HttpContext BindAsync overload to return null. - var httpContext = CreateHttpContext(); - var invoked = false; - - var factoryResult = RequestDelegateFactory.Create((MyBindAsyncRecord myBindAsyncRecord1, MyBindAsyncRecord myBindAsyncRecord2) => - { - invoked = true; - }); - - var requestDelegate = factoryResult.RequestDelegate; - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - - var logs = TestSink.Writes.ToArray(); - - Assert.Equal(2, logs.Length); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); - Assert.Equal(LogLevel.Debug, logs[0].LogLevel); - Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncRecord1"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", logs[0].Message); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); - Assert.Equal(LogLevel.Debug, logs[1].LogLevel); - Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncRecord2"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", logs[1].Message); - } - - [Fact] - public async Task RequestDelegateThrowsForBindAsyncFailuresIfThrowOnBadRequest() - { - // Not supplying any headers will cause the HttpContext BindAsync overload to return null. - var httpContext = CreateHttpContext(); - var invoked = false; - - var factoryResult = RequestDelegateFactory.Create((MyBindAsyncRecord myBindAsyncRecord1, MyBindAsyncRecord myBindAsyncRecord2) => - { - invoked = true; - }, new() { ThrowOnBadRequest = true }); - - var requestDelegate = factoryResult.RequestDelegate; - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncRecord1"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - } - - [Fact] - public async Task RequestDelegateLogsSingleArgBindAsyncFailuresAndSets400Response() - { - // Not supplying any headers will cause the HttpContext BindAsync overload to return null. - var httpContext = CreateHttpContext(); - var invoked = false; - - var factoryResult = RequestDelegateFactory.Create((MySimpleBindAsyncRecord mySimpleBindAsyncRecord1, - MySimpleBindAsyncRecord mySimpleBindAsyncRecord2) => - { - invoked = true; - }); - - var requestDelegate = factoryResult.RequestDelegate; - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - - var logs = TestSink.Writes.ToArray(); - - Assert.Equal(2, logs.Length); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); - Assert.Equal(LogLevel.Debug, logs[0].LogLevel); - Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord1"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", logs[0].Message); - - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); - Assert.Equal(LogLevel.Debug, logs[1].LogLevel); - Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord2"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", logs[1].Message); - } - - [Fact] - public async Task RequestDelegateThrowsForSingleArgBindAsyncFailuresIfThrowOnBadRequest() - { - // Not supplying any headers will cause the HttpContext BindAsync overload to return null. - var httpContext = CreateHttpContext(); - var invoked = false; - - var factoryResult = RequestDelegateFactory.Create((MySimpleBindAsyncRecord mySimpleBindAsyncRecord1, - MySimpleBindAsyncRecord mySimpleBindAsyncRecord2) => - { - invoked = true; - }, new() { ThrowOnBadRequest = true }); - - var requestDelegate = factoryResult.RequestDelegate; - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord1"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - } - [Fact] public async Task BindAsyncWithBodyArgument() { @@ -1537,200 +1266,23 @@ public RefStruct(ReadOnlySpan buffer) } } - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RequestDelegateLogsIOExceptionsAsDebugDoesNotAbortAndNeverThrows(bool throwOnBadRequests) + [Fact] + public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenFromBodyOnMultipleParameters() { - var invoked = false; - - void TestAction([FromBody] Todo todo) - { - invoked = true; - } - - var ioException = new IOException(); - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(ioException); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = throwOnBadRequests }); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); + void TestAttributedInvalidAction([FromBody] int value1, [FromBody] int value2) { } + void TestInferredInvalidAction(Todo value1, Todo value2) { } + void TestBothInvalidAction(Todo value1, [FromBody] int value2) { } - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal("Reading the request body failed with an IOException.", logMessage.Message); - Assert.Same(ioException, logMessage.Exception); + Assert.Throws(() => RequestDelegateFactory.Create(TestAttributedInvalidAction)); + Assert.Throws(() => RequestDelegateFactory.Create(TestInferredInvalidAction)); + Assert.Throws(() => RequestDelegateFactory.Create(TestBothInvalidAction)); } [Fact] - public async Task RequestDelegateLogsJsonExceptionsAsDebugAndSets400Response() + public void BuildRequestDelegateThrowsInvalidOperationExceptionForInvalidTryParse() { - var invoked = false; - - void TestAction([FromBody] Todo todo) - { - invoked = true; - } - - var jsonException = new JsonException(); - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(jsonException); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(2, "InvalidJsonRequestBody"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", logMessage.Message); - Assert.Same(jsonException, logMessage.Exception); - } - - [Fact] - public async Task RequestDelegateThrowsForJsonExceptionsIfThrowOnBadRequest() - { - var invoked = false; - - void TestAction([FromBody] Todo todo) - { - invoked = true; - } - - var jsonException = new JsonException(); - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(jsonException); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true }); - var requestDelegate = factoryResult.RequestDelegate; - - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - Assert.Same(jsonException, badHttpRequestException.InnerException); - } - - [Fact] - public async Task RequestDelegateLogsMalformedJsonAsDebugAndSets400Response() - { - var invoked = false; - - void TestAction([FromBody] Todo todo) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{")); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(2, "InvalidJsonRequestBody"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", logMessage.Message); - Assert.IsType(logMessage.Exception); - } - - [Fact] - public async Task RequestDelegateThrowsForMalformedJsonIfThrowOnBadRequest() - { - var invoked = false; - - void TestAction([FromBody] Todo todo) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{")); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true }); - var requestDelegate = factoryResult.RequestDelegate; - - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - Assert.IsType(badHttpRequestException.InnerException); - } - - [Fact] - public void BuildRequestDelegateThrowsInvalidOperationExceptionGivenFromBodyOnMultipleParameters() - { - void TestAttributedInvalidAction([FromBody] int value1, [FromBody] int value2) { } - void TestInferredInvalidAction(Todo value1, Todo value2) { } - void TestBothInvalidAction(Todo value1, [FromBody] int value2) { } - - Assert.Throws(() => RequestDelegateFactory.Create(TestAttributedInvalidAction)); - Assert.Throws(() => RequestDelegateFactory.Create(TestInferredInvalidAction)); - Assert.Throws(() => RequestDelegateFactory.Create(TestBothInvalidAction)); - } - - [Fact] - public void BuildRequestDelegateThrowsInvalidOperationExceptionForInvalidTryParse() - { - void TestTryParseStruct(BadTryParseStruct value1) { } - void TestTryParseClass(BadTryParseClass value1) { } + void TestTryParseStruct(BadTryParseStruct value1) { } + void TestTryParseClass(BadTryParseClass value1) { } Assert.Throws(() => RequestDelegateFactory.Create(TestTryParseStruct)); Assert.Throws(() => RequestDelegateFactory.Create(TestTryParseClass)); @@ -2848,77 +2400,6 @@ public async Task RequestDelegateWritesNullReturnNullValue(Delegate @delegate) Assert.Equal("null", responseBody); } - public static IEnumerable QueryParamOptionalityData - { - get - { - string requiredQueryParam(string name) => $"Hello {name}!"; - string defaultValueQueryParam(string name = "DefaultName") => $"Hello {name}!"; - string nullableQueryParam(string? name) => $"Hello {name}!"; - string requiredParseableQueryParam(int age) => $"Age: {age}"; - string defaultValueParseableQueryParam(int age = 12) => $"Age: {age}"; - string nullableQueryParseableParam(int? age) => $"Age: {age}"; - - return new List - { - new object?[] { (Func)requiredQueryParam, "name", null, true, null}, - new object?[] { (Func)requiredQueryParam, "name", "TestName", false, "Hello TestName!" }, - new object?[] { (Func)defaultValueQueryParam, "name", null, false, "Hello DefaultName!" }, - new object?[] { (Func)defaultValueQueryParam, "name", "TestName", false, "Hello TestName!" }, - new object?[] { (Func)nullableQueryParam, "name", null, false, "Hello !" }, - new object?[] { (Func)nullableQueryParam, "name", "TestName", false, "Hello TestName!"}, - - new object?[] { (Func)requiredParseableQueryParam, "age", null, true, null}, - new object?[] { (Func)requiredParseableQueryParam, "age", "42", false, "Age: 42" }, - new object?[] { (Func)defaultValueParseableQueryParam, "age", null, false, "Age: 12" }, - new object?[] { (Func)defaultValueParseableQueryParam, "age", "42", false, "Age: 42" }, - new object?[] { (Func)nullableQueryParseableParam, "age", null, false, "Age: " }, - new object?[] { (Func)nullableQueryParseableParam, "age", "42", false, "Age: 42"}, - }; - } - } - - [Theory] - [MemberData(nameof(QueryParamOptionalityData))] - public async Task RequestDelegateHandlesQueryParamOptionality(Delegate @delegate, string paramName, string? queryParam, bool isInvalid, string? expectedResponse) - { - var httpContext = CreateHttpContext(); - var responseBodyStream = new MemoryStream(); - httpContext.Response.Body = responseBodyStream; - - if (queryParam is not null) - { - httpContext.Request.Query = new QueryCollection(new Dictionary - { - [paramName] = queryParam - }); - } - - var factoryResult = RequestDelegateFactory.Create(@delegate); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - var logs = TestSink.Writes.ToArray(); - - if (isInvalid) - { - Assert.Equal(400, httpContext.Response.StatusCode); - var log = Assert.Single(logs); - Assert.Equal(LogLevel.Debug, log.LogLevel); - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); - var expectedType = paramName == "age" ? "int age" : $"string name"; - Assert.Equal($@"Required parameter ""{expectedType}"" was not provided from route or query string.", log.Message); - } - else - { - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); - Assert.Equal(expectedResponse, decodedResponseBody); - } - } - public static IEnumerable RouteParamOptionalityData { get @@ -2991,77 +2472,6 @@ public async Task RequestDelegateHandlesRouteParamOptionality(Delegate @delegate } } - public static IEnumerable BodyParamOptionalityData - { - get - { - string requiredBodyParam(Todo todo) => $"Todo: {todo.Name}"; - string defaultValueBodyParam(Todo? todo = null) => $"Todo: {todo?.Name}"; - string nullableBodyParam(Todo? todo) => $"Todo: {todo?.Name}"; - - return new List - { - new object?[] { (Func)requiredBodyParam, false, true, null }, - new object?[] { (Func)requiredBodyParam, true, false, "Todo: Default Todo"}, - new object?[] { (Func)defaultValueBodyParam, false, false, "Todo: "}, - new object?[] { (Func)defaultValueBodyParam, true, false, "Todo: Default Todo"}, - new object?[] { (Func)nullableBodyParam, false, false, "Todo: " }, - new object?[] { (Func)nullableBodyParam, true, false, "Todo: Default Todo" }, - }; - } - } - - [Theory] - [MemberData(nameof(BodyParamOptionalityData))] - public async Task RequestDelegateHandlesBodyParamOptionality(Delegate @delegate, bool hasBody, bool isInvalid, string? expectedResponse) - { - var httpContext = CreateHttpContext(); - var responseBodyStream = new MemoryStream(); - httpContext.Response.Body = responseBodyStream; - - if (hasBody) - { - var todo = new Todo() { Name = "Default Todo" }; - var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(todo); - var stream = new MemoryStream(requestBodyBytes); - httpContext.Request.Body = stream; - httpContext.Request.Headers["Content-Type"] = "application/json"; - httpContext.Request.ContentLength = stream.Length; - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - } - - var jsonOptions = new JsonOptions(); - jsonOptions.SerializerOptions.Converters.Add(new TodoJsonConverter()); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(LoggerFactory); - serviceCollection.AddSingleton(Options.Create(jsonOptions)); - httpContext.RequestServices = serviceCollection.BuildServiceProvider(); - - var factoryResult = RequestDelegateFactory.Create(@delegate); - var requestDelegate = factoryResult.RequestDelegate; - - var request = requestDelegate(httpContext); - - if (isInvalid) - { - var logs = TestSink.Writes.ToArray(); - Assert.Equal(400, httpContext.Response.StatusCode); - var log = Assert.Single(logs); - Assert.Equal(LogLevel.Debug, log.LogLevel); - Assert.Equal(new EventId(5, "ImplicitBodyNotProvided"), log.EventId); - Assert.Equal(@"Implicit body inferred for parameter ""todo"" but no body was provided. Did you mean to use a Service instead?", log.Message); - } - else - { - await request; - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); - Assert.Equal(expectedResponse, decodedResponseBody); - } - } - public static IEnumerable BindAsyncParamOptionalityData { get @@ -3228,54 +2638,6 @@ public async Task RequestDelegateHandlesServiceParamOptionality(Delegate @delega Assert.False(httpContext.RequestAborted.IsCancellationRequested); } } - - public static IEnumerable AllowEmptyData - { - get - { - string disallowEmptyAndNonOptional([FromBody(AllowEmpty = false)] Todo todo) => $"{todo}"; - string allowEmptyAndNonOptional([FromBody(AllowEmpty = true)] Todo todo) => $"{todo}"; - string allowEmptyAndOptional([FromBody(AllowEmpty = true)] Todo? todo = null) => $"{todo}"; - string disallowEmptyAndOptional([FromBody(AllowEmpty = false)] Todo? todo = null) => $"{todo}"; - - return new List - { - new object?[] { (Func)disallowEmptyAndNonOptional, false }, - new object?[] { (Func)allowEmptyAndNonOptional, true }, - new object?[] { (Func)allowEmptyAndOptional, true }, - new object?[] { (Func)disallowEmptyAndOptional, true } - }; - } - } - - [Theory] - [MemberData(nameof(AllowEmptyData))] - public async Task AllowEmptyOverridesOptionality(Delegate @delegate, bool allowsEmptyRequest) - { - var httpContext = CreateHttpContext(); - - var factoryResult = RequestDelegateFactory.Create(@delegate); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - var logs = TestSink.Writes.ToArray(); - - if (!allowsEmptyRequest) - { - Assert.Equal(400, httpContext.Response.StatusCode); - var log = Assert.Single(logs); - Assert.Equal(LogLevel.Debug, log.LogLevel); - Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); - Assert.Equal(@"Required parameter ""Todo todo"" was not provided from body.", log.Message); - } - else - { - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - } - } - #nullable disable [Theory] @@ -3395,93 +2757,6 @@ public async Task CanExecuteRequestDelegateWithResultsExtension() Assert.Equal(@"""Hello Tester. This is from an extension method.""", decodedResponseBody); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RequestDelegateRejectsNonJsonContent(bool shouldThrow) - { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/xml"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(LoggerFactory); - httpContext.RequestServices = serviceCollection.BuildServiceProvider(); - - var factoryResult = RequestDelegateFactory.Create((HttpContext context, Todo todo) => - { - }, new RequestDelegateFactoryOptions() { ThrowOnBadRequest = shouldThrow }); - var requestDelegate = factoryResult.RequestDelegate; - - var request = requestDelegate(httpContext); - - if (shouldThrow) - { - var ex = await Assert.ThrowsAsync(() => request); - Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); - Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); - } - else - { - await request; - - Assert.Equal(415, httpContext.Response.StatusCode); - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); - } - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RequestDelegateWithBindAndImplicitBodyRejectsNonJsonContent(bool shouldThrow) - { - Todo originalTodo = new() - { - Name = "Write more tests!" - }; - - var httpContext = new DefaultHttpContext(); - - var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(originalTodo); - var stream = new MemoryStream(requestBodyBytes); - httpContext.Request.Body = stream; - httpContext.Request.Headers["Content-Type"] = "application/xml"; - httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton(LoggerFactory); - httpContext.RequestServices = serviceCollection.BuildServiceProvider(); - - var factoryResult = RequestDelegateFactory.Create((HttpContext context, JsonTodo customTodo, Todo todo) => - { - }, new RequestDelegateFactoryOptions() { ThrowOnBadRequest = shouldThrow }); - var requestDelegate = factoryResult.RequestDelegate; - - var request = requestDelegate(httpContext); - - if (shouldThrow) - { - var ex = await Assert.ThrowsAsync(() => request); - Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); - Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); - } - else - { - await request; - - Assert.Equal(415, httpContext.Response.StatusCode); - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); - } - } - public static IEnumerable UriDelegates { get @@ -3522,41 +2797,6 @@ public async Task RequestDelegateCanProcessUriValues(Delegate @delegate, string Assert.Equal(expectedResponse, decodedResponseBody); } - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task RequestDelegateLogsIOExceptionsForFormAsDebugDoesNotAbortAndNeverThrows(bool throwOnBadRequests) - { - var invoked = false; - - void TestAction(IFormFile file) - { - invoked = true; - } - - var ioException = new IOException(); - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; - httpContext.Request.Headers["Content-Length"] = "1"; - httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(ioException); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = throwOnBadRequests }); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal("Reading the request body failed with an IOException.", logMessage.Message); - Assert.Same(ioException, logMessage.Exception); - } - [Fact] public async Task RequestDelegateThrowsBadHttpRequestExceptionWhenReadingOversizeFormResultsIn413BadRequest() { @@ -3623,75 +2863,6 @@ void TestAction(Todo todo) Assert.Equal(413, httpContext.Response.StatusCode); } - [Fact] - public async Task RequestDelegateLogsMalformedFormAsDebugAndSets400Response() - { - var invoked = false; - - void TestAction(IFormFile file) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; - httpContext.Request.Headers["Content-Length"] = "2049"; - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction); - var requestDelegate = factoryResult.RequestDelegate; - - await requestDelegate(httpContext); - - Assert.False(invoked); - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(400, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - var logMessage = Assert.Single(TestSink.Writes); - Assert.Equal(new EventId(8, "InvalidFormRequestBody"), logMessage.EventId); - Assert.Equal(LogLevel.Debug, logMessage.LogLevel); - Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", logMessage.Message); - Assert.IsType(logMessage.Exception); - } - - [Fact] - public async Task RequestDelegateThrowsForMalformedFormIfThrowOnBadRequest() - { - var invoked = false; - - void TestAction(IFormFile file) - { - invoked = true; - } - - var httpContext = CreateHttpContext(); - httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; - httpContext.Request.Headers["Content-Length"] = "2049"; - httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); - httpContext.Features.Set(new RequestBodyDetectionFeature(true)); - - var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true }); - var requestDelegate = factoryResult.RequestDelegate; - - var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); - - Assert.False(invoked); - - // The httpContext should be untouched. - Assert.False(httpContext.RequestAborted.IsCancellationRequested); - Assert.Equal(200, httpContext.Response.StatusCode); - Assert.False(httpContext.Response.HasStarted); - - // We don't log bad requests when we throw. - Assert.Empty(TestSink.Writes); - - Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", badHttpRequestException.Message); - Assert.Equal(400, badHttpRequestException.StatusCode); - Assert.IsType(badHttpRequestException.InnerException); - } - [Fact] public void BuildRequestDelegateThrowsInvalidOperationExceptionBodyAndFormParameters() { diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_BindAsync_Snapshot.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_BindAsync_Snapshot.generated.txt index 31ca63d15f6d..3031548795cd 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_BindAsync_Snapshot.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_BindAsync_Snapshot.generated.txt @@ -334,6 +334,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -356,6 +357,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -380,6 +383,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBindAsyncRecord myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBindAsyncRecord", "myBindAsyncParam", "MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -406,6 +410,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBindAsyncRecord myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBindAsyncRecord", "myBindAsyncParam", "MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -437,6 +442,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -498,6 +505,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -522,6 +531,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBindAsyncStruct", "myBindAsyncParam", "MyBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -548,6 +558,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBindAsyncStruct", "myBindAsyncParam", "MyBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -579,6 +590,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -640,6 +653,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -664,6 +679,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyNullableBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyNullableBindAsyncStruct", "myBindAsyncParam", "MyNullableBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -690,6 +706,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyNullableBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyNullableBindAsyncStruct", "myBindAsyncParam", "MyNullableBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -721,6 +738,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -782,6 +801,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -806,6 +827,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBothBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBothBindAsyncStruct", "myBindAsyncParam", "MyBothBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -832,6 +854,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MyBothBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MyBothBindAsyncStruct", "myBindAsyncParam", "MyBothBindAsyncStruct.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -863,6 +886,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -924,6 +949,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -947,6 +974,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MySimpleBindAsyncRecord myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MySimpleBindAsyncRecord", "myBindAsyncParam", "MySimpleBindAsyncRecord.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -973,6 +1001,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MySimpleBindAsyncRecord myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MySimpleBindAsyncRecord", "myBindAsyncParam", "MySimpleBindAsyncRecord.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1004,6 +1033,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -1064,6 +1095,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -1087,6 +1120,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MySimpleBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MySimpleBindAsyncStruct", "myBindAsyncParam", "MySimpleBindAsyncStruct.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1113,6 +1147,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.MySimpleBindAsyncStruct myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("MySimpleBindAsyncStruct", "myBindAsyncParam", "MySimpleBindAsyncStruct.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1144,6 +1179,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -1204,6 +1241,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -1228,6 +1267,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.BindAsyncFromImplicitStaticAbstractInterface myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("BindAsyncFromImplicitStaticAbstractInterface", "myBindAsyncParam", "BindAsyncFromImplicitStaticAbstractInterface.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1254,6 +1294,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.BindAsyncFromImplicitStaticAbstractInterface myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("BindAsyncFromImplicitStaticAbstractInterface", "myBindAsyncParam", "BindAsyncFromImplicitStaticAbstractInterface.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1285,6 +1326,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -1346,6 +1389,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -1369,6 +1414,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.InheritBindAsync myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("InheritBindAsync", "myBindAsyncParam", "InheritBindAsync.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1395,6 +1441,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.InheritBindAsync myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("InheritBindAsync", "myBindAsyncParam", "InheritBindAsync.BindAsync(HttpContext)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1426,6 +1473,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -1486,6 +1535,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -1510,6 +1561,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.BindAsyncFromExplicitStaticAbstractInterface myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("BindAsyncFromExplicitStaticAbstractInterface", "myBindAsyncParam", "BindAsyncFromExplicitStaticAbstractInterface.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1536,6 +1588,7 @@ namespace Microsoft.AspNetCore.Http.Generated global::Microsoft.AspNetCore.Http.Generators.Tests.BindAsyncFromExplicitStaticAbstractInterface myBindAsyncParam_local; if ((object?)myBindAsyncParam_temp == null) { + logOrThrowExceptionHelper.RequiredParameterNotProvided("BindAsyncFromExplicitStaticAbstractInterface", "myBindAsyncParam", "BindAsyncFromExplicitStaticAbstractInterface.BindAsync(HttpContext, ParameterInfo)"); wasParamCheckFailure = true; myBindAsyncParam_local = default!; } @@ -1567,6 +1620,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var parameters = del.Method.GetParameters(); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -1672,4 +1727,147 @@ namespace Microsoft.AspNetCore.Http.Generated } } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitBodyParam_ComplexReturn_Snapshot.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitBodyParam_ComplexReturn_Snapshot.generated.txt index d52413407ed1..4f4242f26a44 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitBodyParam_ComplexReturn_Snapshot.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitBodyParam_ComplexReturn_Snapshot.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func>)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -120,7 +123,7 @@ namespace Microsoft.AspNetCore.Http.Generated { var wasParamCheckFailure = false; // Endpoint Parameter: todo (Type = Microsoft.AspNetCore.Http.Generators.Tests.Todo, IsOptional = False, IsParsable = False, IsArray = False, Source = JsonBody) - var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, false); + var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, false, "Todo", "todo"); var todo_local = todo_resolveBodyResult.Item2; if (!todo_resolveBodyResult.Item1) { @@ -140,7 +143,7 @@ namespace Microsoft.AspNetCore.Http.Generated { var wasParamCheckFailure = false; // Endpoint Parameter: todo (Type = Microsoft.AspNetCore.Http.Generators.Tests.Todo, IsOptional = False, IsParsable = False, IsArray = False, Source = JsonBody) - var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, false); + var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, false, "Todo", "todo"); var todo_local = todo_resolveBodyResult.Item2; if (!todo_resolveBodyResult.Item1) { @@ -170,6 +173,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func>)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -189,7 +194,7 @@ namespace Microsoft.AspNetCore.Http.Generated { var wasParamCheckFailure = false; // Endpoint Parameter: todo (Type = Microsoft.AspNetCore.Http.Generators.Tests.Todo?, IsOptional = True, IsParsable = False, IsArray = False, Source = JsonBody) - var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, true); + var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, true, "Todo?", "todo"); var todo_local = todo_resolveBodyResult.Item2; if (!todo_resolveBodyResult.Item1) { @@ -209,7 +214,7 @@ namespace Microsoft.AspNetCore.Http.Generated { var wasParamCheckFailure = false; // Endpoint Parameter: todo (Type = Microsoft.AspNetCore.Http.Generators.Tests.Todo?, IsOptional = True, IsParsable = False, IsArray = False, Source = JsonBody) - var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, true); + var todo_resolveBodyResult = await GeneratedRouteBuilderExtensionsCore.TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, true, "Todo?", "todo"); var todo_local = todo_resolveBodyResult.Item2; if (!todo_resolveBodyResult.Item1) { @@ -276,7 +281,7 @@ namespace Microsoft.AspNetCore.Http.Generated } } - private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, bool allowEmpty) + private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, bool allowEmpty, string parameterTypeName, string parameterName, bool isInferred = false) { var feature = httpContext.Features.Get(); @@ -284,6 +289,7 @@ namespace Microsoft.AspNetCore.Http.Generated { if (!httpContext.Request.HasJsonContentType()) { + logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return (false, default); } @@ -292,17 +298,34 @@ namespace Microsoft.AspNetCore.Http.Generated var bodyValue = await httpContext.Request.ReadFromJsonAsync(); if (!allowEmpty && bodyValue == null) { + if (!isInferred) + { + logOrThrowExceptionHelper.RequiredParameterNotProvided(parameterTypeName, parameterName, "body"); + } + else + { + logOrThrowExceptionHelper.ImplicitBodyNotProvided(parameterName); + } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, bodyValue); } return (true, bodyValue); } - catch (IOException) + catch (BadHttpRequestException badHttpRequestException) { + logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); + httpContext.Response.StatusCode = badHttpRequestException.StatusCode; return (false, default); } - catch (System.Text.Json.JsonException) + catch (IOException ioException) { + logOrThrowExceptionHelper.RequestBodyIOException(ioException); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (false, default); + } + catch (System.Text.Json.JsonException jsonException) + { + logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, default); } @@ -316,4 +339,147 @@ namespace Microsoft.AspNetCore.Http.Generated } } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_ComplexTypeArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_ComplexTypeArrayParam.generated.txt index e84a7be77ec9..0cb311dc7224 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_ComplexTypeArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_ComplexTypeArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -102,6 +103,7 @@ namespace Microsoft.AspNetCore.Http.Generated var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -131,7 +133,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -158,7 +164,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -227,4 +237,147 @@ namespace Microsoft.AspNetCore.Http.Generated => T.TryParse(s, provider, out result); } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_NullableStringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_NullableStringArrayParam.generated.txt index 1107b305a99c..4bd9b3324565 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_NullableStringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_NullableStringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -102,6 +103,7 @@ namespace Microsoft.AspNetCore.Http.Generated var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -207,4 +209,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_StringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_StringArrayParam.generated.txt index af1d9ce559d2..67ff7d789ad0 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_StringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitHeader_StringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -102,6 +103,7 @@ namespace Microsoft.AspNetCore.Http.Generated var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -207,4 +209,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_ComplexTypeArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_ComplexTypeArrayParam.generated.txt index 6d34b1878d5a..0300d5ed23aa 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_ComplexTypeArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_ComplexTypeArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -102,6 +103,7 @@ namespace Microsoft.AspNetCore.Http.Generated var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -131,7 +133,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -158,7 +164,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -227,4 +237,147 @@ namespace Microsoft.AspNetCore.Http.Generated => T.TryParse(s, provider, out result); } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_NullableStringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_NullableStringArrayParam.generated.txt index d65dea3b76bd..01cb6a1191e6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_NullableStringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_NullableStringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_StringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_StringArrayParam.generated.txt index b7f5f9c9703e..ad4d2eccc651 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_StringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitQuery_StringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitServiceParam_SimpleReturn_Snapshot.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitServiceParam_SimpleReturn_Snapshot.generated.txt index db3f37b17ae7..077f24ca0ecd 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitServiceParam_SimpleReturn_Snapshot.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitServiceParam_SimpleReturn_Snapshot.generated.txt @@ -109,6 +109,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -131,6 +132,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -191,6 +194,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func, global::System.String>)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -251,6 +256,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func, global::System.String>)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -354,4 +361,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitSource_SimpleReturn_Snapshot.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitSource_SimpleReturn_Snapshot.generated.txt index 5f15f0107c10..c67b5c003606 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitSource_SimpleReturn_Snapshot.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ExplicitSource_SimpleReturn_Snapshot.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -124,6 +127,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(queryValue_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "queryValue", "query string"); } var queryValue_temp = (string?)queryValue_raw; string queryValue_local = queryValue_temp!; @@ -146,6 +150,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(queryValue_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "queryValue", "query string"); } var queryValue_temp = (string?)queryValue_raw; string queryValue_local = queryValue_temp!; @@ -173,6 +178,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -196,6 +203,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(headerValue_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "headerValue", "header"); } var headerValue_temp = (string?)headerValue_raw; string headerValue_local = headerValue_temp!; @@ -218,6 +226,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(headerValue_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "headerValue", "header"); } var headerValue_temp = (string?)headerValue_raw; string headerValue_local = headerValue_temp!; @@ -245,6 +254,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -272,6 +283,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (routeValue_raw == null) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "routeValue", "unknown"); } var routeValue_temp = (string?)routeValue_raw; string routeValue_local = routeValue_temp!; @@ -298,6 +310,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (routeValue_raw == null) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "routeValue", "unknown"); } var routeValue_temp = (string?)routeValue_raw; string routeValue_local = routeValue_temp!; @@ -325,6 +338,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var value_RouteOrQueryResolver = GeneratedRouteBuilderExtensionsCore.ResolveFromRouteOrQuery("value", options?.RouteParameterNames); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -349,6 +364,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (value_raw is StringValues { Count: 0 }) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "value", "route or query string"); } var value_temp = (string?)value_raw; string value_local = value_temp!; @@ -371,6 +387,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (value_raw is StringValues { Count: 0 }) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "value", "route or query string"); } var value_temp = (string?)value_raw; string value_local = value_temp!; @@ -398,6 +415,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); var value_RouteOrQueryResolver = GeneratedRouteBuilderExtensionsCore.ResolveFromRouteOrQuery("value", options?.RouteParameterNames); if (options?.EndpointBuilder?.FilterFactories.Count > 0) @@ -422,6 +441,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (value_raw is StringValues { Count: 0 }) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "value", "route or query string"); } var value_temp = (string?)value_raw; string value_local = value_temp!; @@ -444,6 +464,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (value_raw is StringValues { Count: 0 }) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "value", "route or query string"); } var value_temp = (string?)value_raw; string value_local = value_temp!; @@ -516,4 +537,147 @@ namespace Microsoft.AspNetCore.Http.Generated } } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_ComplexTypeArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_ComplexTypeArrayParam.generated.txt index 105797ce8d7d..be7815770f23 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_ComplexTypeArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_ComplexTypeArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,8 +102,9 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; - var p_RouteOrQueryResolver = GeneratedRouteBuilderExtensionsCore.ResolveFromRouteOrQuery("p", options?.RouteParameterNames); var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); + var p_RouteOrQueryResolver = GeneratedRouteBuilderExtensionsCore.ResolveFromRouteOrQuery("p", options?.RouteParameterNames); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -132,7 +134,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -159,7 +165,11 @@ namespace Microsoft.AspNetCore.Http.Generated var element = p_temp[i]; if (!GeneratedRouteBuilderExtensionsCore.TryParseExplicit(element!, CultureInfo.InvariantCulture, out var parsed_element)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(element)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("ParsableTodo[]", "p", element); + wasParamCheckFailure = true; + } } p_local[i] = parsed_element!; } @@ -234,4 +244,147 @@ namespace Microsoft.AspNetCore.Http.Generated => T.TryParse(s, provider, out result); } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam.generated.txt index d65dea3b76bd..01cb6a1191e6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_EmptyQueryValues.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_EmptyQueryValues.generated.txt index d65dea3b76bd..01cb6a1191e6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_EmptyQueryValues.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_EmptyQueryValues.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_QueryNotPresent.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_QueryNotPresent.generated.txt index d65dea3b76bd..01cb6a1191e6 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_QueryNotPresent.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_NullableStringArrayParam_QueryNotPresent.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_StringArrayParam.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_StringArrayParam.generated.txt index b7f5f9c9703e..ad4d2eccc651 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_StringArrayParam.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_ImplicitQuery_StringArrayParam.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -207,4 +208,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_JsonBodyOrService_HandlesBothJsonAndService.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_JsonBodyOrService_HandlesBothJsonAndService.generated.txt index e41b5347c728..0884d58ea912 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_JsonBodyOrService_HandlesBothJsonAndService.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_JsonBodyOrService_HandlesBothJsonAndService.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,9 +102,11 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; - var serviceProviderIsService = options?.ServiceProvider?.GetService(); - var todo_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(serviceProviderIsService); - var svc_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(serviceProviderIsService); + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); + var serviceProviderIsService = serviceProvider?.GetService(); + var todo_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(logOrThrowExceptionHelper, "Todo", "todo", serviceProviderIsService); + var svc_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(logOrThrowExceptionHelper, "TestService", "svc", serviceProviderIsService); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -225,7 +228,7 @@ namespace Microsoft.AspNetCore.Http.Generated } } - private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, bool allowEmpty) + private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, bool allowEmpty, string parameterTypeName, string parameterName, bool isInferred = false) { var feature = httpContext.Features.Get(); @@ -233,6 +236,7 @@ namespace Microsoft.AspNetCore.Http.Generated { if (!httpContext.Request.HasJsonContentType()) { + logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return (false, default); } @@ -241,17 +245,34 @@ namespace Microsoft.AspNetCore.Http.Generated var bodyValue = await httpContext.Request.ReadFromJsonAsync(); if (!allowEmpty && bodyValue == null) { + if (!isInferred) + { + logOrThrowExceptionHelper.RequiredParameterNotProvided(parameterTypeName, parameterName, "body"); + } + else + { + logOrThrowExceptionHelper.ImplicitBodyNotProvided(parameterName); + } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, bodyValue); } return (true, bodyValue); } - catch (IOException) + catch (BadHttpRequestException badHttpRequestException) { + logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); + httpContext.Response.StatusCode = badHttpRequestException.StatusCode; return (false, default); } - catch (System.Text.Json.JsonException) + catch (IOException ioException) { + logOrThrowExceptionHelper.RequestBodyIOException(ioException); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (false, default); + } + catch (System.Text.Json.JsonException jsonException) + { + logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, default); } @@ -263,7 +284,7 @@ namespace Microsoft.AspNetCore.Http.Generated return (allowEmpty, default); } - private static Func> ResolveJsonBodyOrService(IServiceProviderIsService? serviceProviderIsService = null) + private static Func> ResolveJsonBodyOrService(LogOrThrowExceptionHelper logOrThrowExceptionHelper, string parameterTypeName, string parameterName, IServiceProviderIsService? serviceProviderIsService = null) { if (serviceProviderIsService is not null) { @@ -272,8 +293,151 @@ namespace Microsoft.AspNetCore.Http.Generated return static (httpContext, isOptional) => new ValueTask<(bool, T?)>((true, httpContext.RequestServices.GetService())); } } - return static (httpContext, isOptional) => TryResolveBodyAsync(httpContext, isOptional); + return (httpContext, isOptional) => TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, isOptional, parameterTypeName, parameterName, isInferred: true); + } + + } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } } + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleSpecialTypeParam_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleSpecialTypeParam_StringReturn.generated.txt index 885ee75e0791..799c5595e0f4 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleSpecialTypeParam_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleSpecialTypeParam_StringReturn.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -200,4 +201,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleStringParam_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleStringParam_StringReturn.generated.txt index 62425b4567d8..521870e50dd3 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleStringParam_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_MultipleStringParam_StringReturn.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -124,6 +127,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p1_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "p1", "query string"); } var p1_temp = (string?)p1_raw; string p1_local = p1_temp!; @@ -132,6 +136,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p2_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "p2", "query string"); } var p2_temp = (string?)p2_raw; string p2_local = p2_temp!; @@ -154,6 +159,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p1_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "p1", "query string"); } var p1_temp = (string?)p1_raw; string p1_local = p1_temp!; @@ -162,6 +168,7 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p2_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("string", "p2", "query string"); } var p2_temp = (string?)p2_raw; string p2_local = p2_temp!; @@ -228,4 +235,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_NoParam_StringReturn_WithFilter.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_NoParam_StringReturn_WithFilter.generated.txt index 979efa7f7299..1e6666e1e4e2 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_NoParam_StringReturn_WithFilter.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_NoParam_StringReturn_WithFilter.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -194,4 +195,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleComplexTypeParam_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleComplexTypeParam_StringReturn.generated.txt index d95fd8d12535..20f3c56068be 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleComplexTypeParam_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleComplexTypeParam_StringReturn.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -124,11 +127,16 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("TryParseTodo", "p", "query string"); } var p_temp = (string?)p_raw; if (!global::Microsoft.AspNetCore.Http.Generators.Tests.TryParseTodo.TryParse(p_temp!, out var p_parsed_temp)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(p_temp)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("TryParseTodo", "p", p_temp); + wasParamCheckFailure = true; + } } global::Microsoft.AspNetCore.Http.Generators.Tests.TryParseTodo p_local = p_parsed_temp!; @@ -150,11 +158,16 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("TryParseTodo", "p", "query string"); } var p_temp = (string?)p_raw; if (!global::Microsoft.AspNetCore.Http.Generators.Tests.TryParseTodo.TryParse(p_temp!, out var p_parsed_temp)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(p_temp)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("TryParseTodo", "p", p_temp); + wasParamCheckFailure = true; + } } global::Microsoft.AspNetCore.Http.Generators.Tests.TryParseTodo p_local = p_parsed_temp!; @@ -222,4 +235,147 @@ namespace Microsoft.AspNetCore.Http.Generated => T.TryParse(s, provider, out result); } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleEnumParam_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleEnumParam_StringReturn.generated.txt index 2997b9d604bb..c8468ddb39bf 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleEnumParam_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleEnumParam_StringReturn.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -124,11 +127,16 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("TodoStatus", "p", "query string"); } var p_temp = (string?)p_raw; if (!Enum.TryParse(p_temp!, out var p_parsed_temp)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(p_temp)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("TodoStatus", "p", p_temp); + wasParamCheckFailure = true; + } } global::Microsoft.AspNetCore.Http.Generators.Tests.TodoStatus p_local = p_parsed_temp!; @@ -150,11 +158,16 @@ namespace Microsoft.AspNetCore.Http.Generated if (StringValues.IsNullOrEmpty(p_raw)) { wasParamCheckFailure = true; + logOrThrowExceptionHelper.RequiredParameterNotProvided("TodoStatus", "p", "query string"); } var p_temp = (string?)p_raw; if (!Enum.TryParse(p_temp!, out var p_parsed_temp)) { - wasParamCheckFailure = true; + if (!string.IsNullOrEmpty(p_temp)) + { + logOrThrowExceptionHelper.ParameterBindingFailed("TodoStatus", "p", p_temp); + wasParamCheckFailure = true; + } } global::Microsoft.AspNetCore.Http.Generators.Tests.TodoStatus p_local = p_parsed_temp!; @@ -222,4 +235,147 @@ namespace Microsoft.AspNetCore.Http.Generated => T.TryParse(s, provider, out result); } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleNullableStringParam_WithEmptyQueryStringValueProvided_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleNullableStringParam_WithEmptyQueryStringValueProvided_StringReturn.generated.txt index 7922e10bd982..41bd933de618 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleNullableStringParam_WithEmptyQueryStringValueProvided_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapAction_SingleNullableStringParam_WithEmptyQueryStringValueProvided_StringReturn.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,6 +102,8 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -204,4 +207,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_AndBody_ShouldUseBody.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_AndBody_ShouldUseBody.generated.txt index 6c19d58a2169..d1a1c1055e9e 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_AndBody_ShouldUseBody.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_AndBody_ShouldUseBody.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,8 +102,10 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; - var serviceProviderIsService = options?.ServiceProvider?.GetService(); - var p_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(serviceProviderIsService); + var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); + var serviceProviderIsService = serviceProvider?.GetService(); + var p_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(logOrThrowExceptionHelper, "string[]", "p", serviceProviderIsService); if (options?.EndpointBuilder?.FilterFactories.Count > 0) { @@ -210,7 +213,7 @@ namespace Microsoft.AspNetCore.Http.Generated } } - private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, bool allowEmpty) + private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, bool allowEmpty, string parameterTypeName, string parameterName, bool isInferred = false) { var feature = httpContext.Features.Get(); @@ -218,6 +221,7 @@ namespace Microsoft.AspNetCore.Http.Generated { if (!httpContext.Request.HasJsonContentType()) { + logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return (false, default); } @@ -226,17 +230,34 @@ namespace Microsoft.AspNetCore.Http.Generated var bodyValue = await httpContext.Request.ReadFromJsonAsync(); if (!allowEmpty && bodyValue == null) { + if (!isInferred) + { + logOrThrowExceptionHelper.RequiredParameterNotProvided(parameterTypeName, parameterName, "body"); + } + else + { + logOrThrowExceptionHelper.ImplicitBodyNotProvided(parameterName); + } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, bodyValue); } return (true, bodyValue); } - catch (IOException) + catch (BadHttpRequestException badHttpRequestException) { + logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); + httpContext.Response.StatusCode = badHttpRequestException.StatusCode; return (false, default); } - catch (System.Text.Json.JsonException) + catch (IOException ioException) { + logOrThrowExceptionHelper.RequestBodyIOException(ioException); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (false, default); + } + catch (System.Text.Json.JsonException jsonException) + { + logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, default); } @@ -248,7 +269,7 @@ namespace Microsoft.AspNetCore.Http.Generated return (allowEmpty, default); } - private static Func> ResolveJsonBodyOrService(IServiceProviderIsService? serviceProviderIsService = null) + private static Func> ResolveJsonBodyOrService(LogOrThrowExceptionHelper logOrThrowExceptionHelper, string parameterTypeName, string parameterName, IServiceProviderIsService? serviceProviderIsService = null) { if (serviceProviderIsService is not null) { @@ -257,8 +278,151 @@ namespace Microsoft.AspNetCore.Http.Generated return static (httpContext, isOptional) => new ValueTask<(bool, T?)>((true, httpContext.RequestServices.GetService())); } } - return static (httpContext, isOptional) => TryResolveBodyAsync(httpContext, isOptional); + return (httpContext, isOptional) => TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, isOptional, parameterTypeName, parameterName, isInferred: true); + } + + } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } } + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_ShouldFail.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_ShouldFail.generated.txt index cd0d311f62f3..2a1491135889 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_ShouldFail.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/MapPost_WithArrayQueryString_ShouldFail.generated.txt @@ -79,6 +79,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -101,9 +102,10 @@ namespace Microsoft.AspNetCore.Http.Generated { var handler = (System.Func)del; EndpointFilterDelegate? filteredInvocation = null; - var serviceProviderIsService = options?.ServiceProvider?.GetService(); - var p_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(serviceProviderIsService); var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices; + var logOrThrowExceptionHelper = new LogOrThrowExceptionHelper(serviceProvider, options); + var serviceProviderIsService = serviceProvider?.GetService(); + var p_JsonBodyOrServiceResolver = ResolveJsonBodyOrService(logOrThrowExceptionHelper, "string[]", "p", serviceProviderIsService); var serializerOptions = serviceProvider?.GetService>()?.Value.SerializerOptions ?? new JsonOptions().SerializerOptions; var jsonTypeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(global::System.Int32)); @@ -213,7 +215,7 @@ namespace Microsoft.AspNetCore.Http.Generated } } - private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, bool allowEmpty) + private static async ValueTask<(bool, T?)> TryResolveBodyAsync(HttpContext httpContext, LogOrThrowExceptionHelper logOrThrowExceptionHelper, bool allowEmpty, string parameterTypeName, string parameterName, bool isInferred = false) { var feature = httpContext.Features.Get(); @@ -221,6 +223,7 @@ namespace Microsoft.AspNetCore.Http.Generated { if (!httpContext.Request.HasJsonContentType()) { + logOrThrowExceptionHelper.UnexpectedJsonContentType(httpContext.Request.ContentType); httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; return (false, default); } @@ -229,17 +232,34 @@ namespace Microsoft.AspNetCore.Http.Generated var bodyValue = await httpContext.Request.ReadFromJsonAsync(); if (!allowEmpty && bodyValue == null) { + if (!isInferred) + { + logOrThrowExceptionHelper.RequiredParameterNotProvided(parameterTypeName, parameterName, "body"); + } + else + { + logOrThrowExceptionHelper.ImplicitBodyNotProvided(parameterName); + } httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, bodyValue); } return (true, bodyValue); } - catch (IOException) + catch (BadHttpRequestException badHttpRequestException) { + logOrThrowExceptionHelper.RequestBodyIOException(badHttpRequestException); + httpContext.Response.StatusCode = badHttpRequestException.StatusCode; return (false, default); } - catch (System.Text.Json.JsonException) + catch (IOException ioException) { + logOrThrowExceptionHelper.RequestBodyIOException(ioException); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (false, default); + } + catch (System.Text.Json.JsonException jsonException) + { + logOrThrowExceptionHelper.InvalidJsonRequestBody(parameterTypeName, parameterName, jsonException); httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return (false, default); } @@ -251,7 +271,7 @@ namespace Microsoft.AspNetCore.Http.Generated return (allowEmpty, default); } - private static Func> ResolveJsonBodyOrService(IServiceProviderIsService? serviceProviderIsService = null) + private static Func> ResolveJsonBodyOrService(LogOrThrowExceptionHelper logOrThrowExceptionHelper, string parameterTypeName, string parameterName, IServiceProviderIsService? serviceProviderIsService = null) { if (serviceProviderIsService is not null) { @@ -260,8 +280,151 @@ namespace Microsoft.AspNetCore.Http.Generated return static (httpContext, isOptional) => new ValueTask<(bool, T?)>((true, httpContext.RequestServices.GetService())); } } - return static (httpContext, isOptional) => TryResolveBodyAsync(httpContext, isOptional); + return (httpContext, isOptional) => TryResolveBodyAsync(httpContext, logOrThrowExceptionHelper, isOptional, parameterTypeName, parameterName, isInferred: true); + } + + } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } } + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_NoParam_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_NoParam_StringReturn.generated.txt index cab493e0edfd..99f3a71bb3bb 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_NoParam_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_NoParam_StringReturn.generated.txt @@ -109,6 +109,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -388,4 +389,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_WithParams_StringReturn.generated.txt b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_WithParams_StringReturn.generated.txt index 5e02049b9643..d1e324a12a45 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_WithParams_StringReturn.generated.txt +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/Baselines/Multiple_MapAction_WithParams_StringReturn.generated.txt @@ -109,6 +109,7 @@ namespace Microsoft.AspNetCore.Http.Generated using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Options; @@ -346,4 +347,147 @@ namespace Microsoft.AspNetCore.Http.Generated } + + file class LogOrThrowExceptionHelper + { + private readonly ILogger? _rdgLogger; + private readonly bool _shouldThrow; + + public LogOrThrowExceptionHelper(IServiceProvider? serviceProvider, RequestDelegateFactoryOptions? options) + { + var loggerFactory = serviceProvider?.GetRequiredService(); + _rdgLogger = loggerFactory?.CreateLogger("Microsoft.AspNetCore.Http.RequestDelegateGenerator.RequestDelegateGenerator"); + _shouldThrow = options?.ThrowOnBadRequest ?? false; + } + + public void RequestBodyIOException(IOException exception) + { + if (_rdgLogger != null) + { + _requestBodyIOException(_rdgLogger, exception); + } + } + + private static readonly Action _requestBodyIOException = + LoggerMessage.Define(LogLevel.Debug, new EventId(1, "RequestBodyIOException"), "Reading the request body failed with an IOException."); + + public void InvalidJsonRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as JSON.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidJsonRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidJsonRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(2, "InvalidJsonRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as JSON."); + + public void ParameterBindingFailed(string parameterTypeName, string parameterName, string sourceValue) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to bind parameter \"{0} {1}\" from \"{2}\".", parameterTypeName, parameterName, sourceValue); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _parameterBindingFailed(_rdgLogger, parameterTypeName, parameterName, sourceValue, null); + } + } + + private static readonly Action _parameterBindingFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(3, "ParameterBindingFailed"), "Failed to bind parameter \"{ParameterType} {ParameterName}\" from \"{SourceValue}\"."); + + public void RequiredParameterNotProvided(string parameterTypeName, string parameterName, string source) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Required parameter \"{0} {1}\" was not provided from {2}.", parameterTypeName, parameterName, source); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _requiredParameterNotProvided(_rdgLogger, parameterTypeName, parameterName, source, null); + } + } + + private static readonly Action _requiredParameterNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(4, "RequiredParameterNotProvided"), "Required parameter \"{ParameterType} {ParameterName}\" was not provided from {Source}."); + + public void ImplicitBodyNotProvided(string parameterName) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Implicit body inferred for parameter \"{0}\" but no body was provided. Did you mean to use a Service instead?", parameterName); + throw new BadHttpRequestException(message); + } + + if (_rdgLogger != null) + { + _implicitBodyNotProvided(_rdgLogger, parameterName, null); + } + } + + private static readonly Action _implicitBodyNotProvided = + LoggerMessage.Define(LogLevel.Debug, new EventId(5, "ImplicitBodyNotProvided"), "Implicit body inferred for parameter \"{ParameterName}\" but no body was provided. Did you mean to use a Service instead?"); + + public void UnexpectedJsonContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported JSON media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedJsonContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedJsonContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(6, "UnexpectedContentType"), "Expected a supported JSON media type but got \"{ContentType}\"."); + + public void UnexpectedNonFormContentType(string? contentType) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Expected a supported form media type but got \"{0}\".", contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + if (_rdgLogger != null) + { + _unexpectedNonFormContentType(_rdgLogger, contentType ?? "(none)", null); + } + } + + private static readonly Action _unexpectedNonFormContentType = + LoggerMessage.Define(LogLevel.Debug, new EventId(7, "UnexpectedNonFormContentType"), "Expected a supported form media type but got \"{ContentType}\"."); + + public void InvalidFormRequestBody(string parameterTypeName, string parameterName, Exception exception) + { + if (_shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, "Failed to read parameter \"{0} {1}\" from the request body as form.", parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + if (_rdgLogger != null) + { + _invalidFormRequestBody(_rdgLogger, parameterTypeName, parameterName, exception); + } + } + + private static readonly Action _invalidFormRequestBody = + LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidFormRequestBody"), "Failed to read parameter \"{ParameterType} {ParameterName}\" from the request body as form."); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs index 8037a9647bb1..16e8f57be7fd 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTestBase.cs @@ -152,7 +152,7 @@ internal Endpoint[] GetEndpointsFromCompilation(Compilation compilation, bool? e Assert.NotNull(handler); - var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider ?? new EmptyServiceProvider())); + var builder = new DefaultEndpointRouteBuilder(new ApplicationBuilder(serviceProvider ?? CreateServiceProvider())); _ = handler(builder); var dataSource = Assert.Single(builder.DataSources); diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs index 45b4a682235d..c63979916189 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Http.Generators.Tests; @@ -135,6 +136,14 @@ public async Task MapAction_BindAsync_NonOptional_NotProvided(string bindAsyncTy Assert.Null(httpContext.Items["uri"]); Assert.Equal(400, httpContext.Response.StatusCode); + Assert.Equal(400, httpContext.Response.StatusCode); + var log = Assert.Single(TestSink.Writes); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); + var parameters = bindAsyncType is "MySimpleBindAsyncRecord" || bindAsyncType is "InheritBindAsync" + ? "(HttpContext)" + : "(HttpContext, ParameterInfo)"; + Assert.Equal($@"Required parameter ""{bindAsyncType} myBindAsyncParam"" was not provided from {bindAsyncType}.BindAsync{parameters}.", log.Message); } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs index 0ce47287e0af..4739cf1cf45d 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBody.cs @@ -5,6 +5,8 @@ using System.IO.Pipelines; using System.Text.Json; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + namespace Microsoft.AspNetCore.Http.Generators.Tests; public abstract partial class RequestDelegateCreationTests @@ -401,4 +403,51 @@ void TestAction(HttpContext httpContext, [CustomFromBody(AllowEmpty = true)] Bod Assert.Equal(default(BodyStruct), httpContext.Items[structToBeZeroedKey]); } + + public static IEnumerable AllowEmptyData + { + get + { + return new List + { + new object[] { $@"string handler([CustomFromBody(AllowEmpty = false)] Todo todo) => todo?.ToString() ?? string.Empty", false }, + new object[] { $@"string handler([CustomFromBody(AllowEmpty = true)] Todo todo) => todo?.ToString() ?? string.Empty", true }, + new object[] { $@"string handler([CustomFromBody(AllowEmpty = true)] Todo? todo = null) => todo?.ToString() ?? string.Empty", true }, + new object[] { $@"string handler([CustomFromBody(AllowEmpty = false)] Todo? todo = null) => todo?.ToString() ?? string.Empty", true } + }; + } + } + + [Theory] + [MemberData(nameof(AllowEmptyData))] + public async Task AllowEmptyOverridesOptionality(string innerSource, bool allowsEmptyRequest) + { + var source = $""" +{innerSource}; +app.MapPost("/", handler); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointFromCompilation(compilation); + + var httpContext = CreateHttpContextWithBody(null); + + await endpoint.RequestDelegate(httpContext); + + var logs = TestSink.Writes.ToArray(); + + if (!allowsEmptyRequest) + { + Assert.Equal(400, httpContext.Response.StatusCode); + var log = Assert.Single(logs); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); + Assert.Equal(@"Required parameter ""Todo todo"" was not provided from body.", log.Message); + } + else + { + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + } + } + } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs index d3102ed43864..018749c31ff8 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.JsonBodyOrService.cs @@ -1,12 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + namespace Microsoft.AspNetCore.Http.Generators.Tests; -public abstract partial class RequestDelegateCreationTests +public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase { public static object[][] MapAction_JsonBodyOrService_SimpleReturn_Data { @@ -78,4 +81,52 @@ public async Task MapAction_JsonBodyOrService_HandlesBothJsonAndService() await endpoint.RequestDelegate(httpContext); await VerifyResponseBodyAsync(httpContext, expectedBody); } + + public static IEnumerable BodyParamOptionalityData + { + get + { + return new List + { + new object[] { @"(Todo todo) => $""Todo: {todo.Name}"";", false, true, null }, + new object[] { @"(Todo todo) => $""Todo: {todo.Name}"";", true, false, "Todo: Default Todo"}, + new object[] { @"(Todo? todo = null) => $""Todo: {todo?.Name}"";", false, false, "Todo: "}, + new object[] { @"(Todo? todo = null) => $""Todo: {todo?.Name}"";", true, false, "Todo: Default Todo"}, + new object[] { @"(Todo? todo) => $""Todo: {todo?.Name}"";", false, false, "Todo: " }, + new object[] { @"(Todo? todo) => $""Todo: {todo?.Name}"";", true, false, "Todo: Default Todo" }, + }; + } + } + + [Theory] + [MemberData(nameof(BodyParamOptionalityData))] + public async Task RequestDelegateHandlesBodyParamOptionality(string innerSource, bool hasBody, bool isInvalid, string expectedBody) + { + var source = $""" +string handler{innerSource}; +app.MapPost("/", handler); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var todo = new Todo() { Name = "Default Todo" }; + var httpContext = hasBody ? CreateHttpContextWithBody(todo) : CreateHttpContextWithBody(null); + + await endpoint.RequestDelegate(httpContext); + + if (isInvalid) + { + var logs = TestSink.Writes.ToArray(); + Assert.Equal(400, httpContext.Response.StatusCode); + var log = Assert.Single(logs); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Equal(new EventId(5, "ImplicitBodyNotProvided"), log.EventId); + Assert.Equal(@"Implicit body inferred for parameter ""todo"" but no body was provided. Did you mean to use a Service instead?", log.Message); + } + else + { + await VerifyResponseBodyAsync(httpContext, expectedBody); + } + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Logging.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Logging.cs new file mode 100644 index 000000000000..a7a250be2433 --- /dev/null +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.Logging.cs @@ -0,0 +1,646 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Http.Generators.Tests; + +public abstract partial class RequestDelegateCreationTests : RequestDelegateCreationTestBase +{ + [Fact] + public async Task RequestDelegateLogsStringValuesFromExplicitQueryStringSourceForUnpresentedValuesFailuresAsDebugAndSets400Response() + { + var source = """ +app.MapGet("/", ( + HttpContext httpContext, + [FromHeader(Name = "foo")] StringValues headerValues, + [FromQuery(Name = "bar")] StringValues queryValues + // TODO: https://github.com/dotnet/aspnetcore/issues/47200 + // [FromForm(Name = "form")] StringValues formValues +) => +{ + httpContext.Items["invoked"] = true; +}); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(serviceProvider); + httpContext.Request.Form = new FormCollection(null); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logs = TestSink.Writes.ToArray(); + + Assert.Equal(2, logs.Length); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); + Assert.Equal(LogLevel.Debug, logs[0].LogLevel); + Assert.Equal(@"Required parameter ""StringValues headerValues"" was not provided from header.", logs[0].Message); + var log1Values = Assert.IsAssignableFrom>>(logs[0].State); + Assert.Equal("StringValues", log1Values[0].Value); + Assert.Equal("headerValues", log1Values[1].Value); + Assert.Equal("header", log1Values[2].Value); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); + Assert.Equal(LogLevel.Debug, logs[1].LogLevel); + Assert.Equal(@"Required parameter ""StringValues queryValues"" was not provided from query string.", logs[1].Message); + var log2Values = Assert.IsAssignableFrom>>(logs[1].State); + Assert.Equal("StringValues", log2Values[0].Value); + Assert.Equal("queryValues", log2Values[1].Value); + Assert.Equal("query string", log2Values[2].Value); + + // TODO: https://github.com/dotnet/aspnetcore/issues/47200 + // Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[2].EventId); + // Assert.Equal(LogLevel.Debug, logs[2].LogLevel); + // Assert.Equal(@"Required parameter ""StringValues formValues"" was not provided from form.", logs[2].Message); + } + + [Fact] + public async Task RequestDelegateLogsTryParsableFailuresAsDebugAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, [FromRoute] int tryParsable, [FromRoute] int tryParsable2) +{ + httpContext.Items["invoked"] = true; +} + +app.MapGet("/{tryParsable}/{tryParsable2}", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues["tryParsable"] = "invalid!"; + httpContext.Request.RouteValues["tryParsable2"] = "invalid again!"; + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logs = TestSink.Writes.ToArray(); + + Assert.Equal(2, logs.Length); + + Assert.Equal(new EventId(3, "ParameterBindingFailed"), logs[0].EventId); + Assert.Equal(LogLevel.Debug, logs[0].LogLevel); + Assert.Equal(@"Failed to bind parameter ""int tryParsable"" from ""invalid!"".", logs[0].Message); + var log1Values = Assert.IsAssignableFrom>>(logs[0].State); + Assert.Equal("int", log1Values[0].Value); + Assert.Equal("tryParsable", log1Values[1].Value); + Assert.Equal("invalid!", log1Values[2].Value); + + Assert.Equal(new EventId(3, "ParameterBindingFailed"), logs[1].EventId); + Assert.Equal(LogLevel.Debug, logs[1].LogLevel); + Assert.Equal(@"Failed to bind parameter ""int tryParsable2"" from ""invalid again!"".", logs[1].Message); + var log2Values = Assert.IsAssignableFrom>>(logs[1].State); + Assert.Equal("int", log2Values[0].Value); + Assert.Equal("tryParsable2", log2Values[1].Value); + Assert.Equal("invalid again!", log2Values[2].Value); + + } + + [Fact] + public async Task RequestDelegateThrowsForTryParsableFailuresIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, [FromRoute] int tryParsable, [FromRoute] int tryParsable2) +{ + httpContext.Items["invoked"] = true; +} + +app.MapGet("/{tryParsable}/{tryParsable2}", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues["tryParsable"] = "invalid!"; + httpContext.Request.RouteValues["tryParsable2"] = "invalid again!"; + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to bind parameter ""int tryParsable"" from ""invalid!"".", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + + [Fact] + public async Task RequestDelegateThrowsForTryParsableFailuresIfThrowOnBadRequestWithArrays() + { + var source = """ +void TestAction(HttpContext httpContext, [FromQuery] int[] values) +{ + httpContext.Items["invoked"] = true; +} +app.MapGet("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Query = new QueryCollection(new Dictionary() + { + ["values"] = new(new[] { "1", "NAN", "3" }) + }); + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to bind parameter ""int[] values"" from ""NAN"".", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + + [Fact] + public async Task RequestDelegateLogsBindAsyncFailuresAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, MyBindAsyncRecord myBindAsyncParam1, MyBindAsyncRecord myBindAsyncParam2) +{ + httpContext.Items["invoked"] = true; +} +app.MapGet("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + // Not supplying any headers will cause the HttpContext BindAsync overload to return null. + var httpContext = CreateHttpContext(); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + + var logs = TestSink.Writes.ToArray(); + + Assert.Equal(2, logs.Length); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); + Assert.Equal(LogLevel.Debug, logs[0].LogLevel); + Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncParam1"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", logs[0].Message); + var log1Values = Assert.IsAssignableFrom>>(logs[0].State); + Assert.Equal("MyBindAsyncRecord", log1Values[0].Value); + Assert.Equal("myBindAsyncParam1", log1Values[1].Value); + Assert.Equal("MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo)", log1Values[2].Value); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); + Assert.Equal(LogLevel.Debug, logs[1].LogLevel); + Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncParam2"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", logs[1].Message); + var log2Values = Assert.IsAssignableFrom>>(logs[1].State); + Assert.Equal("MyBindAsyncRecord", log2Values[0].Value); + Assert.Equal("myBindAsyncParam2", log2Values[1].Value); + Assert.Equal("MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo)", log2Values[2].Value); + } + + [Fact] + public async Task RequestDelegateThrowsForBindAsyncFailuresIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, MyBindAsyncRecord myBindAsyncParam1, MyBindAsyncRecord myBindAsyncParam2) +{ + httpContext.Items["invoked"] = true; +} +app.MapGet("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + // Not supplying any headers will cause the HttpContext BindAsync overload to return null. + var httpContext = CreateHttpContext(); + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Required parameter ""MyBindAsyncRecord myBindAsyncParam1"" was not provided from MyBindAsyncRecord.BindAsync(HttpContext, ParameterInfo).", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + + [Fact] + public async Task RequestDelegateLogsSingleArgBindAsyncFailuresAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, MySimpleBindAsyncRecord mySimpleBindAsyncRecord1, MySimpleBindAsyncRecord mySimpleBindAsyncRecord2) +{ + httpContext.Items["invoked"] = true; +} +app.MapGet("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + // Not supplying any headers will cause the HttpContext BindAsync overload to return null. + var httpContext = CreateHttpContext(); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + + var logs = TestSink.Writes.ToArray(); + + Assert.Equal(2, logs.Length); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); + Assert.Equal(LogLevel.Debug, logs[0].LogLevel); + Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord1"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", logs[0].Message); + var log1Values = Assert.IsAssignableFrom>>(logs[0].State); + Assert.Equal("MySimpleBindAsyncRecord", log1Values[0].Value); + Assert.Equal("mySimpleBindAsyncRecord1", log1Values[1].Value); + Assert.Equal("MySimpleBindAsyncRecord.BindAsync(HttpContext)", log1Values[2].Value); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); + Assert.Equal(LogLevel.Debug, logs[1].LogLevel); + Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord2"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", logs[1].Message); + var log2Values = Assert.IsAssignableFrom>>(logs[1].State); + Assert.Equal("MySimpleBindAsyncRecord", log2Values[0].Value); + Assert.Equal("mySimpleBindAsyncRecord2", log2Values[1].Value); + Assert.Equal("MySimpleBindAsyncRecord.BindAsync(HttpContext)", log2Values[2].Value); + } + + [Fact] + public async Task RequestDelegateThrowsForSingleArgBindAsyncFailuresIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, MySimpleBindAsyncRecord mySimpleBindAsyncRecord1, MySimpleBindAsyncRecord mySimpleBindAsyncRecord2) +{ + httpContext.Items["invoked"] = true; +} +app.MapGet("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + // Not supplying any headers will cause the HttpContext BindAsync overload to return null. + var httpContext = CreateHttpContext(); + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Required parameter ""MySimpleBindAsyncRecord mySimpleBindAsyncRecord1"" was not provided from MySimpleBindAsyncRecord.BindAsync(HttpContext).", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateRejectsNonJsonContent(bool shouldThrow) + { + var source = """ +void TestAction(HttpContext httpContext, Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = shouldThrow); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/xml"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var request = endpoint.RequestDelegate(httpContext); + + if (shouldThrow) + { + var ex = await Assert.ThrowsAsync(() => request); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); + } + else + { + await request; + + Assert.Equal(415, httpContext.Response.StatusCode); + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); + var logValues = Assert.IsAssignableFrom>>(logMessage.State); + Assert.Equal("application/xml", logValues[0].Value); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateWithBindAndImplicitBodyRejectsNonJsonContent(bool shouldThrow) + { + var source = """ +void TestAction(HttpContext httpContext, Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = shouldThrow); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + Todo originalTodo = new() + { + Name = "Write more tests!" + }; + + var httpContext = CreateHttpContextWithBody(originalTodo); + httpContext.Request.Headers["Content-Type"] = "application/xml"; + + var request = endpoint.RequestDelegate(httpContext); + + if (shouldThrow) + { + var ex = await Assert.ThrowsAsync(() => request); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); + } + else + { + await request; + + Assert.Equal(415, httpContext.Response.StatusCode); + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); + var logValues = Assert.IsAssignableFrom>>(logMessage.State); + Assert.Equal("application/xml", logValues[0].Value); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateLogsIOExceptionsAsDebugDoesNotAbortAndNeverThrows(bool throwOnBadRequests) + { + var source = """ +void TestAction(HttpContext httpContext, [FromBody] Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = throwOnBadRequests); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var ioException = new IOException(); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(ioException); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Reading the request body failed with an IOException.", logMessage.Message); + Assert.Same(ioException, logMessage.Exception); + } + + [Fact] + public async Task RequestDelegateLogsJsonExceptionsAsDebugAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, [FromBody] Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = false); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + var jsonException = new JsonException(); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(jsonException); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(2, "InvalidJsonRequestBody"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", logMessage.Message); + Assert.Same(jsonException, logMessage.Exception); + var logValues = Assert.IsAssignableFrom>>(logMessage.State); + Assert.Equal("Todo", logValues[0].Value); + Assert.Equal("todo", logValues[1].Value); + } + + [Fact] + public async Task RequestDelegateThrowsForJsonExceptionsIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, [FromBody] Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + var jsonException = new JsonException(); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(jsonException); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + Assert.Same(jsonException, badHttpRequestException.InnerException); + } + + [Fact] + public async Task RequestDelegateLogsMalformedJsonAsDebugAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, [FromBody] Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = false); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{")); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(2, "InvalidJsonRequestBody"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", logMessage.Message); + Assert.IsType(logMessage.Exception); + var logValues = Assert.IsAssignableFrom>>(logMessage.State); + Assert.Equal("Todo", logValues[0].Value); + Assert.Equal("todo", logValues[1].Value); + } + + [Fact] + public async Task RequestDelegateThrowsForMalformedJsonIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, [FromBody] Todo todo) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/json"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{")); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to read parameter ""Todo todo"" from the request body as JSON.", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + Assert.IsType(badHttpRequestException.InnerException); + } +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs index c927a1d45e99..3e1ace1dbfe2 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.QueryParameters.cs @@ -1,71 +1,83 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Xml.Linq; -using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; -using Microsoft.AspNetCore.Mvc; +using System.Text; +using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Http.Generators.Tests; public abstract partial class RequestDelegateCreationTests { - public static object[][] MapAction_ExplicitQueryParam_StringReturn_Data + public static IEnumerable QueryParamOptionalityData { get { - var expectedBody = "TestQueryValue"; - var fromQueryRequiredSource = """app.MapGet("/", ([FromQuery] string queryValue) => queryValue);"""; - var fromQueryWithNameRequiredSource = """app.MapGet("/", ([FromQuery(Name = "queryValue")] string parameterName) => parameterName);"""; - var fromQueryWithNullNameRequiredSource = """app.MapGet("/", ([FromQuery(Name = null)] string queryValue) => queryValue);"""; - var fromQueryNullableSource = """app.MapGet("/", ([FromQuery] string? queryValue) => queryValue ?? string.Empty);"""; - var fromQueryDefaultValueSource = """ -#nullable disable -string getQueryWithDefault([FromQuery] string queryValue = null) => queryValue ?? string.Empty; -app.MapGet("/", getQueryWithDefault); -#nullable restore -"""; - - return new[] - { - new object[] { fromQueryRequiredSource, expectedBody, 200, expectedBody }, - new object[] { fromQueryRequiredSource, null, 400, string.Empty }, - new object[] { fromQueryWithNameRequiredSource, expectedBody, 200, expectedBody }, - new object[] { fromQueryWithNameRequiredSource, null, 400, string.Empty }, - new object[] { fromQueryWithNullNameRequiredSource, expectedBody, 200, expectedBody }, - new object[] { fromQueryWithNullNameRequiredSource, null, 400, string.Empty }, - new object[] { fromQueryNullableSource, expectedBody, 200, expectedBody }, - new object[] { fromQueryNullableSource, null, 200, string.Empty }, - new object[] { fromQueryDefaultValueSource, expectedBody, 200, expectedBody }, - new object[] { fromQueryDefaultValueSource, null, 200, string.Empty }, - }; + return new List + { + new object[] { @"(string name) => $""Hello {name}!""", "name", null, true, null}, + new object[] { @"(string name) => $""Hello {name}!""", "name", "TestName", false, "Hello TestName!" }, + new object[] { @"(string name = ""DefaultName"") => $""Hello {name}!""", "name", null, false, "Hello DefaultName!" }, + new object[] { @"(string name = ""DefaultName"") => $""Hello {name}!""", "name", "TestName", false, "Hello TestName!" }, + new object[] { @"(string? name) => $""Hello {name}!""", "name", null, false, "Hello !" }, + new object[] { @"(string? name) => $""Hello {name}!""", "name", "TestName", false, "Hello TestName!"}, + + new object[] { @"(int age) => $""Age: {age}""", "age", null, true, null}, + new object[] { @"(int age) => $""Age: {age}""", "age", "42", false, "Age: 42" }, + new object[] { @"(int age = 12) => $""Age: {age}""", "age", null, false, "Age: 12" }, + new object[] { @"(int age = 12) => $""Age: {age}""", "age", "42", false, "Age: 42" }, + new object[] { @"(int? age) => $""Age: {age}""", "age", null, false, "Age: " }, + new object[] { @"(int? age) => $""Age: {age}""", "age", "42", false, "Age: 42"}, + }; } } [Theory] - [MemberData(nameof(MapAction_ExplicitQueryParam_StringReturn_Data))] - public async Task MapAction_ExplicitQueryParam_StringReturn(string source, string queryValue, int expectedStatusCode, string expectedBody) + [MemberData(nameof(QueryParamOptionalityData))] + public async Task RequestDelegateHandlesQueryParamOptionality(string innerSource, string paramName, string queryParam, bool isInvalid, string expectedResponse) { - var (results, compilation) = await RunGeneratorAsync(source); - var endpoint = GetEndpointFromCompilation(compilation); - - VerifyStaticEndpointModel(results, (endpointModel) => - { - Assert.Equal("/", endpointModel.RoutePattern); - Assert.Equal("MapGet", endpointModel.HttpMethod); - var p = Assert.Single(endpointModel.Parameters); - Assert.Equal(EndpointParameterSource.Query, p.Source); - Assert.Equal("queryValue", p.LookupName); - }); + var source = $""" +string handler{innerSource}; +app.MapGet("/", handler); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); var httpContext = CreateHttpContext(); - if (queryValue is not null) + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + if (queryParam is not null) { - httpContext.Request.QueryString = new QueryString($"?queryValue={queryValue}"); + httpContext.Request.Query = new QueryCollection(new Dictionary + { + [paramName] = queryParam + }); } await endpoint.RequestDelegate(httpContext); - await VerifyResponseBodyAsync(httpContext, expectedBody, expectedStatusCode); + + var logs = TestSink.Writes.ToArray(); + + if (isInvalid) + { + Assert.Equal(400, httpContext.Response.StatusCode); + var log = Assert.Single(logs); + Assert.Equal(LogLevel.Debug, log.LogLevel); + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), log.EventId); + var expectedType = paramName == "age" ? "int age" : $"string name"; + var parameterSource = IsGeneratorEnabled ? "route or query string" : "query string"; + Assert.Equal($@"Required parameter ""{expectedType}"" was not provided from {parameterSource}.", log.Message); + } + else + { + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + Assert.Equal(expectedResponse, decodedResponseBody); + } } [Fact] diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RuntimeCreationTests.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RuntimeCreationTests.cs index b3338c85745c..77f1c0599c64 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RuntimeCreationTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RuntimeCreationTests.cs @@ -1,6 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; using Microsoft.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Http.Generators.Tests; public class RuntimeCreationTests : RequestDelegateCreationTests @@ -21,4 +26,118 @@ public async Task MapAction_BindAsync_WithWrongType_IsNotUsed(string bindAsyncTy var ex = Assert.Throws(() => GetEndpointFromCompilation(compilation)); Assert.StartsWith($"BindAsync method found on {bindAsyncType} with incorrect format.", ex.Message); } + + // TODO: https://github.com/dotnet/aspnetcore/issues/47200 + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateLogsIOExceptionsForFormAsDebugDoesNotAbortAndNeverThrows(bool throwOnBadRequests) + { + var source = """ +void TestAction(HttpContext httpContext, IFormFile file) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = throwOnBadRequests); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var ioException = new IOException(); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(ioException); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Reading the request body failed with an IOException.", logMessage.Message); + Assert.Same(ioException, logMessage.Exception); + } + + [Fact] + public async Task RequestDelegateLogsMalformedFormAsDebugAndSets400Response() + { + var source = """ +void TestAction(HttpContext httpContext, IFormFile file) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "2049"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + await endpoint.RequestDelegate(httpContext); + + Assert.Null(httpContext.Items["invoked"]); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(8, "InvalidFormRequestBody"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", logMessage.Message); + Assert.IsType(logMessage.Exception); + } + + [Fact] + public async Task RequestDelegateThrowsForMalformedFormIfThrowOnBadRequest() + { + var source = """ +void TestAction(HttpContext httpContext, IFormFile file) +{ + httpContext.Items["invoked"] = true; +} +app.MapPost("/", TestAction); +"""; + var (_, compilation) = await RunGeneratorAsync(source); + var serviceProvider = CreateServiceProvider(serviceCollection => + { + serviceCollection.Configure(options => options.ThrowOnBadRequest = true); + }); + var endpoint = GetEndpointFromCompilation(compilation, serviceProvider: serviceProvider); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "2049"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var badHttpRequestException = await Assert.ThrowsAsync(() => endpoint.RequestDelegate(httpContext)); + + Assert.Null(httpContext.Items["invoked"]); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + Assert.IsType(badHttpRequestException.InnerException); + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs index d9eff390301d..ce454702ba6d 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/SharedTypes.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http.Metadata; namespace Microsoft.AspNetCore.Http.Generators.Tests; @@ -42,6 +44,24 @@ public static bool TryParse(string input, out TryParseTodo? result) } } +[JsonPolymorphic] +[JsonDerivedType(typeof(JsonTodoChild), nameof(JsonTodoChild))] +public class JsonTodo : Todo +{ + public static async ValueTask BindAsync(HttpContext context, ParameterInfo parameter) + { + // manually call deserialize so we don't check content type + var body = await JsonSerializer.DeserializeAsync(context.Request.Body); + context.Request.Body.Position = 0; + return body; + } +} + +public class JsonTodoChild : JsonTodo +{ + public string? Child { get; set; } +} + public class CustomFromBodyAttribute : Attribute, IFromBodyMetadata { public bool AllowEmpty { get; set; } @@ -150,7 +170,7 @@ public record MyBindAsyncRecord(Uri Uri) { throw new UnreachableException($"Unexpected parameter type: {parameter.ParameterType}"); } - if (parameter.Name != "myBindAsyncParam") + if (parameter.Name?.StartsWith("myBindAsyncParam", StringComparison.Ordinal) == false) { throw new UnreachableException("Unexpected parameter name"); } @@ -406,3 +426,48 @@ public struct BodyStruct } #nullable restore + +public class ExceptionThrowingRequestBodyStream : Stream +{ + private readonly Exception _exceptionToThrow; + + public ExceptionThrowingRequestBodyStream(Exception exceptionToThrow) + { + _exceptionToThrow = exceptionToThrow; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotImplementedException(); + + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw _exceptionToThrow; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } +} diff --git a/src/Shared/RequestDelegateCreationMessages.cs b/src/Shared/RequestDelegateCreationMessages.cs new file mode 100644 index 000000000000..60f682e18288 --- /dev/null +++ b/src/Shared/RequestDelegateCreationMessages.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http; + +internal static class RequestDelegateCreationLogging +{ + public const int RequestBodyIOExceptionEventId = 1; + public const string RequestBodyIOExceptionEventName = "RequestBodyIOException"; + public const string RequestBodyIOExceptionMessage = "Reading the request body failed with an IOException."; + + public const int InvalidJsonRequestBodyEventId = 2; + public const string InvalidJsonRequestBodyEventName = "InvalidJsonRequestBody"; + public const string InvalidJsonRequestBodyLogMessage = @"Failed to read parameter ""{ParameterType} {ParameterName}"" from the request body as JSON."; + public const string InvalidJsonRequestBodyExceptionMessage = @"Failed to read parameter ""{0} {1}"" from the request body as JSON."; + + public const int ParameterBindingFailedEventId = 3; + public const string ParameterBindingFailedEventName = "ParameterBindingFailed"; + public const string ParameterBindingFailedLogMessage = @"Failed to bind parameter ""{ParameterType} {ParameterName}"" from ""{SourceValue}""."; + public const string ParameterBindingFailedExceptionMessage = @"Failed to bind parameter ""{0} {1}"" from ""{2}""."; + + public const int RequiredParameterNotProvidedEventId = 4; + public const string RequiredParameterNotProvidedEventName = "RequiredParameterNotProvided"; + public const string RequiredParameterNotProvidedLogMessage = @"Required parameter ""{ParameterType} {ParameterName}"" was not provided from {Source}."; + public const string RequiredParameterNotProvidedExceptionMessage = @"Required parameter ""{0} {1}"" was not provided from {2}."; + + public const int ImplicitBodyNotProvidedEventId = 5; + public const string ImplicitBodyNotProvidedEventName = "ImplicitBodyNotProvided"; + public const string ImplicitBodyNotProvidedLogMessage = @"Implicit body inferred for parameter ""{ParameterName}"" but no body was provided. Did you mean to use a Service instead?"; + public const string ImplicitBodyNotProvidedExceptionMessage = @"Implicit body inferred for parameter ""{0}"" but no body was provided. Did you mean to use a Service instead?"; + + public const int UnexpectedJsonContentTypeEventId = 6; + public const string UnexpectedJsonContentTypeEventName = "UnexpectedContentType"; + public const string UnexpectedJsonContentTypeLogMessage = @"Expected a supported JSON media type but got ""{ContentType}""."; + public const string UnexpectedJsonContentTypeExceptionMessage = @"Expected a supported JSON media type but got ""{0}""."; + + public const int UnexpectedFormContentTypeEventId = 7; + public const string UnexpectedFormContentTypeLogEventName = "UnexpectedNonFormContentType"; + public const string UnexpectedFormContentTypeLogMessage = @"Expected a supported form media type but got ""{ContentType}""."; + public const string UnexpectedFormContentTypeExceptionMessage = @"Expected a supported form media type but got ""{0}""."; + + public const int InvalidFormRequestBodyEventId = 8; + public const string InvalidFormRequestBodyEventName = "InvalidFormRequestBody"; + public const string InvalidFormRequestBodyLogMessage = @"Failed to read parameter ""{ParameterType} {ParameterName}"" from the request body as form."; + public const string InvalidFormRequestBodyExceptionMessage = @"Failed to read parameter ""{0} {1}"" from the request body as form."; +} diff --git a/src/Shared/RoslynUtils/SymbolExtensions.cs b/src/Shared/RoslynUtils/SymbolExtensions.cs index 7d4410e68de8..42ff4587a90a 100644 --- a/src/Shared/RoslynUtils/SymbolExtensions.cs +++ b/src/Shared/RoslynUtils/SymbolExtensions.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure; @@ -123,6 +124,13 @@ parameterSymbol.Type is INamedTypeSymbol NullableAnnotation: NullableAnnotation.Annotated } || parameterSymbol.HasExplicitDefaultValue; + public static string GetDefaultValueString(this IParameterSymbol parameterSymbol) + { + return !parameterSymbol.HasExplicitDefaultValue + ? "null" + : SymbolDisplay.FormatLiteral((parameterSymbol.ExplicitDefaultValue ?? "null").ToString(), parameterSymbol.ExplicitDefaultValue is string); + } + public static bool TryGetNamedArgumentValue(this AttributeData attribute, string argumentName, out T? argumentValue) { argumentValue = default; From ae93f2b9d06b4e45064b928ea3101363f0e7b7ba Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 13 Apr 2023 15:22:22 +0800 Subject: [PATCH 19/24] Log if request reaches the end of request pipeline --- .../Http/src/Builder/ApplicationBuilder.cs | 26 ++++++++- src/Http/Http/test/ApplicationBuilderTests.cs | 57 ++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/Http/Http/src/Builder/ApplicationBuilder.cs b/src/Http/Http/src/Builder/ApplicationBuilder.cs index f68a2777092d..f7e8fd507a66 100644 --- a/src/Http/Http/src/Builder/ApplicationBuilder.cs +++ b/src/Http/Http/src/Builder/ApplicationBuilder.cs @@ -3,14 +3,16 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Builder; /// /// Default implementation for . /// -public class ApplicationBuilder : IApplicationBuilder +public partial class ApplicationBuilder : IApplicationBuilder { private const string ServerFeaturesKey = "server.Features"; private const string ApplicationServicesKey = "application.Services"; @@ -117,8 +119,22 @@ public IApplicationBuilder New() /// The . public RequestDelegate Build() { + var loggerFactory = ApplicationServices?.GetService(); + var logger = loggerFactory?.CreateLogger(); + RequestDelegate app = context => { + if (logger != null && logger.IsEnabled(LogLevel.Information)) + { + Log.RequestPipelineEnd(logger, + context.Request.Protocol, + context.Request.Method, + context.Request.Scheme, + context.Request.Host.Value, + context.Request.PathBase.Value, + context.Request.Path.Value); + } + // If we reach the end of the pipeline, but we have an endpoint, then something unexpected has happened. // This could happen if user code sets an endpoint, but they forgot to add the UseEndpoint middleware. var endpoint = context.GetEndpoint(); @@ -149,4 +165,12 @@ public RequestDelegate Build() return app; } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Information, + "Request reach the end of the middleware pipeline {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path}", + SkipEnabledCheck = true)] + public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path); + } } diff --git a/src/Http/Http/test/ApplicationBuilderTests.cs b/src/Http/Http/test/ApplicationBuilderTests.cs index 3211e3b0344d..48241c423f7d 100644 --- a/src/Http/Http/test/ApplicationBuilderTests.cs +++ b/src/Http/Http/test/ApplicationBuilderTests.cs @@ -3,20 +3,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Builder.Internal; -public class ApplicationBuilderTests +public class ApplicationBuilderTests : LoggedTest { [Fact] - public void BuildReturnsCallableDelegate() + public async Task BuildReturnsCallableDelegate() { var builder = new ApplicationBuilder(null); var app = builder.Build(); var httpContext = new DefaultHttpContext(); - app.Invoke(httpContext); + await app.Invoke(httpContext); Assert.Equal(404, httpContext.Response.StatusCode); } @@ -74,6 +76,55 @@ public async Task BuildImplicitlyThrowsForMatchedEndpointAsLastStep() Assert.False(endpointCalled); } + [Fact] + public async Task BuildLogAtRequestPipelineEnd() + { + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory); + var serviceProvider = services.BuildServiceProvider(); + + var builder = new ApplicationBuilder(serviceProvider); + var app = builder.Build(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Protocol = "HTTP/2"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Method = "GET"; + httpContext.Request.Host = new HostString("localhost:5000"); + httpContext.Request.Path = "/path"; + httpContext.Request.PathBase = "/pathbase"; + httpContext.Request.QueryString = new QueryString("?query=true"); + + await app.Invoke(httpContext); + + Assert.Equal(404, httpContext.Response.StatusCode); + + var log = TestSink.Writes.Single(w => w.EventId.Name == "RequestPipelineEnd"); + Assert.Equal("Request reach the end of the middleware pipeline HTTP/2 GET https://localhost:5000/pathbase/path", log.Message); + } + + [Fact] + public async Task BuildDontLogOrChangeStatusWithTerminalMiddleware() + { + var services = new ServiceCollection(); + services.AddSingleton(LoggerFactory); + var serviceProvider = services.BuildServiceProvider(); + + var builder = new ApplicationBuilder(serviceProvider); + builder.Use((HttpContext context, RequestDelegate next) => + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }); + var app = builder.Build(); + + var httpContext = new DefaultHttpContext(); + await app.Invoke(httpContext); + + Assert.Equal(StatusCodes.Status204NoContent, httpContext.Response.StatusCode); + Assert.DoesNotContain(TestSink.Writes, w => w.EventId.Name == "RequestPipelineEnd"); + } + [Fact] public void BuildDoesNotCallMatchedEndpointWhenTerminated() { From 612478cbe47af5000135036e1fd0343cc83bbd00 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 13 Apr 2023 16:56:57 +0800 Subject: [PATCH 20/24] Apply suggestions from code review --- src/Http/Http/src/Builder/ApplicationBuilder.cs | 2 +- src/Http/Http/test/ApplicationBuilderTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Http/src/Builder/ApplicationBuilder.cs b/src/Http/Http/src/Builder/ApplicationBuilder.cs index f7e8fd507a66..8eb4976ee76f 100644 --- a/src/Http/Http/src/Builder/ApplicationBuilder.cs +++ b/src/Http/Http/src/Builder/ApplicationBuilder.cs @@ -169,7 +169,7 @@ public RequestDelegate Build() private static partial class Log { [LoggerMessage(1, LogLevel.Information, - "Request reach the end of the middleware pipeline {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path}", + "Request reached the end of the middleware pipeline {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path}", SkipEnabledCheck = true)] public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path); } diff --git a/src/Http/Http/test/ApplicationBuilderTests.cs b/src/Http/Http/test/ApplicationBuilderTests.cs index 48241c423f7d..fc9d149af158 100644 --- a/src/Http/Http/test/ApplicationBuilderTests.cs +++ b/src/Http/Http/test/ApplicationBuilderTests.cs @@ -100,7 +100,7 @@ public async Task BuildLogAtRequestPipelineEnd() Assert.Equal(404, httpContext.Response.StatusCode); var log = TestSink.Writes.Single(w => w.EventId.Name == "RequestPipelineEnd"); - Assert.Equal("Request reach the end of the middleware pipeline HTTP/2 GET https://localhost:5000/pathbase/path", log.Message); + Assert.Equal("Request reached the end of the middleware pipeline HTTP/2 GET https://localhost:5000/pathbase/path", log.Message); } [Fact] From f7fbb4b1c9c74f418cbc3a8654a9aa8711bc7388 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 14 Apr 2023 07:12:00 +0800 Subject: [PATCH 21/24] PR feedback --- .../Http/src/Builder/ApplicationBuilder.cs | 28 ++++++++++--------- src/Http/Http/test/ApplicationBuilderTests.cs | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Http/Http/src/Builder/ApplicationBuilder.cs b/src/Http/Http/src/Builder/ApplicationBuilder.cs index 8eb4976ee76f..14d7c4d63dfe 100644 --- a/src/Http/Http/src/Builder/ApplicationBuilder.cs +++ b/src/Http/Http/src/Builder/ApplicationBuilder.cs @@ -124,17 +124,6 @@ public RequestDelegate Build() RequestDelegate app = context => { - if (logger != null && logger.IsEnabled(LogLevel.Information)) - { - Log.RequestPipelineEnd(logger, - context.Request.Protocol, - context.Request.Method, - context.Request.Scheme, - context.Request.Host.Value, - context.Request.PathBase.Value, - context.Request.Path.Value); - } - // If we reach the end of the pipeline, but we have an endpoint, then something unexpected has happened. // This could happen if user code sets an endpoint, but they forgot to add the UseEndpoint middleware. var endpoint = context.GetEndpoint(); @@ -155,6 +144,19 @@ public RequestDelegate Build() { context.Response.StatusCode = StatusCodes.Status404NotFound; } + + if (logger != null && logger.IsEnabled(LogLevel.Information)) + { + Log.RequestPipelineEnd(logger, + context.Request.Protocol, + context.Request.Method, + context.Request.Scheme, + context.Request.Host.Value, + context.Request.PathBase.Value, + context.Request.Path.Value, + context.Response.StatusCode); + } + return Task.CompletedTask; }; @@ -169,8 +171,8 @@ public RequestDelegate Build() private static partial class Log { [LoggerMessage(1, LogLevel.Information, - "Request reached the end of the middleware pipeline {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path}", + "Request reached the end of the middleware pipeline without being handled by application code {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path} - {statusCode}", SkipEnabledCheck = true)] - public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path); + public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path, int statusCode); } } diff --git a/src/Http/Http/test/ApplicationBuilderTests.cs b/src/Http/Http/test/ApplicationBuilderTests.cs index fc9d149af158..f3a173fee17c 100644 --- a/src/Http/Http/test/ApplicationBuilderTests.cs +++ b/src/Http/Http/test/ApplicationBuilderTests.cs @@ -100,7 +100,7 @@ public async Task BuildLogAtRequestPipelineEnd() Assert.Equal(404, httpContext.Response.StatusCode); var log = TestSink.Writes.Single(w => w.EventId.Name == "RequestPipelineEnd"); - Assert.Equal("Request reached the end of the middleware pipeline HTTP/2 GET https://localhost:5000/pathbase/path", log.Message); + Assert.Equal("Request reached the end of the middleware pipeline without being handled by application code HTTP/2 GET https://localhost:5000/pathbase/path - 404", log.Message); } [Fact] From fa6af1ffb82d9aa9c0af0a67479051b5cf825119 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 14 Apr 2023 07:30:40 +0800 Subject: [PATCH 22/24] Update src/Http/Http/src/Builder/ApplicationBuilder.cs Co-authored-by: Brennan --- src/Http/Http/src/Builder/ApplicationBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Http/src/Builder/ApplicationBuilder.cs b/src/Http/Http/src/Builder/ApplicationBuilder.cs index 14d7c4d63dfe..f01dbc490546 100644 --- a/src/Http/Http/src/Builder/ApplicationBuilder.cs +++ b/src/Http/Http/src/Builder/ApplicationBuilder.cs @@ -171,7 +171,7 @@ public RequestDelegate Build() private static partial class Log { [LoggerMessage(1, LogLevel.Information, - "Request reached the end of the middleware pipeline without being handled by application code {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path} - {statusCode}", + "Request reached the end of the middleware pipeline without being handled by application code {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path} - {StatusCode}", SkipEnabledCheck = true)] public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path, int statusCode); } From 0bb56a7ed0aa89307d05f5041ae5d92acfc57007 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 14 Apr 2023 07:43:28 +0800 Subject: [PATCH 23/24] Update src/Http/Http/test/ApplicationBuilderTests.cs Co-authored-by: Brennan --- src/Http/Http/test/ApplicationBuilderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Http/test/ApplicationBuilderTests.cs b/src/Http/Http/test/ApplicationBuilderTests.cs index f3a173fee17c..4073c8f71635 100644 --- a/src/Http/Http/test/ApplicationBuilderTests.cs +++ b/src/Http/Http/test/ApplicationBuilderTests.cs @@ -104,7 +104,7 @@ public async Task BuildLogAtRequestPipelineEnd() } [Fact] - public async Task BuildDontLogOrChangeStatusWithTerminalMiddleware() + public async Task BuildDoesNotLogOrChangeStatusWithTerminalMiddleware() { var services = new ServiceCollection(); services.AddSingleton(LoggerFactory); From b7e2156081d2fc59a79590e84506a0a5471e2474 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 18 Apr 2023 16:07:14 +0800 Subject: [PATCH 24/24] Update log message --- src/Http/Http/src/Builder/ApplicationBuilder.cs | 5 ++--- src/Http/Http/test/ApplicationBuilderTests.cs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Http/Http/src/Builder/ApplicationBuilder.cs b/src/Http/Http/src/Builder/ApplicationBuilder.cs index f01dbc490546..9331d504041f 100644 --- a/src/Http/Http/src/Builder/ApplicationBuilder.cs +++ b/src/Http/Http/src/Builder/ApplicationBuilder.cs @@ -148,7 +148,6 @@ public RequestDelegate Build() if (logger != null && logger.IsEnabled(LogLevel.Information)) { Log.RequestPipelineEnd(logger, - context.Request.Protocol, context.Request.Method, context.Request.Scheme, context.Request.Host.Value, @@ -171,8 +170,8 @@ public RequestDelegate Build() private static partial class Log { [LoggerMessage(1, LogLevel.Information, - "Request reached the end of the middleware pipeline without being handled by application code {Protocol} {Method} {Scheme}://{Host}{PathBase}{Path} - {StatusCode}", + "Request reached the end of the middleware pipeline without being handled by application code. Request path: {Method} {Scheme}://{Host}{PathBase}{Path}, Response status code: {StatusCode}", SkipEnabledCheck = true)] - public static partial void RequestPipelineEnd(ILogger logger, string protocol, string method, string scheme, string host, string? pathBase, string? path, int statusCode); + public static partial void RequestPipelineEnd(ILogger logger, string method, string scheme, string host, string? pathBase, string? path, int statusCode); } } diff --git a/src/Http/Http/test/ApplicationBuilderTests.cs b/src/Http/Http/test/ApplicationBuilderTests.cs index 4073c8f71635..a661288735e9 100644 --- a/src/Http/Http/test/ApplicationBuilderTests.cs +++ b/src/Http/Http/test/ApplicationBuilderTests.cs @@ -100,7 +100,7 @@ public async Task BuildLogAtRequestPipelineEnd() Assert.Equal(404, httpContext.Response.StatusCode); var log = TestSink.Writes.Single(w => w.EventId.Name == "RequestPipelineEnd"); - Assert.Equal("Request reached the end of the middleware pipeline without being handled by application code HTTP/2 GET https://localhost:5000/pathbase/path - 404", log.Message); + Assert.Equal("Request reached the end of the middleware pipeline without being handled by application code. Request path: GET https://localhost:5000/pathbase/path, Response status code: 404", log.Message); } [Fact]