From 6a45d0c52394e08387d7041346c7ae08d4899b3f Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 23 Mar 2022 16:24:11 -0700 Subject: [PATCH] Add AspNetCoreKernelExtension --- dotnet-interactive.sln | 15 ++ .../AspNetCoreTests.cs | 239 ++++++++++++++++++ .../Directory.Build.props | 9 + ...DotNet.Interactive.AspNetCore.Tests.csproj | 39 +++ .../AspNetCoreCSharpKernelExtensions.cs | 8 +- .../AspNetCoreKernelExtension.cs | 24 ++ .../InteractiveHost.cs | 7 +- 7 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/Microsoft.DotNet.Interactive.AspNetCore.Tests/AspNetCoreTests.cs create mode 100644 src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Directory.Build.props create mode 100644 src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Microsoft.DotNet.Interactive.AspNetCore.Tests.csproj create mode 100644 src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreKernelExtension.cs diff --git a/dotnet-interactive.sln b/dotnet-interactive.sln index 7680ab4a2f..b1c8a39526 100644 --- a/dotnet-interactive.sln +++ b/dotnet-interactive.sln @@ -100,6 +100,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Interactiv EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Interactive.CSharpProject.Tests", "src\Microsoft.DotNet.Interactive.CSharpProject.Tests\Microsoft.DotNet.Interactive.CSharpProject.Tests.csproj", "{BF76C062-25C2-4E90-B979-A6D4B8AE04D1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Interactive.AspNetCore.Tests", "src\Microsoft.DotNet.Interactive.AspNetCore.Tests\Microsoft.DotNet.Interactive.AspNetCore.Tests.csproj", "{5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -554,6 +556,18 @@ Global {BF76C062-25C2-4E90-B979-A6D4B8AE04D1}.Release|x64.Build.0 = Release|Any CPU {BF76C062-25C2-4E90-B979-A6D4B8AE04D1}.Release|x86.ActiveCfg = Release|Any CPU {BF76C062-25C2-4E90-B979-A6D4B8AE04D1}.Release|x86.Build.0 = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|x64.Build.0 = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Debug|x86.Build.0 = Debug|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|Any CPU.Build.0 = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|x64.ActiveCfg = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|x64.Build.0 = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|x86.ActiveCfg = Release|Any CPU + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -596,6 +610,7 @@ Global {CA55B4D7-ABE1-4474-9D4F-ACE235358FD6} = {11BA3480-4584-435C-BA9A-8C554DB60E9F} {25A1C91A-0B0F-4023-B95D-2C718327DFF1} = {B95A8485-8C53-4F56-B0CE-19C0726B5805} {BF76C062-25C2-4E90-B979-A6D4B8AE04D1} = {11BA3480-4584-435C-BA9A-8C554DB60E9F} + {5934AEAA-EB91-4CD7-8E40-DCA8289A6EB7} = {11BA3480-4584-435C-BA9A-8C554DB60E9F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6D05A9AF-CFFB-4187-8599-574387B76727} diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/AspNetCoreTests.cs b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/AspNetCoreTests.cs new file mode 100644 index 0000000000..d87e3d06a7 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/AspNetCoreTests.cs @@ -0,0 +1,239 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.DotNet.Interactive.AspNetCore; +using Microsoft.DotNet.Interactive.Commands; +using Microsoft.DotNet.Interactive.CSharp; +using Microsoft.DotNet.Interactive.Events; +using Microsoft.DotNet.Interactive.Tests.Utility; +using Xunit; + +namespace Microsoft.DotNet.Interactive.App.Tests +{ + public class AspNetCoreTests : IDisposable + { + private readonly CompositeKernel _kernel; + + public AspNetCoreTests() + { + _kernel = new CompositeKernel + { + new CSharpKernel(), + }; + + var loadTask = new AspNetCoreKernelExtension().OnLoadAsync(_kernel); + Assert.Same(Task.CompletedTask, loadTask); + } + + public void Dispose() + { + _kernel.Dispose(); + } + + [Fact] + public async Task can_define_aspnet_endpoint_with_MapGet() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +Endpoints.MapGet(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapGet!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapGet!"); + } + + [Fact] + public async Task can_redefine_aspnet_endpoint_with_MapInteractive() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +Endpoints.MapGet(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapGet!""); +}); + +Endpoints.MapInteractive(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapInteractive!""); +}); + +Endpoints.MapInteractive(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapInteractive 2!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapInteractive 2!"); + } + + [Fact] + public async Task can_define_aspnet_middleware_with_Use() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +App.Use(next => +{ + return async httpContext => + { + await httpContext.Response.WriteAsync(""Hello from middleware!""); + }; +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from middleware!"); + } + + [Fact] + public async Task endpoints_take_precedence_over_new_middleware() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +App.Use(next => +{ + return async httpContext => + { + await httpContext.Response.WriteAsync(""Hello from middleware!""); + }; +}); + +Endpoints.MapGet(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapGet!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapGet!"); + + // Re-adding the middleware makes no difference since it's added to the end of the pipeline. + var result2 = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +App.Use(next => +{ + return async httpContext => + { + await httpContext.Response.WriteAsync(""Hello from middleware!""); + }; +}); + +await HttpClient.GetAsync(""/"")")); + + result2.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapGet!"); + } + + [Fact] + public async Task repeatedly_invoking_aspnet_command_noops() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet +#!aspnet + +Endpoints.MapGet(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapGet!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapGet!"); + } + + [Fact] + public async Task aspnet_command_is_only_necessary_in_first_submission() + { + var commandResult = await _kernel.SendAsync(new SubmitCode("#!aspnet")); + + commandResult.KernelEvents.ToSubscribedList().Should().NotContainErrors(); + + var result = await _kernel.SendAsync(new SubmitCode(@" +Endpoints.MapGet(""/"", async context => +{ + await context.Response.WriteAsync($""Hello from MapGet!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Hello from MapGet!"); + } + + [Fact] + public async Task result_includes_trace_level_logs() + { + var commandResult = await _kernel.SendAsync(new SubmitCode("#!aspnet")); + + commandResult.KernelEvents.ToSubscribedList().Should().NotContainErrors(); + + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +Endpoints.MapGet(""/"", async httpContext => +{ + var loggerFactory = httpContext.RequestServices.GetRequiredService(); + var logger = loggerFactory.CreateLogger(""interactive""); + logger.LogTrace(""Log from MapGet!""); + + await httpContext.Response.WriteAsync(""Hello from MapGet!""); +}); + +await HttpClient.GetAsync(""/"")")); + + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.FormattedValues.Should().ContainSingle(f => f.MimeType == "text/html") + .Which.Value.Should().Contain("Log from MapGet!"); + } + + [Fact] + public async Task server_listens_on_ephemeral_port() + { + var result = await _kernel.SendAsync(new SubmitCode(@" +#!aspnet + +HttpClient.BaseAddress")); + + // Assume any port higher than 1000 is ephemeral. In practice, the start of the ephemeral port range is + // usually even higher (Windows XP and older Windows releases notwithstanding). + // https://en.wikipedia.org/wiki/Ephemeral_port + result.KernelEvents.ToSubscribedList().Should().NotContainErrors() + .And.ContainSingle() + .Which.Value.Should().Match(uri => uri.As().Port > 1_000); + } + } +} diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Directory.Build.props b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Directory.Build.props new file mode 100644 index 0000000000..47aea44a94 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Directory.Build.props @@ -0,0 +1,9 @@ + + + + true + + + + + diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Microsoft.DotNet.Interactive.AspNetCore.Tests.csproj b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Microsoft.DotNet.Interactive.AspNetCore.Tests.csproj new file mode 100644 index 0000000000..02ee520379 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.AspNetCore.Tests/Microsoft.DotNet.Interactive.AspNetCore.Tests.csproj @@ -0,0 +1,39 @@ + + + + net6.0 + $(NoWarn);VSTHRD200 + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreCSharpKernelExtensions.cs b/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreCSharpKernelExtensions.cs index 76c5f5a3eb..046b2d2682 100644 --- a/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreCSharpKernelExtensions.cs +++ b/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreCSharpKernelExtensions.cs @@ -3,7 +3,6 @@ using System; using System.CommandLine; -using System.CommandLine.Invocation; using System.CommandLine.NamingConventionBinder; using System.Linq; using System.Net.Http; @@ -92,7 +91,6 @@ public static CSharpKernel UseAspNetCore(this CSharpKernel kernel) }; kernel.AddDirective(directive); - kernel.AddDirective(new Command("#!aspnet-stop", "Stop ASP.NET Core host") { Handler = CommandHandler.Create(async () => @@ -108,6 +106,12 @@ public static CSharpKernel UseAspNetCore(this CSharpKernel kernel) }) }); + kernel.RegisterForDisposal(() => + { + interactiveHost?.Dispose(); + interactiveHost = null; + }); + Formatter.Register((responseMessage, context) => { // Formatter.Register() doesn't support async formatters yet. diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreKernelExtension.cs b/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreKernelExtension.cs new file mode 100644 index 0000000000..1d967aaa7f --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.AspNetCore/AspNetCoreKernelExtension.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Threading.Tasks; +using Microsoft.DotNet.Interactive.CSharp; + +namespace Microsoft.DotNet.Interactive.AspNetCore +{ + public class AspNetCoreKernelExtension : IKernelExtension + { + public Task OnLoadAsync(Kernel kernel) + { + kernel.VisitSubkernelsAndSelf(kernel => + { + if (kernel is CSharpKernel cSharpKernel) + { + cSharpKernel.UseAspNetCore(); + } + }); + + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.DotNet.Interactive.AspNetCore/InteractiveHost.cs b/src/Microsoft.DotNet.Interactive.AspNetCore/InteractiveHost.cs index 17d851bc1d..0da5248db9 100644 --- a/src/Microsoft.DotNet.Interactive.AspNetCore/InteractiveHost.cs +++ b/src/Microsoft.DotNet.Interactive.AspNetCore/InteractiveHost.cs @@ -16,7 +16,7 @@ namespace Microsoft.DotNet.Interactive.AspNetCore { - internal class InteractiveHost : IAsyncDisposable + internal class InteractiveHost : IAsyncDisposable, IDisposable { private readonly IHost _host; private readonly Startup _startup; @@ -63,5 +63,10 @@ public ValueTask DisposeAsync() _host.Dispose(); return default; } + + public void Dispose() + { + _host.Dispose(); + } } }