From 1502dd9a3d3c53923ed6c87dddfbecd31b448ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 28 Nov 2024 17:28:32 +0100 Subject: [PATCH] Add adapter for ASP.NET Core (#568) --- Adapters/AspNetCore/Adapter.cs | 83 ++++++++++ .../GenHTTP.Adapters.AspNetCore.csproj | 62 +++++++ Adapters/AspNetCore/Mapping/Bridge.cs | 110 +++++++++++++ Adapters/AspNetCore/Server/EmptyEndpoints.cs | 8 + Adapters/AspNetCore/Server/ImplicitServer.cs | 56 +++++++ .../AspNetCore/Types}/ClientConnection.cs | 2 +- .../AspNetCore}/Types/Cookies.cs | 3 +- .../AspNetCore}/Types/Headers.cs | 2 +- .../AspNetCore}/Types/Query.cs | 2 +- .../AspNetCore}/Types/Request.cs | 14 +- Engine/Kestrel/GenHTTP.Engine.Kestrel.csproj | 2 + Engine/Kestrel/Hosting/KestrelServer.cs | 91 +---------- Engine/Shared/Infrastructure/ServerBuilder.cs | 2 - GenHTTP.sln | 9 + .../Adapters/AspNetCore/IntegrationTests.cs | 154 ++++++++++++++++++ .../GenHTTP.Testing.Acceptance.csproj | 2 + 16 files changed, 506 insertions(+), 96 deletions(-) create mode 100644 Adapters/AspNetCore/Adapter.cs create mode 100644 Adapters/AspNetCore/GenHTTP.Adapters.AspNetCore.csproj create mode 100644 Adapters/AspNetCore/Mapping/Bridge.cs create mode 100644 Adapters/AspNetCore/Server/EmptyEndpoints.cs create mode 100644 Adapters/AspNetCore/Server/ImplicitServer.cs rename {Engine/Kestrel/Hosting => Adapters/AspNetCore/Types}/ClientConnection.cs (91%) rename {Engine/Kestrel => Adapters/AspNetCore}/Types/Cookies.cs (84%) rename {Engine/Kestrel => Adapters/AspNetCore}/Types/Headers.cs (93%) rename {Engine/Kestrel => Adapters/AspNetCore}/Types/Query.cs (92%) rename {Engine/Kestrel => Adapters/AspNetCore}/Types/Request.cs (85%) create mode 100644 Testing/Acceptance/Adapters/AspNetCore/IntegrationTests.cs diff --git a/Adapters/AspNetCore/Adapter.cs b/Adapters/AspNetCore/Adapter.cs new file mode 100644 index 00000000..90597f02 --- /dev/null +++ b/Adapters/AspNetCore/Adapter.cs @@ -0,0 +1,83 @@ +using GenHTTP.Adapters.AspNetCore.Mapping; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Modules.ClientCaching; +using GenHTTP.Modules.Compression; +using GenHTTP.Modules.ErrorHandling; +using GenHTTP.Modules.IO; + +using Microsoft.AspNetCore.Builder; + +namespace GenHTTP.Adapters.AspNetCore; + +public static class Adapter +{ + + /// + /// Registers the given handler to respond to requests to the specified path. + /// + /// The application to add the mapping to + /// The path to register the handler for + /// The handler to be registered + /// An object that will be informed about handled requests and any error + public static void Map(this WebApplication app, string path, IHandlerBuilder handler, IServerCompanion? companion = null) + => Map(app, path, handler.Build(), companion); + + /// + /// Registers the given handler to respond to requests to the specified path. + /// + /// The application to add the mapping to + /// The path to register the handler for + /// The handler to be registered + /// An object that will be informed about handled requests and any error + public static void Map(this WebApplication app, string path, IHandler handler, IServerCompanion? companion = null) + => app.Map(path + "/{*any}", async (context) => await Bridge.MapAsync(context, handler, companion: companion, registeredPath: path)); + + /// + /// Registers the given handler to respond to any request. + /// + /// The application to be configured + /// The handler to be registered + /// The server instance that would like to execute requests + public static void Run(this IApplicationBuilder app, IHandler handler, IServer server) + => app.Run(async (context) => await Bridge.MapAsync(context, handler, server)); + + /// + /// Enables default features on the given handler. This should be used on the + /// outer-most handler only. + /// + /// The handler to be configured + /// If enabled, any exception will be catched and converted into an error response + /// If enabled, responses will automatically be compressed if possible + /// If enabled, ETags are attached to any generated response and the tag is evaluated on the next request of the same resource + /// If enabled, clients can request ranges instead of the complete response body + /// The type of the handler builder which will be returned to allow the factory pattern + /// The handler builder instance to be chained + public static T Defaults(this T builder, bool errorHandling = true, bool compression = true, bool clientCaching = true, bool rangeSupport = false) where T : IHandlerBuilder + { + if (compression) + { + builder.Add(CompressedContent.Default()); + } + + if (rangeSupport) + { + builder.Add(RangeSupport.Create()); + } + + if (clientCaching) + { + builder.Add(ClientCache.Validation()); + } + + if (errorHandling) + { + builder.Add(ErrorHandler.Default()); + } + + return builder; + } + +} diff --git a/Adapters/AspNetCore/GenHTTP.Adapters.AspNetCore.csproj b/Adapters/AspNetCore/GenHTTP.Adapters.AspNetCore.csproj new file mode 100644 index 00000000..3843dd25 --- /dev/null +++ b/Adapters/AspNetCore/GenHTTP.Adapters.AspNetCore.csproj @@ -0,0 +1,62 @@ + + + + + net8.0;net9.0 + + 13.0 + enable + true + enable + + 9.2.0.0 + 9.2.0.0 + 9.2.0 + + Andreas Nägeli + + + LICENSE + https://genhttp.org/ + + Adapter to run GenHTTP handlers within an ASP.NET Core app. + ASP.NET Core Adapter GenHTTP + + true + true + snupkg + + true + CS1591,CS1587,CS1572,CS1573 + + icon.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Adapters/AspNetCore/Mapping/Bridge.cs b/Adapters/AspNetCore/Mapping/Bridge.cs new file mode 100644 index 00000000..90711221 --- /dev/null +++ b/Adapters/AspNetCore/Mapping/Bridge.cs @@ -0,0 +1,110 @@ +using GenHTTP.Adapters.AspNetCore.Server; +using GenHTTP.Adapters.AspNetCore.Types; +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using Microsoft.AspNetCore.Http; + +namespace GenHTTP.Adapters.AspNetCore.Mapping; + +public static class Bridge +{ + + public static async ValueTask MapAsync(HttpContext context, IHandler handler, IServer? server = null, IServerCompanion? companion = null, string? registeredPath = null) + { + var actualServer = server ?? new ImplicitServer(handler, companion); + + try + { + using var request = new Request(actualServer, context); + + if (registeredPath != null) + { + AdvanceTo(request, registeredPath); + } + + using var response = await handler.HandleAsync(request); + + if (response == null) + { + context.Response.StatusCode = 404; + } + else + { + await WriteAsync(response, context); + + actualServer.Companion?.OnRequestHandled(request, response); + } + } + catch (Exception e) + { + actualServer.Companion?.OnServerError(ServerErrorScope.ServerConnection, context.Connection.RemoteIpAddress, e); + throw; + } + } + + private static async ValueTask WriteAsync(IResponse response, HttpContext context) + { + var target = context.Response; + + target.StatusCode = response.Status.RawStatus; + + foreach (var header in response.Headers) + { + target.Headers.Append(header.Key, header.Value); + } + + if (response.Modified != null) + { + target.Headers.LastModified = response.Modified.Value.ToUniversalTime().ToString("r"); + } + + if (response.Expires != null) + { + target.Headers.Expires = response.Expires.Value.ToUniversalTime().ToString("r"); + } + + if (response.HasCookies) + { + foreach (var cookie in response.Cookies) + { + if (cookie.Value.MaxAge != null) + { + target.Cookies.Append(cookie.Key, cookie.Value.Value, new() + { + MaxAge = TimeSpan.FromSeconds(cookie.Value.MaxAge.Value) + }); + } + else + { + target.Cookies.Append(cookie.Key, cookie.Value.Value); + } + } + } + + if (response.Content != null) + { + target.ContentLength = (long?)response.ContentLength ?? (long?)response.Content.Length; + + target.ContentType = response.ContentType?.Charset != null ? $"{response.ContentType?.RawType}; charset={response.ContentType?.Charset}" : response.ContentType?.RawType; + + if (response.ContentEncoding != null) + { + target.Headers.ContentEncoding = response.ContentEncoding; + } + + await response.Content.WriteAsync(target.Body, 65 * 1024); + } + } + + private static void AdvanceTo(Request request, string registeredPath) + { + var parts = registeredPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + + foreach (var _ in parts) + { + request.Target.Advance(); + } + } + +} diff --git a/Adapters/AspNetCore/Server/EmptyEndpoints.cs b/Adapters/AspNetCore/Server/EmptyEndpoints.cs new file mode 100644 index 00000000..5fe11c31 --- /dev/null +++ b/Adapters/AspNetCore/Server/EmptyEndpoints.cs @@ -0,0 +1,8 @@ +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Adapters.AspNetCore.Server; + +public class EmptyEndpoints : List, IEndPointCollection +{ + +} diff --git a/Adapters/AspNetCore/Server/ImplicitServer.cs b/Adapters/AspNetCore/Server/ImplicitServer.cs new file mode 100644 index 00000000..cf5acfea --- /dev/null +++ b/Adapters/AspNetCore/Server/ImplicitServer.cs @@ -0,0 +1,56 @@ +using System.Runtime.InteropServices; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Adapters.AspNetCore.Server; + +public sealed class ImplicitServer : IServer +{ + + #region Get-/Setters + + public string Version => RuntimeInformation.FrameworkDescription; + + public bool Running { get; } + + public bool Development + { + get + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return string.Compare(env, "Development", StringComparison.OrdinalIgnoreCase) == 0; + } + } + + public IEndPointCollection EndPoints { get; } + + public IServerCompanion? Companion { get; } + + public IHandler Handler { get; } + + #endregion + + #region Initialization + + public ImplicitServer(IHandler handler, IServerCompanion? companion) + { + Handler = handler; + Companion = companion; + + EndPoints = new EmptyEndpoints(); + + Running = true; + } + + #endregion + + #region Functionality + + public ValueTask DisposeAsync() => new(); + + public ValueTask StartAsync() => throw new InvalidOperationException("Server is managed by ASP.NET Core and cannot be started"); + + #endregion + +} diff --git a/Engine/Kestrel/Hosting/ClientConnection.cs b/Adapters/AspNetCore/Types/ClientConnection.cs similarity index 91% rename from Engine/Kestrel/Hosting/ClientConnection.cs rename to Adapters/AspNetCore/Types/ClientConnection.cs index d8b5cc11..207c053e 100644 --- a/Engine/Kestrel/Hosting/ClientConnection.cs +++ b/Adapters/AspNetCore/Types/ClientConnection.cs @@ -4,7 +4,7 @@ using GenHTTP.Api.Protocol; using Microsoft.AspNetCore.Http; -namespace GenHTTP.Engine.Kestrel.Hosting; +namespace GenHTTP.Adapters.AspNetCore.Types; public sealed class ClientConnection : IClientConnection { diff --git a/Engine/Kestrel/Types/Cookies.cs b/Adapters/AspNetCore/Types/Cookies.cs similarity index 84% rename from Engine/Kestrel/Types/Cookies.cs rename to Adapters/AspNetCore/Types/Cookies.cs index f9836552..e0ce67d7 100644 --- a/Engine/Kestrel/Types/Cookies.cs +++ b/Adapters/AspNetCore/Types/Cookies.cs @@ -1,8 +1,7 @@ using GenHTTP.Api.Protocol; - using Microsoft.AspNetCore.Http; -namespace GenHTTP.Engine.Kestrel.Types; +namespace GenHTTP.Adapters.AspNetCore.Types; public sealed class Cookies : Dictionary, ICookieCollection { diff --git a/Engine/Kestrel/Types/Headers.cs b/Adapters/AspNetCore/Types/Headers.cs similarity index 93% rename from Engine/Kestrel/Types/Headers.cs rename to Adapters/AspNetCore/Types/Headers.cs index 826df3b9..975beefd 100644 --- a/Engine/Kestrel/Types/Headers.cs +++ b/Adapters/AspNetCore/Types/Headers.cs @@ -2,7 +2,7 @@ using GenHTTP.Api.Protocol; using Microsoft.AspNetCore.Http; -namespace GenHTTP.Engine.Kestrel.Types; +namespace GenHTTP.Adapters.AspNetCore.Types; public sealed class Headers : IHeaderCollection { diff --git a/Engine/Kestrel/Types/Query.cs b/Adapters/AspNetCore/Types/Query.cs similarity index 92% rename from Engine/Kestrel/Types/Query.cs rename to Adapters/AspNetCore/Types/Query.cs index 2080fee3..fe04bf8e 100644 --- a/Engine/Kestrel/Types/Query.cs +++ b/Adapters/AspNetCore/Types/Query.cs @@ -2,7 +2,7 @@ using GenHTTP.Api.Protocol; using Microsoft.AspNetCore.Http; -namespace GenHTTP.Engine.Kestrel.Types; +namespace GenHTTP.Adapters.AspNetCore.Types; public sealed class Query : IRequestQuery { diff --git a/Engine/Kestrel/Types/Request.cs b/Adapters/AspNetCore/Types/Request.cs similarity index 85% rename from Engine/Kestrel/Types/Request.cs rename to Adapters/AspNetCore/Types/Request.cs index dcc140eb..ba9653e6 100644 --- a/Engine/Kestrel/Types/Request.cs +++ b/Adapters/AspNetCore/Types/Request.cs @@ -1,12 +1,14 @@ using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; using GenHTTP.Api.Routing; + using GenHTTP.Engine.Shared.Types; + using Microsoft.AspNetCore.Http; -using Local = GenHTTP.Engine.Kestrel.Hosting; + using HttpProtocol = GenHTTP.Api.Protocol.HttpProtocol; -namespace GenHTTP.Engine.Kestrel.Types; +namespace GenHTTP.Adapters.AspNetCore.Types; public sealed class Request : IRequest { @@ -20,6 +22,8 @@ public sealed class Request : IRequest private Headers? _Headers; + private readonly IEndPoint? _EndPoint; + #region Get-/Setters public IRequestProperties Properties @@ -29,7 +33,7 @@ public IRequestProperties Properties public IServer Server { get; } - public IEndPoint EndPoint { get; } + public IEndPoint EndPoint => _EndPoint ?? throw new InvalidOperationException("EndPoint is not available as it is managed by ASP.NET Core"); public IClientConnection Client { get; } @@ -105,11 +109,11 @@ public Request(IServer server, HttpContext context) _Forwardings.TryAddLegacy(Headers); } - LocalClient = new Local.ClientConnection(context.Connection, context.Request); + LocalClient = new ClientConnection(context.Connection, context.Request); Client = _Forwardings.DetermineClient(context.Connection.ClientCertificate) ?? LocalClient; - EndPoint = Server.EndPoints.First(e => e.Port == context.Connection.LocalPort); + _EndPoint = Server.EndPoints.FirstOrDefault(e => e.Port == context.Connection.LocalPort); } #endregion diff --git a/Engine/Kestrel/GenHTTP.Engine.Kestrel.csproj b/Engine/Kestrel/GenHTTP.Engine.Kestrel.csproj index 347fc755..649f2cfb 100644 --- a/Engine/Kestrel/GenHTTP.Engine.Kestrel.csproj +++ b/Engine/Kestrel/GenHTTP.Engine.Kestrel.csproj @@ -46,6 +46,8 @@ + + diff --git a/Engine/Kestrel/Hosting/KestrelServer.cs b/Engine/Kestrel/Hosting/KestrelServer.cs index 94dfec1d..36d2634f 100644 --- a/Engine/Kestrel/Hosting/KestrelServer.cs +++ b/Engine/Kestrel/Hosting/KestrelServer.cs @@ -1,15 +1,18 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Adapters.AspNetCore; + using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; -using GenHTTP.Api.Protocol; -using GenHTTP.Engine.Kestrel.Types; + using GenHTTP.Engine.Shared.Infrastructure; + using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Https; + using Microsoft.Extensions.Logging; namespace GenHTTP.Engine.Kestrel.Hosting; @@ -73,7 +76,7 @@ private WebApplication Spawn(Action? configurationHook, A var app = builder.Build(); - app.Run(async (context) => await MapAsync(context)); + app.Run(Handler, server: this); applicationHook?.Invoke(app); @@ -128,86 +131,6 @@ private void Configure(WebApplicationBuilder builder) }); } - private async ValueTask MapAsync(HttpContext context) - { - try - { - using var request = new Request(this, context); - - using var response = await Handler.HandleAsync(request); - - if (response == null) - { - context.Response.StatusCode = 204; - } - else - { - await WriteAsync(response, context); - - Companion?.OnRequestHandled(request, response); - } - } - catch (Exception e) - { - Companion?.OnServerError(ServerErrorScope.ServerConnection, context.Connection.RemoteIpAddress, e); - throw; - } - } - - private async ValueTask WriteAsync(IResponse response, HttpContext context) - { - var target = context.Response; - - target.StatusCode = response.Status.RawStatus; - - foreach (var header in response.Headers) - { - target.Headers.Append(header.Key, header.Value); - } - - if (response.Modified != null) - { - target.Headers.LastModified = response.Modified.Value.ToUniversalTime().ToString("r"); - } - - if (response.Expires != null) - { - target.Headers.Expires = response.Expires.Value.ToUniversalTime().ToString("r"); - } - - if (response.HasCookies) - { - foreach (var cookie in response.Cookies) - { - if (cookie.Value.MaxAge != null) - { - target.Cookies.Append(cookie.Key, cookie.Value.Value, new() - { - MaxAge = TimeSpan.FromSeconds(cookie.Value.MaxAge.Value) - }); - } - else - { - target.Cookies.Append(cookie.Key, cookie.Value.Value); - } - } - } - - if (response.Content != null) - { - target.ContentLength = (long?)response.ContentLength ?? (long?)response.Content.Length; - - target.ContentType = response.ContentType?.Charset != null ? $"{response.ContentType?.RawType}; charset={response.ContentType?.Charset}" : response.ContentType?.RawType; - - if (response.ContentEncoding != null) - { - target.Headers.ContentEncoding = response.ContentEncoding; - } - - await response.Content.WriteAsync(target.Body, Configuration.Network.TransferBufferSize); - } - } - #endregion #region Lifecycle diff --git a/Engine/Shared/Infrastructure/ServerBuilder.cs b/Engine/Shared/Infrastructure/ServerBuilder.cs index ce93150d..74de4f3b 100644 --- a/Engine/Shared/Infrastructure/ServerBuilder.cs +++ b/Engine/Shared/Infrastructure/ServerBuilder.cs @@ -173,6 +173,4 @@ public IServer Build() #endregion - - } diff --git a/GenHTTP.sln b/GenHTTP.sln index 710f4ad7..c7a84e8d 100644 --- a/GenHTTP.sln +++ b/GenHTTP.sln @@ -101,6 +101,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Engine.Kestrel", "E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Engine.Shared", "Engine\Shared\GenHTTP.Engine.Shared.csproj", "{7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Adapters", "Adapters", "{C3265C1A-E9A9-45FD-BD24-66DE9C7062F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Adapters.AspNetCore", "Adapters\AspNetCore\GenHTTP.Adapters.AspNetCore.csproj", "{AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -247,6 +251,10 @@ Global {7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}.Debug|Any CPU.Build.0 = Debug|Any CPU {7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}.Release|Any CPU.ActiveCfg = Release|Any CPU {7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}.Release|Any CPU.Build.0 = Release|Any CPU + {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -289,6 +297,7 @@ Global {4A492C9D-4338-4CCD-A227-F7829D032221} = {AFBFE61E-0C33-42F6-9370-9F5088EB8633} {4137673D-9218-4D42-924C-A36A6F412F5E} = {AFBFE61E-0C33-42F6-9370-9F5088EB8633} {7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54} = {AFBFE61E-0C33-42F6-9370-9F5088EB8633} + {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78} = {C3265C1A-E9A9-45FD-BD24-66DE9C7062F1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution LessCompiler = 2603124e-1287-4d61-9540-6ac3efad4eb9 diff --git a/Testing/Acceptance/Adapters/AspNetCore/IntegrationTests.cs b/Testing/Acceptance/Adapters/AspNetCore/IntegrationTests.cs new file mode 100644 index 00000000..5db7bcd5 --- /dev/null +++ b/Testing/Acceptance/Adapters/AspNetCore/IntegrationTests.cs @@ -0,0 +1,154 @@ +using System.Net; + +using GenHTTP.Adapters.AspNetCore; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.IO; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +using Microsoft.Extensions.Logging; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Adapters.AspNetCore; + +[TestClass] +public class IntegrationTests +{ + + #region Tests + + [TestMethod] + public async Task TestMapping() + { + var port = TestHost.NextPort(); + + var options = (WebApplication app) => + { + app.Map("/builder", Inline.Create().Get("/a", () => "a")); + app.Map("/handler", Inline.Create().Get("/b", () => "b").Build()); + }; + + await using var app = await RunApplicationAsync(port, options); + + using var client = new HttpClient(); + + using var builderResponse = await GetResponseAsync(client, "/builder/a", port); + + await builderResponse.AssertStatusAsync(HttpStatusCode.OK); + Assert.AreEqual("a", await builderResponse.GetContentAsync()); + + using var handlerResponse = await GetResponseAsync(client, "/handler/b", port); + + await handlerResponse.AssertStatusAsync(HttpStatusCode.OK); + Assert.AreEqual("b", await handlerResponse.GetContentAsync()); + } + + [TestMethod] + public async Task TestDefaults() + { + var port = TestHost.NextPort(); + + var options = (WebApplication app) => + { + app.Map("/content", Content.From(Resource.FromString("Hello World")).Defaults(rangeSupport: true)); + }; + + await using var app = await RunApplicationAsync(port, options); + + using var client = new HttpClient(); + + using var response = await GetResponseAsync(client, "/content", port); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Hello World", await response.GetContentAsync()); + + Assert.IsTrue(response.Headers.Contains("ETag")); + } + + [TestMethod] + public async Task TestErrorHandling() + { + var port = TestHost.NextPort(); + + var options = (WebApplication app) => + { + app.Map("/notfound", Inline.Create().Defaults()); + }; + + await using var app = await RunApplicationAsync(port, options); + + using var client = new HttpClient(); + + using var response = await GetResponseAsync(client, "/notfound", port); + + await response.AssertStatusAsync(HttpStatusCode.NotFound); + + AssertX.Contains("404", await response.GetContentAsync()); + } + + [TestMethod] + public async Task TestImplicitServer() + { + var port = TestHost.NextPort(); + + var options = (WebApplication app) => + { + app.Map("/server", Inline.Create().Get(async (IRequest r) => + { + await r.Server.DisposeAsync(); // nop + + await Assert.ThrowsExceptionAsync(async () => await r.Server.StartAsync()); + + return r.Server; + })); + }; + + await using var app = await RunApplicationAsync(port, options); + + using var client = new HttpClient(); + + using var response = await GetResponseAsync(client, "/server", port); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + var content = await response.GetContentAsync(); + + AssertX.Contains("\"running\":true", content); + AssertX.Contains("\"development\":false", content); + AssertX.Contains("\"version\"", content); + } + + #endregion + + #region Helpers + + private static async ValueTask RunApplicationAsync(int port, Action options) + { + var builder = WebApplication.CreateBuilder(); + + builder.Logging.ClearProviders(); + + builder.WebHost.ConfigureKestrel(options => + { + options.AllowSynchronousIO = true; + options.Listen(IPAddress.Any, port); + }); + + var app = builder.Build(); + + options.Invoke(app); + + await app.StartAsync(); + + return app; + } + + private static async ValueTask GetResponseAsync(HttpClient client, string path, int port) + => await client.GetAsync($"http://localhost:{port}{path}"); + + #endregion + +} diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj index b2fbad95..0ebd2df4 100644 --- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj +++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj @@ -64,6 +64,8 @@ + +