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 @@
+
+