diff --git a/Adapters/WiredIO/Adapter.cs b/Adapters/WiredIO/Adapter.cs new file mode 100644 index 000000000..b0aa728a6 --- /dev/null +++ b/Adapters/WiredIO/Adapter.cs @@ -0,0 +1,65 @@ +using GenHTTP.Adapters.WiredIO.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 Wired.IO.Builder; +using Wired.IO.Http11Express; +using Wired.IO.Http11Express.Context; + +namespace GenHTTP.Adapters.WiredIO; + +public static class Adapter +{ + + // ToDo: IBaseRequest and IBaseResponse do not feature basic access (such as headers), so we cannot be generic here + + public static void Map(this Builder, Http11ExpressContext> builder, string path, IHandlerBuilder handler, IServerCompanion? companion = null) + => Map(builder, path, handler.Build(), companion); + + public static void Map(this Builder, Http11ExpressContext> builder, string path, IHandler handler, IServerCompanion? companion = null) + { + builder.UseMiddleware(scope => async (c, n) => await Bridge.MapAsync(c, n, handler, companion: companion, registeredPath: path)); + } + + /// + /// 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/WiredIO/GenHTTP.Adapters.WiredIO.csproj b/Adapters/WiredIO/GenHTTP.Adapters.WiredIO.csproj new file mode 100644 index 000000000..ea1498f5f --- /dev/null +++ b/Adapters/WiredIO/GenHTTP.Adapters.WiredIO.csproj @@ -0,0 +1,30 @@ + + + + + net9.0 + + Adapter to run GenHTTP handlers within an Wired.IO app. + Wired.IO Adapter GenHTTP + + + + + + + + + + + + + + + + + + + + + + diff --git a/Adapters/WiredIO/Mapping/Bridge.cs b/Adapters/WiredIO/Mapping/Bridge.cs new file mode 100644 index 000000000..b33ef8655 --- /dev/null +++ b/Adapters/WiredIO/Mapping/Bridge.cs @@ -0,0 +1,110 @@ +using GenHTTP.Adapters.WiredIO.Server; +using GenHTTP.Adapters.WiredIO.Types; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using Wired.IO.Http11Express.Context; + +using WR = Wired.IO.Protocol.Response; + +namespace GenHTTP.Adapters.WiredIO.Mapping; + +public static class Bridge +{ + + public static async Task MapAsync(Http11ExpressContext context, Func next, IHandler handler, IServerCompanion? companion = null, string? registeredPath = null) + { + if ((registeredPath != null) && !context.Request.Route.StartsWith(registeredPath)) + { + await next(context); + return; + } + + // todo: can we cache this somewhere? + var server = new ImplicitServer(handler, companion); + + try + { + using var request = new Request(server, context.Request); + + if (registeredPath != null) + { + AdvanceTo(request, registeredPath); + } + + using var response = await handler.HandleAsync(request); + + if (response != null) + { + MapResponse(response, context); + + server.Companion?.OnRequestHandled(request, response); + } + else + { + await next(context); + } + } + catch (Exception e) + { + // todo: cannot tell the IP of the client in wired + server.Companion?.OnServerError(ServerErrorScope.ServerConnection, null, e); + throw; + } + } + + private static void MapResponse(IResponse response, Http11ExpressContext context) + { + var target = context.Respond(); + + target.Status((WR.ResponseStatus)response.Status.RawStatus); + + foreach (var header in response.Headers) + { + target.Header(header.Key, header.Value); + } + + if (response.Modified != null) + { + target.Header("Last-Modified", response.Modified.Value.ToUniversalTime().ToString("r")); + } + + if (response.Expires != null) + { + target.Header("Expires", response.Expires.Value.ToUniversalTime().ToString("r")); + } + + if (response.HasCookies) + { + foreach (var cookie in response.Cookies) + { + target.Header("Set-Cookie", $"{cookie.Key}={cookie.Value.Value}"); + } + } + + if (response.Content != null) + { + target.Content(new MappedContent(response)); + + target.Header("Content-Type", (response.ContentType?.Charset != null ? $"{response.ContentType?.RawType}; charset={response.ContentType?.Charset}" : response.ContentType?.RawType) ?? "application/octet-stream"); + + if (response.ContentEncoding != null) + { + target.Header("Content-Encoding", response.ContentEncoding); + } + } + } + + 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/WiredIO/Server/EmptyEndpoints.cs b/Adapters/WiredIO/Server/EmptyEndpoints.cs new file mode 100644 index 000000000..27adb9735 --- /dev/null +++ b/Adapters/WiredIO/Server/EmptyEndpoints.cs @@ -0,0 +1,5 @@ +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Adapters.WiredIO.Server; + +public class EmptyEndpoints : List, IEndPointCollection; diff --git a/Adapters/WiredIO/Server/ImplicitServer.cs b/Adapters/WiredIO/Server/ImplicitServer.cs new file mode 100644 index 000000000..0d688b7df --- /dev/null +++ b/Adapters/WiredIO/Server/ImplicitServer.cs @@ -0,0 +1,56 @@ +using System.Runtime.InteropServices; +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Adapters.WiredIO.Server; + +public sealed class ImplicitServer : IServer +{ + + #region Get-/Setters + + public string Version => RuntimeInformation.FrameworkDescription; + + public bool Running { get; } + + public bool Development + { + get + { + // todo: is there something like development mode in wired? + 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 WiredIO and cannot be started"); + + #endregion + +} diff --git a/Adapters/WiredIO/Types/ClientConnection.cs b/Adapters/WiredIO/Types/ClientConnection.cs new file mode 100644 index 000000000..d90e3b2f3 --- /dev/null +++ b/Adapters/WiredIO/Types/ClientConnection.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using Wired.IO.Http11Express.Request; + +namespace GenHTTP.Adapters.WiredIO.Types; + +public sealed class ClientConnection : IClientConnection +{ + + #region Get-/Setters + + public IPAddress IPAddress => throw new InvalidOperationException("Remote client IP address is not known"); + + public ClientProtocol? Protocol { get; } + + public string? Host => Request.Headers.GetValueOrDefault("Host"); + + public X509Certificate? Certificate => null; + + private IExpressRequest Request { get; } + + #endregion + + #region Initialization + + public ClientConnection(IExpressRequest request) + { + Request = request; + + // todo: wired does not expose this information + Protocol = ClientProtocol.Http; + } + + #endregion + +} diff --git a/Adapters/WiredIO/Types/Headers.cs b/Adapters/WiredIO/Types/Headers.cs new file mode 100644 index 000000000..e7d13fa31 --- /dev/null +++ b/Adapters/WiredIO/Types/Headers.cs @@ -0,0 +1,81 @@ +using System.Collections; + +using GenHTTP.Api.Protocol; + +using Wired.IO.Http11Express.Request; + +namespace GenHTTP.Adapters.WiredIO.Types; + +public sealed class Headers : IHeaderCollection +{ + + #region Get-/Setters + + public int Count => Request.Headers.Count; + + public bool ContainsKey(string key) => Request.Headers.ContainsKey(key); + + public bool TryGetValue(string key, out string value) + { + if (Request.Headers.TryGetValue(key, out var found)) + { + value = found; + return true; + } + + value = string.Empty; + return false; + } + + public string this[string key] => ContainsKey(key) ? Request.Headers[key] : string.Empty; + + public IEnumerable Keys => Request.Headers.Keys; + + public IEnumerable Values + { + get + { + foreach (var entry in Request.Headers) + { + yield return entry.Value; + } + } + } + + private IExpressRequest Request { get; } + + #endregion + + #region Initialization + + public Headers(IExpressRequest request) + { + Request = request; + } + + #endregion + + #region Functionality + + public IEnumerator> GetEnumerator() + { + foreach (var entry in Request.Headers) + { + yield return new(entry.Key, entry.Value); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion + + #region Lifecycle + + public void Dispose() + { + + } + + #endregion + +} diff --git a/Adapters/WiredIO/Types/MappedContent.cs b/Adapters/WiredIO/Types/MappedContent.cs new file mode 100644 index 000000000..001f6a9c1 --- /dev/null +++ b/Adapters/WiredIO/Types/MappedContent.cs @@ -0,0 +1,27 @@ +using System.IO.Pipelines; + +using GenHTTP.Api.Protocol; + +using Wired.IO.Http11Express.Response.Content; + +namespace GenHTTP.Adapters.WiredIO.Types; + +public class MappedContent(IResponse source) : IExpressResponseContent +{ + + public ulong? Length => source.ContentLength; + + public void Write(PipeWriter writer) + { + if (source.Content != null) + { + // todo: this is bad + using var stream = writer.AsStream(); + + source.Content.WriteAsync(stream, 4096).AsTask().GetAwaiter().GetResult(); + + writer.FlushAsync().AsTask().GetAwaiter().GetResult(); + } + } + +} diff --git a/Adapters/WiredIO/Types/Query.cs b/Adapters/WiredIO/Types/Query.cs new file mode 100644 index 000000000..396f6c6ca --- /dev/null +++ b/Adapters/WiredIO/Types/Query.cs @@ -0,0 +1,98 @@ +using System.Collections; + +using GenHTTP.Api.Protocol; + +using Wired.IO.Http11Express.Request; + +namespace GenHTTP.Adapters.WiredIO.Types; + +public sealed class Query : IRequestQuery +{ + + #region Get-/Setters + + public int Count => Request.QueryParameters?.Count ?? 0; + + public bool ContainsKey(string key) => Request.QueryParameters?.ContainsKey(key) ?? false; + + public bool TryGetValue(string key, out string value) + { + if (Request.QueryParameters?.TryGetValue(key, out var stringValue) ?? false) + { + value = stringValue; + return true; + } + + value = string.Empty; + return false; + } + + public string this[string key] + { + get + { + if (Request.QueryParameters?.TryGetValue(key, out var stringValue) ?? false) + { + return stringValue; + } + + return string.Empty; + } + } + + public IEnumerable Keys => Request.QueryParameters?.Keys ?? Enumerable.Empty(); + + public IEnumerable Values + { + get + { + if (Request.QueryParameters != null) + { + foreach (var entry in Request.QueryParameters) + { + yield return entry.Value; + } + } + } + } + + private IExpressRequest Request { get; } + + #endregion + + #region Initialization + + public Query(IExpressRequest request) + { + Request = request; + } + + #endregion + + #region Functionality + + public IEnumerator> GetEnumerator() + { + if (Request.QueryParameters != null) + { + foreach (var entry in Request.QueryParameters) + { + yield return new(entry.Key, entry.Value); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + #endregion + + #region Lifecycle + + public void Dispose() + { + + } + + #endregion + +} diff --git a/Adapters/WiredIO/Types/Request.cs b/Adapters/WiredIO/Types/Request.cs new file mode 100644 index 000000000..3b4030c8d --- /dev/null +++ b/Adapters/WiredIO/Types/Request.cs @@ -0,0 +1,155 @@ +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Api.Routing; + +using GenHTTP.Engine.Shared.Types; + +using Wired.IO.Http11Express.Request; + +namespace GenHTTP.Adapters.WiredIO.Types; + +public sealed class Request : IRequest +{ + private RequestProperties? _Properties; + + private Query? _Query; + + private ICookieCollection? _Cookies; + + private readonly ForwardingCollection _Forwardings = new(); + + private Headers? _Headers; + + #region Get-/Setters + + public IRequestProperties Properties + { + get { return _Properties ??= new RequestProperties(); } + } + + public IServer Server { get; } + + public IEndPoint EndPoint => throw new InvalidOperationException("EndPoint is not available as it is managed by WiredIO"); + + public IClientConnection Client { get; } + + public IClientConnection LocalClient { get; } + + public HttpProtocol ProtocolType { get; } + + public FlexibleRequestMethod Method { get; } + + public RoutingTarget Target { get; } + + public string? UserAgent => this["User-Agent"]; + + public string? Referer => this["Referer"]; + + public string? Host => this["Host"]; + + public string? this[string additionalHeader] => Headers.GetValueOrDefault(additionalHeader); + + public IRequestQuery Query + { + get { return _Query ??= new Query(InnerRequest); } + } + + public ICookieCollection Cookies + { + get { return _Cookies ??= FetchCookies(InnerRequest); } + } + + public IForwardingCollection Forwardings => _Forwardings; + + public IHeaderCollection Headers + { + get { return _Headers ??= new Headers(InnerRequest); } + } + + // todo: this is quite inefficient + public Stream Content => (InnerRequest.Content != null) ? new MemoryStream(InnerRequest.Content) : Stream.Null; + + public FlexibleContentType? ContentType + { + get + { + if (InnerRequest.Headers.TryGetValue("Content-Type", out var contentType)) + { + return FlexibleContentType.Parse(contentType); + } + + return null; + } + } + + private IExpressRequest InnerRequest { get; } + + #endregion + + #region Initialization + + public Request(IServer server, IExpressRequest request) + { + Server = server; + InnerRequest = request; + + // todo: no API provided by wired + ProtocolType = HttpProtocol.Http11; + + Method = FlexibleRequestMethod.Get(request.HttpMethod); + Target = new RoutingTarget(WebPath.FromString(request.Route)); + + if (request.Headers.TryGetValue("forwarded", out var entry)) + { + _Forwardings.Add(entry); + } + else + { + _Forwardings.TryAddLegacy(Headers); + } + + LocalClient = new ClientConnection(request); + + // todo: potential client certificate is not exposed by wired + Client = _Forwardings.DetermineClient(null) ?? LocalClient; + } + + private CookieCollection FetchCookies(IExpressRequest request) + { + var cookies = new CookieCollection(); + + if (request.Headers.TryGetValue("Cookie", out var header)) + { + cookies.Add(header); + } + + return cookies; + } + + #endregion + + #region Functionality + + public IResponseBuilder Respond() => new ResponseBuilder().Status(ResponseStatus.Ok); + + public UpgradeInfo Upgrade() => throw new NotSupportedException("Web sockets are not supported by the Kestrel server implementation"); + + #endregion + + #region Lifecycle + + private bool _Disposed; + + public void Dispose() + { + if (!_Disposed) + { + _Properties?.Dispose(); + + _Disposed = true; + } + } + + #endregion + +} diff --git a/GenHTTP.slnx b/GenHTTP.slnx index a1d3af5d7..d7caf9238 100644 --- a/GenHTTP.slnx +++ b/GenHTTP.slnx @@ -1,6 +1,7 @@  +