diff --git a/API/Protocol/ContentType.cs b/API/Protocol/ContentType.cs index f8c30de5..9b1fb89c 100644 --- a/API/Protocol/ContentType.cs +++ b/API/Protocol/ContentType.cs @@ -30,6 +30,11 @@ public enum ContentType /// ApplicationJson, + /// + /// A YAML file. + /// + ApplicationYaml, + /// /// A PNG image. /// @@ -314,9 +319,9 @@ public enum ContentType /// public class FlexibleContentType { - private static readonly ConcurrentDictionary _RawCache = new(StringComparer.InvariantCultureIgnoreCase); + private static readonly ConcurrentDictionary RawCache = new(StringComparer.InvariantCultureIgnoreCase); - private static readonly Dictionary _KnownCache = new(); + private static readonly Dictionary KnownCache = new(); #region Get-/Setters @@ -339,70 +344,191 @@ public class FlexibleContentType #region Mapping - private static readonly Dictionary MAPPING = new() + private static readonly Dictionary Mapping = new() { - { ContentType.AudioMp4, "audio/mp4" }, - { ContentType.AudioOgg, "audio/ogg" }, - { ContentType.AudioMpeg, "audio/mpeg" }, - { ContentType.AudioWav, "audio/wav" }, - { ContentType.ApplicationJavaScript, "application/javascript" }, - { ContentType.ApplicationOfficeDocumentWordProcessing, "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, - { ContentType.ApplicationOfficeDocumentPresentation, "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, - { ContentType.ApplicationOfficeDocumentSlideshow, "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, - { ContentType.ApplicationOfficeDocumentSheet, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, - { ContentType.ApplicationForceDownload, "application/force-download" }, - { ContentType.ApplicationOctetStream, "application/octet-stream" }, - { ContentType.ApplicationJson, "application/json" }, - { ContentType.ApplicationWwwFormUrlEncoded, "application/x-www-form-urlencoded" }, - { ContentType.ApplicationProtobuf, "application/protobuf" }, - { ContentType.FontEmbeddedOpenTypeFont, "font/eot" }, - { ContentType.FontOpenTypeFont, "font/otf" }, - { ContentType.FontTrueTypeFont, "font/ttf" }, - { ContentType.FontWoff, "font/woff" }, - { ContentType.FontWoff2, "font/woff2" }, - { ContentType.ImageIcon, "image/x-icon" }, - { ContentType.ImageGif, "image/gif" }, - { ContentType.ImageJpg, "image/jpg" }, - { ContentType.ImagePng, "image/png" }, - { ContentType.ImageTiff, "image/tiff" }, - { ContentType.ImageBmp, "image/bmp" }, - { ContentType.ImageScalableVectorGraphics, "image/svg" }, - { ContentType.ImageScalableVectorGraphicsXml, "image/svg+xml" }, - { ContentType.ImageScalableVectorGraphicsCompressed, "image/svgz" }, - { ContentType.TextHtml, "text/html" }, - { ContentType.TextCss, "text/css" }, - { ContentType.TextCsv, "text/csv" }, - { ContentType.TextRichText, "text/richtext" }, - { ContentType.TextPlain, "text/plain" }, - { ContentType.TextJavaScript, "text/javascript" }, - { ContentType.TextXml, "text/xml" }, - { ContentType.Video3Gpp, "video/3gpp" }, - { ContentType.Video3Gpp2, "video/3gpp2" }, - { ContentType.VideoAV1, "video/av1" }, - { ContentType.VideoAvc, "video/av" }, - { ContentType.VideoDV, "video/dv" }, - { ContentType.VideoH261, "video/H261" }, - { ContentType.VideoH263, "video/H263" }, - { ContentType.VideoH264, "video/H264" }, - { ContentType.VideoH265, "video/H265" }, - { ContentType.VideoH266, "video/H266" }, - { ContentType.VideoMatroska, "video/matroska" }, - { ContentType.VideoMatroska3D, "video/matroska-3d" }, - { ContentType.VideoMJ2, "video/mj2" }, - { ContentType.VideoMP4, "video/mp4" }, - { ContentType.VideoMpeg, "video/mpeg" }, - { ContentType.VideoMpeg4Generic, "video/mpeg4-generic" }, - { ContentType.VideoMpv, "video/MPV" }, - { ContentType.VideoQuicktime, "video/quicktime" }, - { ContentType.VideoRaw, "video/raw" }, - { ContentType.VideoVC1, "video/vc1" }, - { ContentType.VideoVC2, "video/vc2" }, - { ContentType.VideoVP8, "video/VP8" }, - { ContentType.VideoVP9, "video/VP9" }, - { ContentType.VideoWebM, "video/webm" } + { + ContentType.AudioMp4, "audio/mp4" + }, + { + ContentType.AudioOgg, "audio/ogg" + }, + { + ContentType.AudioMpeg, "audio/mpeg" + }, + { + ContentType.AudioWav, "audio/wav" + }, + { + ContentType.ApplicationJavaScript, "application/javascript" + }, + { + ContentType.ApplicationOfficeDocumentWordProcessing, "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }, + { + ContentType.ApplicationOfficeDocumentPresentation, "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }, + { + ContentType.ApplicationOfficeDocumentSlideshow, "application/vnd.openxmlformats-officedocument.presentationml.slideshow" + }, + { + ContentType.ApplicationOfficeDocumentSheet, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }, + { + ContentType.ApplicationForceDownload, "application/force-download" + }, + { + ContentType.ApplicationOctetStream, "application/octet-stream" + }, + { + ContentType.ApplicationJson, "application/json" + }, + { + ContentType.ApplicationYaml, "application/yaml" + }, + { + ContentType.ApplicationWwwFormUrlEncoded, "application/x-www-form-urlencoded" + }, + { + ContentType.ApplicationProtobuf, "application/protobuf" + }, + { + ContentType.FontEmbeddedOpenTypeFont, "font/eot" + }, + { + ContentType.FontOpenTypeFont, "font/otf" + }, + { + ContentType.FontTrueTypeFont, "font/ttf" + }, + { + ContentType.FontWoff, "font/woff" + }, + { + ContentType.FontWoff2, "font/woff2" + }, + { + ContentType.ImageIcon, "image/x-icon" + }, + { + ContentType.ImageGif, "image/gif" + }, + { + ContentType.ImageJpg, "image/jpg" + }, + { + ContentType.ImagePng, "image/png" + }, + { + ContentType.ImageTiff, "image/tiff" + }, + { + ContentType.ImageBmp, "image/bmp" + }, + { + ContentType.ImageScalableVectorGraphics, "image/svg" + }, + { + ContentType.ImageScalableVectorGraphicsXml, "image/svg+xml" + }, + { + ContentType.ImageScalableVectorGraphicsCompressed, "image/svgz" + }, + { + ContentType.TextHtml, "text/html" + }, + { + ContentType.TextCss, "text/css" + }, + { + ContentType.TextCsv, "text/csv" + }, + { + ContentType.TextRichText, "text/richtext" + }, + { + ContentType.TextPlain, "text/plain" + }, + { + ContentType.TextJavaScript, "text/javascript" + }, + { + ContentType.TextXml, "text/xml" + }, + { + ContentType.Video3Gpp, "video/3gpp" + }, + { + ContentType.Video3Gpp2, "video/3gpp2" + }, + { + ContentType.VideoAV1, "video/av1" + }, + { + ContentType.VideoAvc, "video/av" + }, + { + ContentType.VideoDV, "video/dv" + }, + { + ContentType.VideoH261, "video/H261" + }, + { + ContentType.VideoH263, "video/H263" + }, + { + ContentType.VideoH264, "video/H264" + }, + { + ContentType.VideoH265, "video/H265" + }, + { + ContentType.VideoH266, "video/H266" + }, + { + ContentType.VideoMatroska, "video/matroska" + }, + { + ContentType.VideoMatroska3D, "video/matroska-3d" + }, + { + ContentType.VideoMJ2, "video/mj2" + }, + { + ContentType.VideoMP4, "video/mp4" + }, + { + ContentType.VideoMpeg, "video/mpeg" + }, + { + ContentType.VideoMpeg4Generic, "video/mpeg4-generic" + }, + { + ContentType.VideoMpv, "video/MPV" + }, + { + ContentType.VideoQuicktime, "video/quicktime" + }, + { + ContentType.VideoRaw, "video/raw" + }, + { + ContentType.VideoVC1, "video/vc1" + }, + { + ContentType.VideoVC2, "video/vc2" + }, + { + ContentType.VideoVP8, "video/VP8" + }, + { + ContentType.VideoVP9, "video/VP9" + }, + { + ContentType.VideoWebM, "video/webm" + } }; - private static readonly Dictionary MAPPING_REVERSE = MAPPING.ToDictionary(x => x.Value, x => x.Key); + private static readonly Dictionary MAPPING_REVERSE = Mapping.ToDictionary(x => x.Value, x => x.Key); #endregion @@ -436,7 +562,7 @@ public FlexibleContentType(string rawType, string? charset = null) public FlexibleContentType(ContentType type, string? charset = null) { KnownType = type; - RawType = MAPPING[type]; + RawType = Mapping[type]; Charset = charset; } @@ -457,14 +583,14 @@ public static FlexibleContentType Get(string rawType, string? charset = null) return new FlexibleContentType(rawType, charset); } - if (_RawCache.TryGetValue(rawType, out var found)) + if (RawCache.TryGetValue(rawType, out var found)) { return found; } var type = new FlexibleContentType(rawType); - _RawCache[rawType] = type; + RawCache[rawType] = type; return type; } @@ -481,14 +607,14 @@ public static FlexibleContentType Get(ContentType knownType, string? charset = n return new FlexibleContentType(knownType, charset); } - if (_KnownCache.TryGetValue(knownType, out var found)) + if (KnownCache.TryGetValue(knownType, out var found)) { return found; } var type = new FlexibleContentType(knownType); - _KnownCache[knownType] = type; + KnownCache[knownType] = type; return type; } diff --git a/API/Protocol/IRequestProperties.cs b/API/Protocol/IRequestProperties.cs index d8cf3f66..e4874a4c 100644 --- a/API/Protocol/IRequestProperties.cs +++ b/API/Protocol/IRequestProperties.cs @@ -31,5 +31,4 @@ public interface IRequestProperties : IDisposable /// /// The entry to be removed void Clear(string key); - } diff --git a/API/Protocol/RequestMethod.cs b/API/Protocol/RequestMethod.cs index 6a631c1e..9d387663 100644 --- a/API/Protocol/RequestMethod.cs +++ b/API/Protocol/RequestMethod.cs @@ -23,44 +23,96 @@ public enum RequestMethod /// public class FlexibleRequestMethod { - private static readonly Dictionary _RawCache = new(StringComparer.InvariantCultureIgnoreCase) + private static readonly Dictionary RawCache = new(StringComparer.InvariantCultureIgnoreCase) { - { "HEAD", new FlexibleRequestMethod(RequestMethod.Head) }, - { "GET", new FlexibleRequestMethod(RequestMethod.Get) }, - { "POST", new FlexibleRequestMethod(RequestMethod.Post) }, - { "PUT", new FlexibleRequestMethod(RequestMethod.Put) }, - { "DELETE", new FlexibleRequestMethod(RequestMethod.Delete) }, - { "OPTIONS", new FlexibleRequestMethod(RequestMethod.Options) } + { + "HEAD", new FlexibleRequestMethod(RequestMethod.Head) + }, + { + "GET", new FlexibleRequestMethod(RequestMethod.Get) + }, + { + "POST", new FlexibleRequestMethod(RequestMethod.Post) + }, + { + "PUT", new FlexibleRequestMethod(RequestMethod.Put) + }, + { + "DELETE", new FlexibleRequestMethod(RequestMethod.Delete) + }, + { + "OPTIONS", new FlexibleRequestMethod(RequestMethod.Options) + } }; - private static readonly Dictionary _KnownCache = new() + private static readonly Dictionary KnownCache = new() { - { RequestMethod.Head, new FlexibleRequestMethod(RequestMethod.Head) }, - { RequestMethod.Get, new FlexibleRequestMethod(RequestMethod.Get) }, - { RequestMethod.Post, new FlexibleRequestMethod(RequestMethod.Post) }, - { RequestMethod.Put, new FlexibleRequestMethod(RequestMethod.Put) }, - { RequestMethod.Delete, new FlexibleRequestMethod(RequestMethod.Delete) }, - { RequestMethod.Options, new FlexibleRequestMethod(RequestMethod.Options) } + { + RequestMethod.Head, new FlexibleRequestMethod(RequestMethod.Head) + }, + { + RequestMethod.Get, new FlexibleRequestMethod(RequestMethod.Get) + }, + { + RequestMethod.Post, new FlexibleRequestMethod(RequestMethod.Post) + }, + { + RequestMethod.Put, new FlexibleRequestMethod(RequestMethod.Put) + }, + { + RequestMethod.Delete, new FlexibleRequestMethod(RequestMethod.Delete) + }, + { + RequestMethod.Options, new FlexibleRequestMethod(RequestMethod.Options) + } }; #region Mapping - private static readonly Dictionary MAPPING = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary Mapping = new(StringComparer.OrdinalIgnoreCase) { - { "GET", RequestMethod.Get }, - { "HEAD", RequestMethod.Head }, - { "POST", RequestMethod.Post }, - { "PUT", RequestMethod.Put }, - { "PATCH", RequestMethod.Patch }, - { "DELETE", RequestMethod.Delete }, - { "OPTIONS", RequestMethod.Options }, - { "PROPFIND", RequestMethod.PropFind }, - { "PROPPATCH", RequestMethod.PropPatch }, - { "MKCOL", RequestMethod.MkCol }, - { "COPY", RequestMethod.Copy }, - { "MOVE", RequestMethod.Move }, - { "LOCK", RequestMethod.Lock }, - { "UNLOCK", RequestMethod.Unlock } + { + "GET", RequestMethod.Get + }, + { + "HEAD", RequestMethod.Head + }, + { + "POST", RequestMethod.Post + }, + { + "PUT", RequestMethod.Put + }, + { + "PATCH", RequestMethod.Patch + }, + { + "DELETE", RequestMethod.Delete + }, + { + "OPTIONS", RequestMethod.Options + }, + { + "PROPFIND", RequestMethod.PropFind + }, + { + "PROPPATCH", RequestMethod.PropPatch + }, + { + "MKCOL", RequestMethod.MkCol + }, + { + "COPY", RequestMethod.Copy + }, + { + "MOVE", RequestMethod.Move + }, + { + "LOCK", RequestMethod.Lock + }, + { + "UNLOCK", RequestMethod.Unlock + } }; #endregion @@ -99,7 +151,7 @@ public FlexibleRequestMethod(string rawType) { RawMethod = rawType; - if (MAPPING.TryGetValue(rawType, out var type)) + if (Mapping.TryGetValue(rawType, out var type)) { KnownMethod = type; } @@ -120,14 +172,14 @@ public FlexibleRequestMethod(string rawType) /// The content type instance to be used public static FlexibleRequestMethod Get(string rawMethod) { - if (_RawCache.TryGetValue(rawMethod, out var found)) + if (RawCache.TryGetValue(rawMethod, out var found)) { return found; } var method = new FlexibleRequestMethod(rawMethod); - _RawCache[rawMethod] = method; + RawCache[rawMethod] = method; return method; } @@ -139,14 +191,14 @@ public static FlexibleRequestMethod Get(string rawMethod) /// The content type instance to be used public static FlexibleRequestMethod Get(RequestMethod knownMethod) { - if (_KnownCache.TryGetValue(knownMethod, out var found)) + if (KnownCache.TryGetValue(knownMethod, out var found)) { return found; } var method = new FlexibleRequestMethod(knownMethod); - _KnownCache[knownMethod] = method; + KnownCache[knownMethod] = method; return method; } diff --git a/API/Protocol/ResponseStatus.cs b/API/Protocol/ResponseStatus.cs index bf3e3dfb..40d76ec6 100644 --- a/API/Protocol/ResponseStatus.cs +++ b/API/Protocol/ResponseStatus.cs @@ -140,57 +140,159 @@ public readonly struct FlexibleResponseStatus private static readonly Dictionary MAPPING = new() { - { ResponseStatus.Accepted, "Accepted" }, - { ResponseStatus.BadGateway, "Bad Gateway" }, - { ResponseStatus.BadRequest, "Bad Request" }, - { ResponseStatus.Created, "Created" }, - { ResponseStatus.Forbidden, "Forbidden" }, - { ResponseStatus.InternalServerError, "Internal Server Error" }, - { ResponseStatus.MethodNotAllowed, "Method Not Allowed" }, - { ResponseStatus.MovedPermanently, "Moved Permamently" }, - { ResponseStatus.Found, "Found" }, - { ResponseStatus.NoContent, "No Content" }, - { ResponseStatus.NotFound, "Not Found" }, - { ResponseStatus.NotImplemented, "Not Implemented" }, - { ResponseStatus.NotModified, "Not Modified" }, - { ResponseStatus.Ok, "OK" }, - { ResponseStatus.ServiceUnavailable, "Service Unavailable" }, - { ResponseStatus.Unauthorized, "Unauthorized" }, - { ResponseStatus.PartialContent, "Partial Content" }, - { ResponseStatus.MultiStatus, "Multi-Status" }, - { ResponseStatus.AlreadyReported, "Already Reported" }, - { ResponseStatus.SeeOther, "See Other" }, - { ResponseStatus.TemporaryRedirect, "Temporary Redirect" }, - { ResponseStatus.PermanentRedirect, "Permanent Redirect" }, - { ResponseStatus.Continue, "Continue" }, - { ResponseStatus.SwitchingProtocols, "Switching Protocols" }, - { ResponseStatus.NotAcceptable, "Not Acceptable" }, - { ResponseStatus.ProxyAuthenticationRequired, "Proxy Authentication Required" }, - { ResponseStatus.Conflict, "Conflict" }, - { ResponseStatus.Gone, "Gone" }, - { ResponseStatus.LengthRequired, "Length Required" }, - { ResponseStatus.PreconditionFailed, "Precondition Failed" }, - { ResponseStatus.RequestEntityTooLarge, "Request Entity Too Large" }, - { ResponseStatus.RequestUriTooLong, "Request Uri Too Long" }, - { ResponseStatus.UnsupportedMediaType, "Unsupported Media Type" }, - { ResponseStatus.RequestedRangeNotSatisfiable, "Requested Range Not Satisfiable" }, - { ResponseStatus.ExpectationFailed, "Expectation Failed" }, - { ResponseStatus.UnprocessableEntity, "Unprocessable Entity" }, - { ResponseStatus.Locked, "Locked" }, - { ResponseStatus.FailedDependency, "Failed Dependency" }, - { ResponseStatus.ReservedForWebDav, "Reserved For WebDAV" }, - { ResponseStatus.UpgradeRequired, "Upgrade Required" }, - { ResponseStatus.PreconditionRequired, "Precondition Required" }, - { ResponseStatus.TooManyRequests, "Too Many Requests" }, - { ResponseStatus.RequestHeaderFieldsTooLarge, "Request Header Fields Too Large" }, - { ResponseStatus.UnavailableForLegalReasons, "Unavailable For Legal Reasons" }, - { ResponseStatus.GatewayTimeout, "Gateway Timeout" }, - { ResponseStatus.HttpVersionNotSupported, "HTTP Version Not Supported" }, - { ResponseStatus.InsufficientStorage, "Insufficient Storage" }, - { ResponseStatus.LoopDetected, "Loop Detected" }, - { ResponseStatus.NotExtended, "Not Extended" }, - { ResponseStatus.NetworkAuthenticationRequired, "Network Authentication Required" }, - { ResponseStatus.Processing, "Processing" } + { + ResponseStatus.Accepted, "Accepted" + }, + { + ResponseStatus.BadGateway, "Bad Gateway" + }, + { + ResponseStatus.BadRequest, "Bad Request" + }, + { + ResponseStatus.Created, "Created" + }, + { + ResponseStatus.Forbidden, "Forbidden" + }, + { + ResponseStatus.InternalServerError, "Internal Server Error" + }, + { + ResponseStatus.MethodNotAllowed, "Method Not Allowed" + }, + { + ResponseStatus.MovedPermanently, "Moved Permamently" + }, + { + ResponseStatus.Found, "Found" + }, + { + ResponseStatus.NoContent, "No Content" + }, + { + ResponseStatus.NotFound, "Not Found" + }, + { + ResponseStatus.NotImplemented, "Not Implemented" + }, + { + ResponseStatus.NotModified, "Not Modified" + }, + { + ResponseStatus.Ok, "OK" + }, + { + ResponseStatus.ServiceUnavailable, "Service Unavailable" + }, + { + ResponseStatus.Unauthorized, "Unauthorized" + }, + { + ResponseStatus.PartialContent, "Partial Content" + }, + { + ResponseStatus.MultiStatus, "Multi-Status" + }, + { + ResponseStatus.AlreadyReported, "Already Reported" + }, + { + ResponseStatus.SeeOther, "See Other" + }, + { + ResponseStatus.TemporaryRedirect, "Temporary Redirect" + }, + { + ResponseStatus.PermanentRedirect, "Permanent Redirect" + }, + { + ResponseStatus.Continue, "Continue" + }, + { + ResponseStatus.SwitchingProtocols, "Switching Protocols" + }, + { + ResponseStatus.NotAcceptable, "Not Acceptable" + }, + { + ResponseStatus.ProxyAuthenticationRequired, "Proxy Authentication Required" + }, + { + ResponseStatus.Conflict, "Conflict" + }, + { + ResponseStatus.Gone, "Gone" + }, + { + ResponseStatus.LengthRequired, "Length Required" + }, + { + ResponseStatus.PreconditionFailed, "Precondition Failed" + }, + { + ResponseStatus.RequestEntityTooLarge, "Request Entity Too Large" + }, + { + ResponseStatus.RequestUriTooLong, "Request Uri Too Long" + }, + { + ResponseStatus.UnsupportedMediaType, "Unsupported Media Type" + }, + { + ResponseStatus.RequestedRangeNotSatisfiable, "Requested Range Not Satisfiable" + }, + { + ResponseStatus.ExpectationFailed, "Expectation Failed" + }, + { + ResponseStatus.UnprocessableEntity, "Unprocessable Entity" + }, + { + ResponseStatus.Locked, "Locked" + }, + { + ResponseStatus.FailedDependency, "Failed Dependency" + }, + { + ResponseStatus.ReservedForWebDav, "Reserved For WebDAV" + }, + { + ResponseStatus.UpgradeRequired, "Upgrade Required" + }, + { + ResponseStatus.PreconditionRequired, "Precondition Required" + }, + { + ResponseStatus.TooManyRequests, "Too Many Requests" + }, + { + ResponseStatus.RequestHeaderFieldsTooLarge, "Request Header Fields Too Large" + }, + { + ResponseStatus.UnavailableForLegalReasons, "Unavailable For Legal Reasons" + }, + { + ResponseStatus.GatewayTimeout, "Gateway Timeout" + }, + { + ResponseStatus.HttpVersionNotSupported, "HTTP Version Not Supported" + }, + { + ResponseStatus.InsufficientStorage, "Insufficient Storage" + }, + { + ResponseStatus.LoopDetected, "Loop Detected" + }, + { + ResponseStatus.NotExtended, "Not Extended" + }, + { + ResponseStatus.NetworkAuthenticationRequired, "Network Authentication Required" + }, + { + ResponseStatus.Processing, "Processing" + } }; private static readonly Dictionary CODE_MAPPING = MAPPING.Keys.ToDictionary(k => (int)k, k => k); diff --git a/Engine/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Infrastructure/Endpoints/SecureEndPoint.cs index 3a0494b2..82f024dc 100644 --- a/Engine/Infrastructure/Endpoints/SecureEndPoint.cs +++ b/Engine/Infrastructure/Endpoints/SecureEndPoint.cs @@ -25,7 +25,9 @@ internal SecureEndPoint(IServer server, IPEndPoint endPoint, SecurityConfigurati ClientCertificateRequired = false, AllowRenegotiation = true, ApplicationProtocols = new List - { SslApplicationProtocol.Http11 }, + { + SslApplicationProtocol.Http11 + }, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, // no support for client certificates yet EncryptionPolicy = EncryptionPolicy.RequireEncryption, ServerCertificateSelectionCallback = SelectCertificate diff --git a/Engine/Infrastructure/ThreadedServerBuilder.cs b/Engine/Infrastructure/ThreadedServerBuilder.cs index dbb40e29..8cf94e5b 100644 --- a/Engine/Infrastructure/ThreadedServerBuilder.cs +++ b/Engine/Infrastructure/ThreadedServerBuilder.cs @@ -10,18 +10,18 @@ namespace GenHTTP.Engine.Infrastructure; internal sealed class ThreadedServerBuilder : IServerBuilder { + + private readonly List _Concerns = []; private readonly List _EndPoints = []; + private ushort _Backlog = 1024; private IServerCompanion? _Companion; - private IHandlerBuilder? _Handler; - - private readonly List _Concerns = []; - private bool _Development; + private IHandlerBuilder? _Handler; + private ushort _Port = 8080; - private ushort _Backlog = 1024; private uint _RequestMemoryLimit = 1 * 1024 * 1024; // 1 MB @@ -69,7 +69,10 @@ public IServer Build() var config = new ServerConfiguration(_Development, endpoints, network); - var concerns = new[] { ErrorHandler.Default() }.Concat(_Concerns); + var concerns = new[] + { + ErrorHandler.Default() + }.Concat(_Concerns); var handler = new CoreRouter(_Handler, concerns); diff --git a/Engine/Protocol/Parser/Conversion/MethodConverter.cs b/Engine/Protocol/Parser/Conversion/MethodConverter.cs index d3cc6e80..7a51195f 100644 --- a/Engine/Protocol/Parser/Conversion/MethodConverter.cs +++ b/Engine/Protocol/Parser/Conversion/MethodConverter.cs @@ -7,13 +7,27 @@ internal static class MethodConverter { private static readonly Dictionary KnownMethods = new(7) { - { "GET", RequestMethod.Get }, - { "HEAD", RequestMethod.Head }, - { "POST", RequestMethod.Post }, - { "PUT", RequestMethod.Put }, - { "PATCH", RequestMethod.Patch }, - { "DELETE", RequestMethod.Delete }, - { "OPTIONS", RequestMethod.Options } + { + "GET", RequestMethod.Get + }, + { + "HEAD", RequestMethod.Head + }, + { + "POST", RequestMethod.Post + }, + { + "PUT", RequestMethod.Put + }, + { + "PATCH", RequestMethod.Patch + }, + { + "DELETE", RequestMethod.Delete + }, + { + "OPTIONS", RequestMethod.Options + } }; internal static FlexibleRequestMethod ToRequestMethod(ReadOnlySequence value) diff --git a/Engine/Protocol/RequestQuery.cs b/Engine/Protocol/RequestQuery.cs index d49d72e2..118f09ba 100644 --- a/Engine/Protocol/RequestQuery.cs +++ b/Engine/Protocol/RequestQuery.cs @@ -11,5 +11,4 @@ internal RequestQuery() : base(DefaultSize, StringComparer.OrdinalIgnoreCase) { } - } diff --git a/Engine/Protocol/Response.cs b/Engine/Protocol/Response.cs index f41c6d07..13ce31df 100644 --- a/Engine/Protocol/Response.cs +++ b/Engine/Protocol/Response.cs @@ -59,7 +59,10 @@ public string? this[string field] { _Headers[field] = value; } - else _Headers.Remove(field); + else + { + _Headers.Remove(field); + } } } diff --git a/Engine/Protocol/ResponseHeaderCollection.cs b/Engine/Protocol/ResponseHeaderCollection.cs index 2d4aff11..f24e0020 100644 --- a/Engine/Protocol/ResponseHeaderCollection.cs +++ b/Engine/Protocol/ResponseHeaderCollection.cs @@ -9,8 +9,14 @@ internal sealed class ResponseHeaderCollection : PooledDictionary ReservedHeaders = new(StringComparer.InvariantCultureIgnoreCase) { - "Date", "Connection", "Content-Type", "Content-Encoding", "Content-Length", - "Transfer-Encoding", "Last-Modified", "Expires" + "Date", + "Connection", + "Content-Type", + "Content-Encoding", + "Content-Length", + "Transfer-Encoding", + "Last-Modified", + "Expires" }; #region Initialization diff --git a/Engine/Utilities/PooledDictionary.cs b/Engine/Utilities/PooledDictionary.cs index ae5ecd9d..a5204009 100644 --- a/Engine/Utilities/PooledDictionary.cs +++ b/Engine/Utilities/PooledDictionary.cs @@ -263,7 +263,7 @@ private void CheckResize() #region IDisposable Support - private bool _Disposed = false; + private bool _Disposed; private void Dispose(bool disposing) { diff --git a/GenHTTP.sln b/GenHTTP.sln index 6aeda4da..dcae9f04 100644 --- a/GenHTTP.sln +++ b/GenHTTP.sln @@ -88,6 +88,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Testing", "Testing\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.Pages", "Modules\Pages\GenHTTP.Modules.Pages.csproj", "{4CDA31EB-A6C2-4634-9379-9306D3996B21}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.OpenApi", "Modules\OpenApi\GenHTTP.Modules.OpenApi.csproj", "{A5149821-D510-4854-9DC9-D489323BC545}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -210,6 +212,10 @@ Global {4CDA31EB-A6C2-4634-9379-9306D3996B21}.Debug|Any CPU.Build.0 = Debug|Any CPU {4CDA31EB-A6C2-4634-9379-9306D3996B21}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CDA31EB-A6C2-4634-9379-9306D3996B21}.Release|Any CPU.Build.0 = Release|Any CPU + {A5149821-D510-4854-9DC9-D489323BC545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5149821-D510-4854-9DC9-D489323BC545}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5149821-D510-4854-9DC9-D489323BC545}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5149821-D510-4854-9DC9-D489323BC545}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -246,9 +252,10 @@ Global {C5067243-AFBA-4A17-BD61-DDB503367EA3} = {A7930BE4-0549-4197-B139-B1A73E74B464} {FC7F7D69-5ED0-4D3B-B201-EDBCEC71B8DB} = {A7930BE4-0549-4197-B139-B1A73E74B464} {4CDA31EB-A6C2-4634-9379-9306D3996B21} = {23B23225-275E-4F52-8B29-6F44C85B6ACE} + {A5149821-D510-4854-9DC9-D489323BC545} = {23B23225-275E-4F52-8B29-6F44C85B6ACE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - LessCompiler = 2603124e-1287-4d61-9540-6ac3efad4eb9 SolutionGuid = {9C67B3AF-0BF6-4E21-8C39-3F74CFCF9632} + LessCompiler = 2603124e-1287-4d61-9540-6ac3efad4eb9 EndGlobalSection EndGlobal diff --git a/Modules/Authentication/BasicAuthentication.cs b/Modules/Authentication/BasicAuthentication.cs index 046bf06f..afe4e830 100644 --- a/Modules/Authentication/BasicAuthentication.cs +++ b/Modules/Authentication/BasicAuthentication.cs @@ -23,7 +23,7 @@ public static class BasicAuthentication /// The name of the realm returned to the client /// The newly created basic authentication concern public static BasicAuthenticationConcernBuilder Create(Func> authenticator, string realm = DefaultRealm) => new BasicAuthenticationConcernBuilder().Handler(authenticator) - .Realm(realm); + .Realm(realm); /// /// Creates a basic authentication concern that stores credentials in diff --git a/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs b/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs index 4a8d61e5..21e0e504 100644 --- a/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs +++ b/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs @@ -15,7 +15,6 @@ internal class OpenIDConfiguration [JsonPropertyName("jwks_uri")] public string? KeySetUrl { get; init; } - } #endregion diff --git a/Modules/Authentication/Extensions.cs b/Modules/Authentication/Extensions.cs index d4f77010..4bea3cd6 100644 --- a/Modules/Authentication/Extensions.cs +++ b/Modules/Authentication/Extensions.cs @@ -12,14 +12,10 @@ public static void SetUser(this IRequest request, IUser user) request.Properties[UserProperty] = user; } - public static T? GetUser(this IRequest request) where T : class, IUser - { - return request.Properties.TryGet(UserProperty, out var user) ? user : null; - } + public static T? GetUser(this IRequest request) where T : class, IUser => request.Properties.TryGet(UserProperty, out var user) ? user : null; public static void ClearUser(this IRequest request) { request.Properties.Clear(UserProperty); } - } diff --git a/Modules/Basics/CoreExtensions.cs b/Modules/Basics/CoreExtensions.cs index 35863319..93f69d78 100644 --- a/Modules/Basics/CoreExtensions.cs +++ b/Modules/Basics/CoreExtensions.cs @@ -69,66 +69,168 @@ public static bool HasType(this IRequest request, params RequestMethod[] methods private static readonly Dictionary ContentTypes = new() { // CSS - { "css", ContentType.TextCss }, + { + "css", ContentType.TextCss + }, // HTML - { "html", ContentType.TextHtml }, - { "htm", ContentType.TextHtml }, + { + "html", ContentType.TextHtml + }, + { + "htm", ContentType.TextHtml + }, // Text files - { "txt", ContentType.TextPlain }, - { "conf", ContentType.TextPlain }, - { "config", ContentType.TextPlain }, + { + "txt", ContentType.TextPlain + }, + { + "conf", ContentType.TextPlain + }, + { + "config", ContentType.TextPlain + }, // Fonts - { "eot", ContentType.FontEmbeddedOpenTypeFont }, - { "ttf", ContentType.FontTrueTypeFont }, - { "otf", ContentType.FontOpenTypeFont }, - { "woff", ContentType.FontWoff }, - { "woff2", ContentType.FontWoff2 }, + { + "eot", ContentType.FontEmbeddedOpenTypeFont + }, + { + "ttf", ContentType.FontTrueTypeFont + }, + { + "otf", ContentType.FontOpenTypeFont + }, + { + "woff", ContentType.FontWoff + }, + { + "woff2", ContentType.FontWoff2 + }, // Scripts - { "js", ContentType.ApplicationJavaScript }, - { "mjs", ContentType.ApplicationJavaScript }, + { + "js", ContentType.ApplicationJavaScript + }, + { + "mjs", ContentType.ApplicationJavaScript + }, // Images - { "ico", ContentType.ImageIcon }, - { "gif", ContentType.ImageGif }, - { "jpeg", ContentType.ImageJpg }, - { "jpg", ContentType.ImageJpg }, - { "png", ContentType.ImagePng }, - { "bmp", ContentType.ImageBmp }, - { "tiff", ContentType.ImageTiff }, - { "svg", ContentType.ImageScalableVectorGraphicsXml }, - { "svgz", ContentType.ImageScalableVectorGraphicsCompressed }, + { + "ico", ContentType.ImageIcon + }, + { + "gif", ContentType.ImageGif + }, + { + "jpeg", ContentType.ImageJpg + }, + { + "jpg", ContentType.ImageJpg + }, + { + "png", ContentType.ImagePng + }, + { + "bmp", ContentType.ImageBmp + }, + { + "tiff", ContentType.ImageTiff + }, + { + "svg", ContentType.ImageScalableVectorGraphicsXml + }, + { + "svgz", ContentType.ImageScalableVectorGraphicsCompressed + }, // Audio - { "ogg", ContentType.AudioOgg }, - { "mp3", ContentType.AudioMpeg }, - { "wav", ContentType.AudioWav }, + { + "ogg", ContentType.AudioOgg + }, + { + "mp3", ContentType.AudioMpeg + }, + { + "wav", ContentType.AudioWav + }, // Video - { "avi", ContentType.VideoMpeg }, - { "3gp", ContentType.Video3Gpp }, - { "3g2", ContentType.Video3Gpp2 }, - { "av1", ContentType.VideoAV1 }, - { "avc", ContentType.VideoAvc }, - { "dv", ContentType.VideoDV }, - { "mkv", ContentType.VideoMatroska }, - { "mk3d", ContentType.VideoMatroska3D }, - { "mj2", ContentType.VideoMJ2 }, - { "mpg", ContentType.VideoMpeg }, - { "mp4", ContentType.VideoMP4 }, - { "mpeg", ContentType.VideoMpeg }, - { "mpv", ContentType.VideoMpv }, - { "mov", ContentType.VideoQuicktime }, - { "hdmov", ContentType.VideoQuicktime }, - { "vc1", ContentType.VideoVC1 }, - { "vc2", ContentType.VideoVC2 }, - { "webm", ContentType.VideoWebM }, + { + "avi", ContentType.VideoMpeg + }, + { + "3gp", ContentType.Video3Gpp + }, + { + "3g2", ContentType.Video3Gpp2 + }, + { + "av1", ContentType.VideoAV1 + }, + { + "avc", ContentType.VideoAvc + }, + { + "dv", ContentType.VideoDV + }, + { + "mkv", ContentType.VideoMatroska + }, + { + "mk3d", ContentType.VideoMatroska3D + }, + { + "mj2", ContentType.VideoMJ2 + }, + { + "mpg", ContentType.VideoMpeg + }, + { + "mp4", ContentType.VideoMP4 + }, + { + "mpeg", ContentType.VideoMpeg + }, + { + "mpv", ContentType.VideoMpv + }, + { + "mov", ContentType.VideoQuicktime + }, + { + "hdmov", ContentType.VideoQuicktime + }, + { + "vc1", ContentType.VideoVC1 + }, + { + "vc2", ContentType.VideoVC2 + }, + { + "webm", ContentType.VideoWebM + }, // Documents - { "csv", ContentType.TextCsv }, - { "rtf", ContentType.TextRichText }, - { "docx", ContentType.ApplicationOfficeDocumentWordProcessing }, - { "pptx", ContentType.ApplicationOfficeDocumentPresentation }, - { "ppsx", ContentType.ApplicationOfficeDocumentSlideshow }, - { "xslx", ContentType.ApplicationOfficeDocumentSheet }, + { + "csv", ContentType.TextCsv + }, + { + "rtf", ContentType.TextRichText + }, + { + "docx", ContentType.ApplicationOfficeDocumentWordProcessing + }, + { + "pptx", ContentType.ApplicationOfficeDocumentPresentation + }, + { + "ppsx", ContentType.ApplicationOfficeDocumentSlideshow + }, + { + "xslx", ContentType.ApplicationOfficeDocumentSheet + }, // Object models - { "json", ContentType.ApplicationJson }, - { "xml", ContentType.TextXml } + { + "json", ContentType.ApplicationJson + }, + { + "xml", ContentType.TextXml + } }; public static ContentType? GuessContentType(this string fileName) diff --git a/Modules/Compression/Providers/CompressionConcern.cs b/Modules/Compression/Providers/CompressionConcern.cs index cc50f9ef..50489b56 100644 --- a/Modules/Compression/Providers/CompressionConcern.cs +++ b/Modules/Compression/Providers/CompressionConcern.cs @@ -110,7 +110,7 @@ private static bool ShouldCompress(WebPath path, ContentType? type) case ContentType.ImageBmp: case ContentType.TextXml: case ContentType.TextJavaScript: - + case ContentType.ApplicationYaml: { return true; } diff --git a/Modules/Controllers/Controller.cs b/Modules/Controllers/Controller.cs index 8c95270b..f2a33a2b 100644 --- a/Modules/Controllers/Controller.cs +++ b/Modules/Controllers/Controller.cs @@ -19,5 +19,4 @@ public static class Controller /// The instance to be used /// The newly created request handler public static ControllerBuilder From(object instance) => new ControllerBuilder().Instance(instance); - } diff --git a/Modules/Controllers/ControllerActionAttribute.cs b/Modules/Controllers/ControllerActionAttribute.cs index c1aeceb1..9f8661b7 100644 --- a/Modules/Controllers/ControllerActionAttribute.cs +++ b/Modules/Controllers/ControllerActionAttribute.cs @@ -17,5 +17,4 @@ public ControllerActionAttribute(params RequestMethod[] methods) : base(methods) /// /// The request methods which are supported by this action public ControllerActionAttribute(params FlexibleRequestMethod[] methods) : base(methods) { } - } diff --git a/Modules/Controllers/Provider/ControllerBuilder.cs b/Modules/Controllers/Provider/ControllerBuilder.cs index 385c3f4f..e15aa912 100644 --- a/Modules/Controllers/Provider/ControllerBuilder.cs +++ b/Modules/Controllers/Provider/ControllerBuilder.cs @@ -17,10 +17,10 @@ public sealed class ControllerBuilder : IHandlerBuilder private IBuilder? _Injection; - private IBuilder? _Serializers; - private object? _Instance; + private IBuilder? _Serializers; + #region Functionality public ControllerBuilder Serializers(IBuilder registry) @@ -69,7 +69,9 @@ public IHandler Build(IHandler parent) var instance = _Instance ?? throw new BuilderMissingPropertyException("Instance or Type"); - return Concerns.Chain(parent, _Concerns, p => new ControllerHandler(p, instance, serializers, injectors, formatters)); + var extensions = new MethodRegistry(serializers, injectors, formatters); + + return Concerns.Chain(parent, _Concerns, p => new ControllerHandler(p, instance, extensions)); } #endregion diff --git a/Modules/Controllers/Provider/ControllerHandler.cs b/Modules/Controllers/Provider/ControllerHandler.cs index ce0921e9..dd6a9c0c 100644 --- a/Modules/Controllers/Provider/ControllerHandler.cs +++ b/Modules/Controllers/Provider/ControllerHandler.cs @@ -2,28 +2,24 @@ using System.Text.RegularExpressions; using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; -using GenHTTP.Modules.Conversion.Formatters; -using GenHTTP.Modules.Conversion.Serializers; using GenHTTP.Modules.Reflection; -using GenHTTP.Modules.Reflection.Injectors; +using GenHTTP.Modules.Reflection.Operations; namespace GenHTTP.Modules.Controllers.Provider; -public sealed partial class ControllerHandler : IHandler +public sealed partial class ControllerHandler : IHandler, IServiceMethodProvider { - private static readonly MethodRouting Empty = new("^(/|)$", true, false); - private static readonly Regex HyphenMatcher = CreateHyphenMatcher(); #region Get-/Setters public IHandler Parent { get; } - private MethodCollection Provider { get; } + public MethodCollection Methods { get; } private ResponseProvider ResponseProvider { get; } - private FormatterRegistry Formatting { get; } + private MethodRegistry Registry { get; } private object Instance { get; } @@ -31,19 +27,19 @@ public sealed partial class ControllerHandler : IHandler #region Initialization - public ControllerHandler(IHandler parent, object instance, SerializationRegistry serialization, InjectionRegistry injection, FormatterRegistry formatting) + public ControllerHandler(IHandler parent, object instance, MethodRegistry registry) { Parent = parent; - Formatting = formatting; + Registry = registry; Instance = instance; - ResponseProvider = new ResponseProvider(serialization, formatting); + ResponseProvider = new ResponseProvider(registry); - Provider = new MethodCollection(this, AnalyzeMethods(instance.GetType(), serialization, injection, formatting)); + Methods = new MethodCollection(this, AnalyzeMethods(instance.GetType(), registry)); } - private IEnumerable> AnalyzeMethods(Type type, SerializationRegistry serialization, InjectionRegistry injection, FormatterRegistry formatting) + private IEnumerable> AnalyzeMethods(Type type, MethodRegistry registry) { foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { @@ -51,56 +47,40 @@ private IEnumerable> AnalyzeMethods(Type type, Ser var arguments = FindPathArguments(method); - var path = DeterminePath(method, arguments); + var operation = CreateOperation(method, arguments); - yield return parent => new MethodHandler(parent, method, path, () => Instance, annotation, ResponseProvider.GetResponseAsync, serialization, injection, formatting); + yield return parent => new MethodHandler(parent, operation, Instance, annotation, registry); } } - private static MethodRouting DeterminePath(MethodInfo method, List arguments) + private Operation CreateOperation(MethodInfo method, List arguments) { - var pathArgs = string.Join('/', arguments.Select(a => a.ToParameter())); - - var isWildcard = PathArguments.CheckWildcardRoute(method.ReturnType); + var pathArguments = string.Join('/', arguments.Select(a => $":{a}")); if (method.Name == "Index") { - return pathArgs.Length > 0 ? new MethodRouting($"^/{pathArgs}/", false, isWildcard) : Empty; + return OperationBuilder.Create(pathArguments.Length > 0 ? $"/{pathArguments}/" : null, method, Registry, true); } var name = HypenCase(method.Name); - var path = $"^/{name}"; + var path = $"/{name}"; - return pathArgs.Length > 0 ? new MethodRouting( $"{path}/{pathArgs}/", false, isWildcard) : new MethodRouting($"{path}/", false, isWildcard); + return OperationBuilder.Create(pathArguments.Length > 0 ? $"{path}/{pathArguments}/" : $"{path}/", method, Registry, true); } - private List FindPathArguments(MethodInfo method) + private static List FindPathArguments(MethodInfo method) { var found = new List(); - var parameters = method.GetParameters(); - - foreach (var parameter in parameters) + foreach (var parameter in method.GetParameters()) { - if (parameter.GetCustomAttribute(true) is not null) + if (parameter.Name != null) { - if (!parameter.CanFormat(Formatting)) - { - throw new InvalidOperationException("Parameters marked as 'FromPath' must be formattable (e.g. string or int)"); - } - - if (parameter.CheckNullable()) + if (parameter.GetCustomAttribute(true) is not null) { - throw new InvalidOperationException("Parameters marked as 'FromPath' are not allowed to be nullable"); + found.Add(parameter.Name); } - - if (parameter.Name is null) - { - throw new InvalidOperationException("Parameters marked as 'FromPath' must have a name"); - } - - found.Add(parameter.Name); } } @@ -116,9 +96,9 @@ private List FindPathArguments(MethodInfo method) #region Functionality - public ValueTask PrepareAsync() => Provider.PrepareAsync(); + public ValueTask PrepareAsync() => Methods.PrepareAsync(); - public ValueTask HandleAsync(IRequest request) => Provider.HandleAsync(request); + public ValueTask HandleAsync(IRequest request) => Methods.HandleAsync(request); #endregion diff --git a/Modules/Conversion/Formatters/BoolFormatter.cs b/Modules/Conversion/Formatters/BoolFormatter.cs index 32fbf796..0a627a27 100644 --- a/Modules/Conversion/Formatters/BoolFormatter.cs +++ b/Modules/Conversion/Formatters/BoolFormatter.cs @@ -22,5 +22,4 @@ public sealed class BoolFormatter : IFormatter public string Write(object value, Type type) => (bool)value ? "1" : "0"; private static bool Compare(string value, string expected) => string.Equals(value, expected, StringComparison.InvariantCultureIgnoreCase); - } diff --git a/Modules/Conversion/Formatters/DateOnlyFormatter.cs b/Modules/Conversion/Formatters/DateOnlyFormatter.cs index 0c293863..b0aebc16 100644 --- a/Modules/Conversion/Formatters/DateOnlyFormatter.cs +++ b/Modules/Conversion/Formatters/DateOnlyFormatter.cs @@ -6,9 +6,6 @@ public sealed partial class DateOnlyFormatter : IFormatter { private static readonly Regex DateOnlyPattern = CreateDateOnlyPattern(); - [GeneratedRegex(@"^([0-9]{4})\-([0-9]{2})\-([0-9]{2})$", RegexOptions.Compiled)] - private static partial Regex CreateDateOnlyPattern(); - public bool CanHandle(Type type) => type == typeof(DateOnly); public object Read(string value, Type type) @@ -29,4 +26,6 @@ public object Read(string value, Type type) public string Write(object value, Type type) => ((DateOnly)value).ToString("yyyy-MM-dd"); + [GeneratedRegex(@"^([0-9]{4})\-([0-9]{2})\-([0-9]{2})$", RegexOptions.Compiled)] + private static partial Regex CreateDateOnlyPattern(); } diff --git a/Modules/Conversion/Formatters/EnumFormatter.cs b/Modules/Conversion/Formatters/EnumFormatter.cs index f0c92f18..e88a0056 100644 --- a/Modules/Conversion/Formatters/EnumFormatter.cs +++ b/Modules/Conversion/Formatters/EnumFormatter.cs @@ -8,5 +8,4 @@ public sealed class EnumFormatter : IFormatter public object Read(string value, Type type) => Enum.Parse(type, value); public string? Write(object value, Type type) => value.ToString(); - } diff --git a/Modules/Conversion/Formatters/GuidFormatter.cs b/Modules/Conversion/Formatters/GuidFormatter.cs index 24f98dca..2ca5e498 100644 --- a/Modules/Conversion/Formatters/GuidFormatter.cs +++ b/Modules/Conversion/Formatters/GuidFormatter.cs @@ -8,5 +8,4 @@ public sealed class GuidFormatter : IFormatter public object Read(string value, Type type) => Guid.Parse(value); public string? Write(object value, Type type) => value.ToString(); - } diff --git a/Modules/Conversion/Formatters/IFormatter.cs b/Modules/Conversion/Formatters/IFormatter.cs index fd3340c5..60ae306a 100644 --- a/Modules/Conversion/Formatters/IFormatter.cs +++ b/Modules/Conversion/Formatters/IFormatter.cs @@ -36,5 +36,4 @@ public interface IFormatter /// or to generate form encoded data. /// string? Write(object value, Type type); - } diff --git a/Modules/Conversion/Formatters/PrimitiveFormatter.cs b/Modules/Conversion/Formatters/PrimitiveFormatter.cs index 93babfef..272d61ca 100644 --- a/Modules/Conversion/Formatters/PrimitiveFormatter.cs +++ b/Modules/Conversion/Formatters/PrimitiveFormatter.cs @@ -10,5 +10,4 @@ public sealed class PrimitiveFormatter : IFormatter public object Read(string value, Type type) => Convert.ChangeType(value, type, CultureInfo.InvariantCulture); public string? Write(object value, Type type) => Convert.ChangeType(value, typeof(string), CultureInfo.InvariantCulture) as string; - } diff --git a/Modules/Conversion/Formatters/StringFormatter.cs b/Modules/Conversion/Formatters/StringFormatter.cs index bd1ba8f3..b0660f87 100644 --- a/Modules/Conversion/Formatters/StringFormatter.cs +++ b/Modules/Conversion/Formatters/StringFormatter.cs @@ -8,5 +8,4 @@ public sealed class StringFormatter : IFormatter public object Read(string value, Type type) => value; public string Write(object value, Type type) => (string)value; - } diff --git a/Modules/Conversion/Formatting.cs b/Modules/Conversion/Formatting.cs index 7f55bb5c..c5e55358 100644 --- a/Modules/Conversion/Formatting.cs +++ b/Modules/Conversion/Formatting.cs @@ -27,5 +27,4 @@ public static class Formatting /// /// An empty formatter registry public static FormatterBuilder Empty() => new(); - } diff --git a/Modules/Conversion/Serialization.cs b/Modules/Conversion/Serialization.cs index 1756ea4b..58a8ee66 100644 --- a/Modules/Conversion/Serialization.cs +++ b/Modules/Conversion/Serialization.cs @@ -26,5 +26,4 @@ public static class Serialization /// Returns an empty registry to be customized. /// public static SerializationBuilder Empty() => new(); - } diff --git a/Modules/Conversion/Serializers/Forms/FormContent.cs b/Modules/Conversion/Serializers/Forms/FormContent.cs index 8002f8e0..3e01318d 100644 --- a/Modules/Conversion/Serializers/Forms/FormContent.cs +++ b/Modules/Conversion/Serializers/Forms/FormContent.cs @@ -55,8 +55,8 @@ public async ValueTask WriteAsync(Stream target, uint bufferSize) } var replaced = query.ToString()? - .Replace("+", "%20") - .Replace("%2b", "+"); + .Replace("+", "%20") + .Replace("%2b", "+"); await writer.WriteAsync(replaced); } diff --git a/Modules/Conversion/Serializers/SerializationRegistry.cs b/Modules/Conversion/Serializers/SerializationRegistry.cs index cf205a59..c7a85e6d 100644 --- a/Modules/Conversion/Serializers/SerializationRegistry.cs +++ b/Modules/Conversion/Serializers/SerializationRegistry.cs @@ -25,7 +25,7 @@ public SerializationRegistry(FlexibleContentType defaultType, private FlexibleContentType Default { get; } - private Dictionary Formats { get; } + public IReadOnlyDictionary Formats { get; } #endregion diff --git a/Modules/Functional/Provider/InlineBuilder.cs b/Modules/Functional/Provider/InlineBuilder.cs index aac4501e..cdbe1a44 100644 --- a/Modules/Functional/Provider/InlineBuilder.cs +++ b/Modules/Functional/Provider/InlineBuilder.cs @@ -60,7 +60,7 @@ public InlineBuilder Formatters(IBuilder registry) /// Adds a route for a request of any type to the root of the handler. /// /// The logic to be executed - public InlineBuilder Any(Delegate function) => On(function, AllMethods, null); + public InlineBuilder Any(Delegate function) => On(function, AllMethods); /// /// Adds a route for a request of any type to the specified path. @@ -74,7 +74,9 @@ public InlineBuilder Formatters(IBuilder registry) /// /// The logic to be executed public InlineBuilder Get(Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Get) }, null); + { + FlexibleRequestMethod.Get(RequestMethod.Get) + }); /// /// Adds a route for a GET request to the specified path. @@ -82,63 +84,81 @@ public InlineBuilder Formatters(IBuilder registry) /// The path of the request to handle (e.g. "/my-method") /// The logic to be executed public InlineBuilder Get(string path, Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Get) }, path); + { + FlexibleRequestMethod.Get(RequestMethod.Get) + }, path); /// /// Adds a route for a HEAD request to the root of the handler. /// /// The logic to be executed public InlineBuilder Head(Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Head) }); + { + FlexibleRequestMethod.Get(RequestMethod.Head) + }); /// /// Adds a route for a HEAD request to the specified path. /// /// The path of the request to handle (e.g. "/my-method") public InlineBuilder Head(string path, Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Head) }, path); + { + FlexibleRequestMethod.Get(RequestMethod.Head) + }, path); /// /// Adds a route for a POST request to the root of the handler. /// /// The logic to be executed public InlineBuilder Post(Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Post) }); + { + FlexibleRequestMethod.Get(RequestMethod.Post) + }); /// /// Adds a route for a POST request to the specified path. /// /// The path of the request to handle (e.g. "/my-method") public InlineBuilder Post(string path, Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Post) }, path); + { + FlexibleRequestMethod.Get(RequestMethod.Post) + }, path); /// /// Adds a route for a PUT request to the root of the handler. /// /// The logic to be executed public InlineBuilder Put(Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Put) }); + { + FlexibleRequestMethod.Get(RequestMethod.Put) + }); /// /// Adds a route for a PUT request to the specified path. /// /// The path of the request to handle (e.g. "/my-method") public InlineBuilder Put(string path, Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Put) }, path); + { + FlexibleRequestMethod.Get(RequestMethod.Put) + }, path); /// /// Adds a route for a DELETE request to the root of the handler. /// /// The logic to be executed public InlineBuilder Delete(Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Delete) }); + { + FlexibleRequestMethod.Get(RequestMethod.Delete) + }); /// /// Adds a route for a DELETE request to the specified path. /// /// The path of the request to handle (e.g. "/my-method") public InlineBuilder Delete(string path, Delegate function) => On(function, new HashSet - { FlexibleRequestMethod.Get(RequestMethod.Delete) }, path); + { + FlexibleRequestMethod.Get(RequestMethod.Delete) + }, path); /// /// Executes the given function for the specified path and method. @@ -181,7 +201,9 @@ public IHandler Build(IHandler parent) var formatters = (_Formatters ?? Formatting.Default()).Build(); - return Concerns.Chain(parent, _Concerns, p => new InlineHandler(p, _Functions, serializers, injectors, formatters)); + var extensions = new MethodRegistry(serializers, injectors, formatters); + + return Concerns.Chain(parent, _Concerns, p => new InlineHandler(p, _Functions, extensions)); } #endregion diff --git a/Modules/Functional/Provider/InlineHandler.cs b/Modules/Functional/Provider/InlineHandler.cs index 0cfbbf35..0e3574e9 100644 --- a/Modules/Functional/Provider/InlineHandler.cs +++ b/Modules/Functional/Provider/InlineHandler.cs @@ -1,20 +1,18 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; -using GenHTTP.Modules.Conversion.Formatters; -using GenHTTP.Modules.Conversion.Serializers; using GenHTTP.Modules.Reflection; -using GenHTTP.Modules.Reflection.Injectors; +using GenHTTP.Modules.Reflection.Operations; namespace GenHTTP.Modules.Functional.Provider; -public class InlineHandler : IHandler +public class InlineHandler : IHandler, IServiceMethodProvider { #region Get-/Setters public IHandler Parent { get; } - private MethodCollection Methods { get; } + public MethodCollection Methods { get; } private ResponseProvider ResponseProvider { get; } @@ -22,28 +20,26 @@ public class InlineHandler : IHandler #region Initialization - public InlineHandler(IHandler parent, List functions, SerializationRegistry serialization, InjectionRegistry injection, FormatterRegistry formatting) + public InlineHandler(IHandler parent, List functions, MethodRegistry registry) { Parent = parent; - ResponseProvider = new ResponseProvider(serialization, formatting); + ResponseProvider = new ResponseProvider(registry); - Methods = new MethodCollection(this, AnalyzeMethods(functions, serialization, injection, formatting)); + Methods = new MethodCollection(this, AnalyzeMethods(functions, registry)); } - private IEnumerable> AnalyzeMethods(List functions, SerializationRegistry formats, InjectionRegistry injection, FormatterRegistry formatting) + private static IEnumerable> AnalyzeMethods(List functions, MethodRegistry registry) { foreach (var function in functions) { var method = function.Delegate.Method; - var wildcardRoute = PathArguments.CheckWildcardRoute(method.ReturnType); - - var path = PathArguments.Route(function.Path, wildcardRoute); + var operation = OperationBuilder.Create(function.Path, method, registry); var target = function.Delegate.Target ?? throw new InvalidOperationException("Delegate target must not be null"); - yield return parent => new MethodHandler(parent, method, path, () => target, function.Configuration, ResponseProvider.GetResponseAsync, formats, injection, formatting); + yield return parent => new MethodHandler(parent, operation, target, function.Configuration, registry); } } diff --git a/Modules/Layouting/Provider/LayoutRouter.cs b/Modules/Layouting/Provider/LayoutRouter.cs index eb0c7441..7fe4ac84 100644 --- a/Modules/Layouting/Provider/LayoutRouter.cs +++ b/Modules/Layouting/Provider/LayoutRouter.cs @@ -29,11 +29,11 @@ public LayoutRouter(IHandler parent, public IHandler Parent { get; } - private Dictionary RoutedHandlers { get; } + public IReadOnlyDictionary RoutedHandlers { get; } - private List RootHandlers { get; } + public IReadOnlyList RootHandlers { get; } - private IHandler? Index { get; } + public IHandler? Index { get; } #endregion diff --git a/Modules/OpenApi/ApiDescription.cs b/Modules/OpenApi/ApiDescription.cs new file mode 100644 index 00000000..3f0797c7 --- /dev/null +++ b/Modules/OpenApi/ApiDescription.cs @@ -0,0 +1,34 @@ +using GenHTTP.Modules.OpenApi.Discovery; +using GenHTTP.Modules.OpenApi.Handler; + +namespace GenHTTP.Modules.OpenApi; + +/// +/// Provides a concern that will analyze the inner handler and +/// automatically generate an OpenAPI specification and +/// provide it to the clients when requested. +/// +public static class ApiDescription +{ + + /// + /// Creates a pre-configured concern to be added to a layout or any other handler. + /// + /// The pre-configured concern that will generate an OpenAPI specification on request + /// + /// The generated concern will crawl through the inner handler chain and analyze the following + /// types of content: Layouts, Concerns, Functional Handlers, Webservices, Controllers. + /// If you use other handlers or specific concerns to provide your API, you will need to implement + /// instances and pass them to the method. + /// + public static OpenApiConcernBuilder Create() => With(ApiDiscovery.Default()); + + /// + /// Creates a concern that will use the given discovery configuration to search for API endpoints + /// to be added to the generated OpenAPI specification. + /// + /// The explorer registry to be used to analyze the handler chain + /// The newly generated concern + public static OpenApiConcernBuilder With(ApiDiscoveryRegistryBuilder discovery) => new(discovery.Build()); + +} diff --git a/Modules/OpenApi/ApiDiscovery.cs b/Modules/OpenApi/ApiDiscovery.cs new file mode 100644 index 00000000..d708367e --- /dev/null +++ b/Modules/OpenApi/ApiDiscovery.cs @@ -0,0 +1,28 @@ +using GenHTTP.Modules.OpenApi.Discovery; + +namespace GenHTTP.Modules.OpenApi; + +/// +/// Provides capabilities used by the OpenAPI generation feature to analyze +/// the functionality provided by your API. +/// +public static class ApiDiscovery +{ + + /// + /// Creates an empty registry. + /// + /// The newly created, empty registry + public static ApiDiscoveryRegistryBuilder Empty() => new(); + + /// + /// Creates a registry that supports layouts, concerns, functional handlers, + /// controllers and webservices for automatic content discovery. + /// + /// The default registry to use as a basis + public static ApiDiscoveryRegistryBuilder Default() => Empty().Add() + .Add() + .Add() + .Add() + .Add(); +} diff --git a/Modules/OpenApi/Discovery/ApiDiscoveryRegistry.cs b/Modules/OpenApi/Discovery/ApiDiscoveryRegistry.cs new file mode 100644 index 00000000..1ab1a71c --- /dev/null +++ b/Modules/OpenApi/Discovery/ApiDiscoveryRegistry.cs @@ -0,0 +1,48 @@ +using GenHTTP.Api.Content; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class ApiDiscoveryRegistry +{ + + #region Get-/Setters + + private List Explorers { get; } + + #endregion + + #region Initialization + + public ApiDiscoveryRegistry(List explorers) + { + Explorers = explorers; + } + + #endregion + + #region Functionality + + /// + /// Iterates through the registered explorers to find a responsible one to analyze + /// the given handler instance. + /// + /// The handler to get analyzed + /// The current stack of path segments that have already been analyzed, relative to the location of the OpenAPI concern + /// The document to be adjusted and enriched + /// The manager to generate JSON schemas with + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata) + { + foreach (var explorer in Explorers) + { + if (explorer.CanExplore(handler)) + { + explorer.Explore(handler, path, document, schemata, this); + break; + } + } + } + + #endregion + +} diff --git a/Modules/OpenApi/Discovery/ApiDiscoveryRegistryBuilder.cs b/Modules/OpenApi/Discovery/ApiDiscoveryRegistryBuilder.cs new file mode 100644 index 00000000..ee632011 --- /dev/null +++ b/Modules/OpenApi/Discovery/ApiDiscoveryRegistryBuilder.cs @@ -0,0 +1,31 @@ +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class ApiDiscoveryRegistryBuilder : IBuilder +{ + private readonly List _Explorers = []; + + #region Functionality + + /// + /// Adds the given explorer to the registry. + /// + /// The type of the explorer to be added + public ApiDiscoveryRegistryBuilder Add() where TExplorer : IApiExplorer, new() => Add(new TExplorer()); + + /// + /// Adds the given explorer to the registry. + /// + /// The explorer to be added + public ApiDiscoveryRegistryBuilder Add(IApiExplorer explorer) + { + _Explorers.Add(explorer); + return this; + } + + public ApiDiscoveryRegistry Build() => new(_Explorers); + + #endregion + +} diff --git a/Modules/OpenApi/Discovery/ConcernExplorer.cs b/Modules/OpenApi/Discovery/ConcernExplorer.cs new file mode 100644 index 00000000..84e2bcf9 --- /dev/null +++ b/Modules/OpenApi/Discovery/ConcernExplorer.cs @@ -0,0 +1,19 @@ +using GenHTTP.Api.Content; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class ConcernExplorer : IApiExplorer +{ + + public bool CanExplore(IHandler handler) => handler is IConcern; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + if (handler is IConcern concern) + { + registry.Explore(concern.Content, path, document, schemata); + } + } + +} diff --git a/Modules/OpenApi/Discovery/Extensions.cs b/Modules/OpenApi/Discovery/Extensions.cs new file mode 100644 index 00000000..8012511c --- /dev/null +++ b/Modules/OpenApi/Discovery/Extensions.cs @@ -0,0 +1,16 @@ +namespace GenHTTP.Modules.OpenApi.Discovery; + +internal static class Extensions +{ + + internal static bool MightBeNull(this Type type) + { + if (type.IsClass) + { + return true; + } + + return Nullable.GetUnderlyingType(type) != null; + } + +} diff --git a/Modules/OpenApi/Discovery/LayoutExplorer.cs b/Modules/OpenApi/Discovery/LayoutExplorer.cs new file mode 100644 index 00000000..6f14217a --- /dev/null +++ b/Modules/OpenApi/Discovery/LayoutExplorer.cs @@ -0,0 +1,27 @@ +using GenHTTP.Api.Content; +using GenHTTP.Modules.Layouting.Provider; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class LayoutExplorer : IApiExplorer +{ + + public bool CanExplore(IHandler handler) => handler is LayoutRouter; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + if (handler is LayoutRouter layout) + { + foreach (var root in layout.RootHandlers) + { + registry.Explore(root, path, document, schemata); + } + + foreach (var (route, routeHandler) in layout.RoutedHandlers) + { + registry.Explore(routeHandler, [..path, route], document, schemata); + } + } + } +} diff --git a/Modules/OpenApi/Discovery/MethodCollectionExplorer.cs b/Modules/OpenApi/Discovery/MethodCollectionExplorer.cs new file mode 100644 index 00000000..9b53ebb3 --- /dev/null +++ b/Modules/OpenApi/Discovery/MethodCollectionExplorer.cs @@ -0,0 +1,22 @@ +using GenHTTP.Api.Content; +using GenHTTP.Modules.Reflection; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class MethodCollectionExplorer : IApiExplorer +{ + + public bool CanExplore(IHandler handler) => handler is MethodCollection; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + if (handler is MethodCollection collection) + { + foreach (var method in collection.Methods) + { + registry.Explore(method, path, document, schemata); + } + } + } +} diff --git a/Modules/OpenApi/Discovery/MethodHandlerExplorer.cs b/Modules/OpenApi/Discovery/MethodHandlerExplorer.cs new file mode 100644 index 00000000..f232e331 --- /dev/null +++ b/Modules/OpenApi/Discovery/MethodHandlerExplorer.cs @@ -0,0 +1,276 @@ +using System.Text; +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Reflection; +using GenHTTP.Modules.Reflection.Operations; +using NJsonSchema; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class MethodHandlerExplorer : IApiExplorer +{ + + public bool CanExplore(IHandler handler) => handler is MethodHandler; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + if (handler is MethodHandler methodHandler) + { + var tag = GetTag(methodHandler.Operation); + + if (tag != null) + { + if (document.Tags.All(t => t.Name != tag)) + { + document.Tags.Add(new OpenApiTag + { + Name = tag + }); + } + } + + var pathItem = GetPathItem(document, path, methodHandler.Operation); + + foreach (var method in methodHandler.Configuration.SupportedMethods) + { + if (method == RequestMethod.Head && methodHandler.Configuration.SupportedMethods.Count > 1) + { + continue; + } + + var operation = new OpenApiOperation + { + IsDeprecated = methodHandler.Operation.Method.GetCustomAttributes(typeof(ObsoleteAttribute), true).Length > 0 + }; + + if (tag != null) + { + operation.Tags.Add(tag); + } + + foreach (var arg in methodHandler.Operation.Arguments) + { + if (arg.Value.Source == OperationArgumentSource.Injected) + { + continue; + } + + if (arg.Value.Source == OperationArgumentSource.Body) + { + if (method.KnownMethod != RequestMethod.Get) + { + operation.RequestBody = GetRequestBody(schemata, typeof(string), "text/plain"); + } + } + else if (arg.Value.Source == OperationArgumentSource.Content) + { + if (method.KnownMethod != RequestMethod.Get) + { + var supportedTypes = methodHandler.Registry.Serialization.Formats.Select(s => s.Key.RawType).ToArray(); + operation.RequestBody = GetRequestBody(schemata, arg.Value.Type, supportedTypes); + } + } + else if (arg.Value.Source == OperationArgumentSource.Streamed) + { + if (method.KnownMethod != RequestMethod.Get) + { + var body = new OpenApiRequestBody(); + + body.Content.Add("*/*", new OpenApiMediaType + { + Schema = new JsonSchema + { + Format = "binary" + } + }); + + operation.RequestBody = body; + } + } + else + { + var param = new OpenApiParameter + { + Name = arg.Key, + Schema = JsonSchema.FromType(arg.Value.Type), + Kind = MapArgumentType(arg.Value.Source), + IsRequired = MapRequired(arg.Value.Source) + }; + + operation.Parameters.Add(param); + } + } + + foreach (var (key, value) in GetResponses(methodHandler.Operation, schemata, methodHandler.Registry)) + { + operation.Responses.Add(key, value); + } + + pathItem.Add(method.RawMethod, operation); + } + } + } + + private static OpenApiPathItem GetPathItem(OpenApiDocument document, List path, Operation operation) + { + var stringPath = BuildPath(operation.Path.Name, path); + + if (document.Paths.TryGetValue(stringPath, out var existing)) + { + return existing; + } + + var newPath = new OpenApiPathItem(); + + document.Paths.Add(stringPath, newPath); + + return newPath; + } + + private static string BuildPath(string name, List pathParts) + { + var builder = new StringBuilder("/"); + + if (pathParts.Count > 0) + { + builder.Append(string.Join('/', pathParts)); + builder.Append('/'); + } + + if (name.Length > 0 && name[0] == '/') + { + builder.Append(name[1..]); + } + else + { + builder.Append(name); + } + + return builder.ToString(); + } + + private static OpenApiParameterKind MapArgumentType(OperationArgumentSource source) => source switch + { + OperationArgumentSource.Path => OpenApiParameterKind.Path, + OperationArgumentSource.Body => OpenApiParameterKind.Body, + OperationArgumentSource.Content => OpenApiParameterKind.ModelBinding, + OperationArgumentSource.Query => OpenApiParameterKind.Query, + _ => OpenApiParameterKind.Undefined + }; + + private static bool MapRequired(OperationArgumentSource source) => source switch + { + OperationArgumentSource.Path => true, + OperationArgumentSource.Content => true, + _ => false + }; + + private static string? GetTag(Operation operation) + { + var type = operation.Method.DeclaringType?.Name; + + if (type != null) + { + return type.Contains("<>") ? "Inline" : type; + } + + return null; + } + + private static Dictionary GetResponses(Operation operation, SchemaManager schemata, MethodRegistry registry) + { + var result = new Dictionary(); + + var sink = operation.Result.Sink; + var type = operation.Result.Type; + + if (sink == OperationResultSink.None || type.MightBeNull()) + { + result.Add("204", new OpenApiResponse + { + Description = "A response containing no body" + }); + } + + if (sink == OperationResultSink.Formatter) + { + result.Add("200", GetResponse(schemata, type, "text/plain")); + } + else if (sink == OperationResultSink.Serializer) + { + result.Add("200", GetResponse(schemata, type, registry.Serialization.Formats.Select(s => s.Key.RawType).ToArray())); + } + else if (sink == OperationResultSink.Stream) + { + var response = new OpenApiResponse + { + Description = "A dynamically generated response" + }; + + var schema = new JsonSchema + { + Format = "binary" + }; + + response.Content.Add("application/octet-stream", new OpenApiMediaType + { + Schema = schema + }); + + result.Add("200", response); + } + else if (sink == OperationResultSink.Dynamic) + { + var response = new OpenApiResponse + { + Description = "A dynamically generated response" + }; + + response.Content.Add("*/*", new OpenApiMediaType()); + + result.Add("200", response); + } + + return result; + } + + private static OpenApiRequestBody GetRequestBody(SchemaManager schemata, Type type, params string[] mediaTypes) + { + var requestBody = new OpenApiRequestBody(); + + var schema = schemata.GetOrCreateSchema(type); + + foreach (var mediaType in mediaTypes) + { + var media = new OpenApiMediaType + { + Schema = schema + }; + + requestBody.Content.Add(mediaType, media); + } + + return requestBody; + } + + private static OpenApiResponse GetResponse(SchemaManager schemata, Type type, params string[] mediaTypes) + { + var response = new OpenApiResponse(); + + var schema = schemata.GetOrCreateSchema(type); + + foreach (var mediaType in mediaTypes) + { + var media = new OpenApiMediaType + { + Schema = schema + }; + + response.Content.Add(mediaType, media); + } + + return response; + } + +} diff --git a/Modules/OpenApi/Discovery/SchemaManager.cs b/Modules/OpenApi/Discovery/SchemaManager.cs new file mode 100644 index 00000000..491527c9 --- /dev/null +++ b/Modules/OpenApi/Discovery/SchemaManager.cs @@ -0,0 +1,34 @@ +using Namotion.Reflection; +using NJsonSchema; +using NJsonSchema.Generation; +using NJsonSchema.NewtonsoftJson.Generation; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class SchemaManager +{ + private readonly JsonSchemaGenerator _Generator; + + private readonly OpenApiSchemaResolver _Resolver; + + internal SchemaManager(OpenApiDocument document) + { + var settings = new NewtonsoftJsonSchemaGeneratorSettings + { + SchemaType = SchemaType.OpenApi3, + AllowReferencesWithProperties = true + }; + + _Generator = new JsonSchemaGenerator(settings); + _Resolver = new OpenApiSchemaResolver(document, settings); + } + + /// + /// Generates or retrieves a JSON schema that represents the given type. + /// + /// The type to generate a schema for + /// The generated or retrieved JSON schema + public JsonSchema GetOrCreateSchema(Type type) => _Generator.GenerateWithReferenceAndNullability(type.ToContextualType(), false, _Resolver); + +} diff --git a/Modules/OpenApi/Discovery/ServiceExplorer.cs b/Modules/OpenApi/Discovery/ServiceExplorer.cs new file mode 100644 index 00000000..0205d082 --- /dev/null +++ b/Modules/OpenApi/Discovery/ServiceExplorer.cs @@ -0,0 +1,20 @@ +using GenHTTP.Api.Content; +using GenHTTP.Modules.Reflection; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Discovery; + +public sealed class ServiceExplorer : IApiExplorer +{ + + public bool CanExplore(IHandler handler) => handler is IServiceMethodProvider; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + if (handler is IServiceMethodProvider serviceProvider) + { + registry.Explore(serviceProvider.Methods, path, document, schemata); + } + } + +} diff --git a/Modules/OpenApi/GenHTTP.Modules.OpenApi.csproj b/Modules/OpenApi/GenHTTP.Modules.OpenApi.csproj new file mode 100644 index 00000000..6c34fd03 --- /dev/null +++ b/Modules/OpenApi/GenHTTP.Modules.OpenApi.csproj @@ -0,0 +1,61 @@ + + + + + net8.0;net9.0 + + 13.0 + enable + true + enable + + 9.0.0.0 + 9.0.0.0 + 9.0.0 + + Andreas Nägeli + + + LICENSE + https://genhttp.org/ + + Adds an endpoint to your service that describes the API using the Open API format + HTTP Webserver C# Module OpenAPI + + true + true + snupkg + + true + CS1591,CS1587,CS1572,CS1573 + + icon.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Modules/OpenApi/Handler/OpenApiConcern.cs b/Modules/OpenApi/Handler/OpenApiConcern.cs new file mode 100644 index 00000000..345c0788 --- /dev/null +++ b/Modules/OpenApi/Handler/OpenApiConcern.cs @@ -0,0 +1,137 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.OpenApi.Discovery; +using NJsonSchema; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Handler; + +public sealed class OpenApiConcern : IConcern +{ + private OpenApiDocument? _Cached; + + #region Initialization + + public OpenApiConcern(IHandler parent, Func contentFactory, ApiDiscoveryRegistry discovery, bool enableCaching, Action postProcessor) + { + Parent = parent; + Content = contentFactory(this); + + Discovery = discovery; + EnableCaching = enableCaching; + PostProcessor = postProcessor; + } + + #endregion + + #region Get-/Setters + + public IHandler Parent { get; } + + public IHandler Content { get; } + + private ApiDiscoveryRegistry Discovery { get; } + + private bool EnableCaching { get; } + + private Action PostProcessor { get; } + + #endregion + + #region Functionality + + public ValueTask PrepareAsync() => new(); + + public async ValueTask HandleAsync(IRequest request) + { + var path = request.Target.Current?.Original; + + if (request.Method == RequestMethod.Get || request.Method == RequestMethod.Head) + { + if (string.Compare(path, "openapi", StringComparison.OrdinalIgnoreCase) == 0) + { + IResponse response; + + if (request.Headers.TryGetValue("Accept", out var accept)) + { + response = accept.ToLowerInvariant() switch + { + "application/json" or "application/application/vnd.oai.openapi+json" => GetDocument(request, OpenApiFormat.Json), + "application/yaml" or "application/application/vnd.oai.openapi+yaml" => GetDocument(request, OpenApiFormat.Yaml), + _ => throw new ProviderException(ResponseStatus.BadRequest, $"Generating API specifications of format '{accept}' is not supported") + }; + } + else + { + response = GetDocument(request, OpenApiFormat.Json); + } + + response.Headers.Add("Vary", "Accept"); + + return response; + } + if (string.Compare(path, "openapi.json", StringComparison.OrdinalIgnoreCase) == 0) + { + return GetDocument(request, OpenApiFormat.Json); + } + if (string.Compare(path, "openapi.yaml", StringComparison.OrdinalIgnoreCase) == 0 || string.Compare(path, "openapi.yml", StringComparison.OrdinalIgnoreCase) == 0) + { + return GetDocument(request, OpenApiFormat.Yaml); + } + } + + return await Content.HandleAsync(request); + } + + private IResponse GetDocument(IRequest request, OpenApiFormat format) + { + var document = Discover(request, Discovery); + + var content = new OpenApiContent(document, format); + + var contentType = format == OpenApiFormat.Json ? FlexibleContentType.Get(ContentType.ApplicationJson) : FlexibleContentType.Get(ContentType.ApplicationYaml); + + return request.Respond() + .Content(content) + .Type(contentType) + .Build(); + } + + private OpenApiDocument Discover(IRequest request, ApiDiscoveryRegistry registry) + { + if (EnableCaching && _Cached != null) + { + return _Cached; + } + + var document = new OpenApiDocument(); + + var schemata = new SchemaManager(document); + + document.SchemaType = SchemaType.OpenApi3; + + var path = request.Target.Path.ToString(); + + if (request.Host != null) + { + document.Servers.Add(new OpenApiServer + { + Url = (request.EndPoint.Secure ? "https://" : "http://") + request.Host + path[..path.LastIndexOf('/')] + }); + } + + registry.Explore(Content, [], document, schemata); + + PostProcessor.Invoke(request, document); + + if (EnableCaching) + { + _Cached = document; + } + + return document; + } + + #endregion + +} diff --git a/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs b/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs new file mode 100644 index 00000000..644f4ba2 --- /dev/null +++ b/Modules/OpenApi/Handler/OpenApiConcernBuilder.cs @@ -0,0 +1,94 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.OpenApi.Discovery; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Handler; + +public sealed class OpenApiConcernBuilder : IConcernBuilder +{ + private bool _Caching = true; + + private Action? _PostProcessor; + + private string? _Title, _Version; + + #region Initialization + + public OpenApiConcernBuilder(ApiDiscoveryRegistry registry) + { + Discovery = registry; + } + + #endregion + + #region Get-/Setters + + private ApiDiscoveryRegistry Discovery { get; } + + #endregion + + #region Functionality + + /// + /// Sets the title of the OpenAPI specification. + /// + /// The title of the API + public OpenApiConcernBuilder Title(string title) + { + _Title = title; + return this; + } + + /// + /// Sets the version of the described API. + /// + /// The version of the API + public OpenApiConcernBuilder Version(string version) + { + _Version = version; + return this; + } + + /// + /// Specifies, whether the generated OpenAPI specification should + /// get cached on first request, so it is no re-generated on every request. + /// + /// Whether to use caching or not + public OpenApiConcernBuilder Caching(bool enabled) + { + _Caching = enabled; + return this; + } + + /// + /// Registers a function that will be called when an OpenAPI document has been + /// generated, directly before it is served to the client. + /// + /// The method to be invoked to adjust the generated document + public OpenApiConcernBuilder PostProcessor(Action action) + { + _PostProcessor = action; + return this; + } + + public IConcern Build(IHandler parent, Func contentFactory) => new OpenApiConcern(parent, contentFactory, Discovery, _Caching, DoPostProcessing); + + private void DoPostProcessing(IRequest request, OpenApiDocument document) + { + if (_Title != null) + { + document.Info.Title = _Title; + } + + if (_Version != null) + { + document.Info.Version = _Version; + } + + _PostProcessor?.Invoke(request, document); + } + + #endregion + +} diff --git a/Modules/OpenApi/Handler/OpenApiContent.cs b/Modules/OpenApi/Handler/OpenApiContent.cs new file mode 100644 index 00000000..daab147d --- /dev/null +++ b/Modules/OpenApi/Handler/OpenApiContent.cs @@ -0,0 +1,48 @@ +using System.Text; +using GenHTTP.Api.Protocol; +using NSwag; + +namespace GenHTTP.Modules.OpenApi.Handler; + +internal class OpenApiContent : IResponseContent +{ + + #region Initialization + + internal OpenApiContent(OpenApiDocument document, OpenApiFormat format) + { + Document = document; + Format = format; + } + + #endregion + + #region Get-/Setters + + public ulong? Length => null; + + private OpenApiDocument Document { get; } + + private OpenApiFormat Format { get; } + + #endregion + + #region Functionality + + public ValueTask CalculateChecksumAsync() => new((ulong)Document.ToJson().GetHashCode()); + + public async ValueTask WriteAsync(Stream target, uint bufferSize) + { + if (Format == OpenApiFormat.Json) + { + await target.WriteAsync(Encoding.UTF8.GetBytes(Document.ToJson())); + } + else + { + await target.WriteAsync(Encoding.UTF8.GetBytes(Document.ToYaml())); + } + } + + #endregion + +} diff --git a/Modules/OpenApi/Handler/OpenApiFormat.cs b/Modules/OpenApi/Handler/OpenApiFormat.cs new file mode 100644 index 00000000..f0ccf0f9 --- /dev/null +++ b/Modules/OpenApi/Handler/OpenApiFormat.cs @@ -0,0 +1,7 @@ +namespace GenHTTP.Modules.OpenApi.Handler; + +internal enum OpenApiFormat +{ + Json, + Yaml +} diff --git a/Modules/OpenApi/IApiExplorer.cs b/Modules/OpenApi/IApiExplorer.cs new file mode 100644 index 00000000..f5560c98 --- /dev/null +++ b/Modules/OpenApi/IApiExplorer.cs @@ -0,0 +1,37 @@ +using GenHTTP.Api.Content; +using GenHTTP.Modules.OpenApi.Discovery; + +using NSwag; + +namespace GenHTTP.Modules.OpenApi; + +/// +/// Allows to analyze a specific kind of handler and adds the API endpoints defined +/// by the handler to the resulting OpenAPI document. +/// +public interface IApiExplorer +{ + + /// + /// Specfies, whether the given handler is supported by this explorer. + /// + /// The handler to be inspected + /// true, if this explorer is capable of inspecting this explorer + /// + /// Note that this method is only allowed to return true if the explorer can actually + /// analyze this handler. No other explorer will be invoked for the handler in this case. + /// + bool CanExplore(IHandler handler); + + /// + /// Analyzes the given handler and adds the endpoints defined by this handler to the resulting + /// OpenAPI document. + /// + /// The handler to be analyzed + /// The current stack of path segments that have already been analyzed, relative to the location of the OpenAPI concern + /// The document to be adjusted and enriched + /// The manager to generate JSON schemas with + /// The registry containing all active explorers which can be used to further analyze any child handler of the given handler instance + void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry); + +} diff --git a/Modules/Reflection/Extensions.cs b/Modules/Reflection/Extensions.cs index 25c0ef98..56cb2262 100644 --- a/Modules/Reflection/Extensions.cs +++ b/Modules/Reflection/Extensions.cs @@ -5,6 +5,7 @@ namespace GenHTTP.Modules.Reflection; public static class Extensions { + private static readonly Type? VoidTaskResult = Type.GetType("System.Threading.Tasks.VoidTaskResult"); /// /// Checks, whether the given parameter can be passed via the URL. @@ -30,6 +31,8 @@ public static class Extensions public static bool IsAsyncGeneric(this Type resultType) => resultType.IsAssignableToGenericType(typeof(ValueTask<>)) || resultType.IsAssignableToGenericType(typeof(Task<>)); + public static bool IsGenericallyVoid(this Type type) => type.GenericTypeArguments.Length == 1 && type.GenericTypeArguments[0] == VoidTaskResult; + public static bool IsAssignableToGenericType(this Type givenType, Type genericType) { var interfaceTypes = givenType.GetInterfaces(); @@ -56,5 +59,4 @@ public static bool IsAssignableToGenericType(this Type givenType, Type genericTy return IsAssignableToGenericType(baseType, genericType); } - } diff --git a/Modules/Reflection/IMethodConfiguration.cs b/Modules/Reflection/IMethodConfiguration.cs index 7c1c2835..bfea1fb6 100644 --- a/Modules/Reflection/IMethodConfiguration.cs +++ b/Modules/Reflection/IMethodConfiguration.cs @@ -13,5 +13,4 @@ public interface IMethodConfiguration /// The HTTP verbs which are supported by this method. /// public HashSet SupportedMethods { get; } - } diff --git a/Modules/Reflection/IResultWrapper.cs b/Modules/Reflection/IResultWrapper.cs index 68815fe8..c5eca7dc 100644 --- a/Modules/Reflection/IResultWrapper.cs +++ b/Modules/Reflection/IResultWrapper.cs @@ -20,4 +20,5 @@ internal interface IResultWrapper /// /// The response builder to manipulate void Apply(IResponseBuilder builder); + } diff --git a/Modules/Reflection/IServiceMethodProvider.cs b/Modules/Reflection/IServiceMethodProvider.cs new file mode 100644 index 00000000..3798415d --- /dev/null +++ b/Modules/Reflection/IServiceMethodProvider.cs @@ -0,0 +1,16 @@ +namespace GenHTTP.Modules.Reflection; + +/// +/// Implemented by handlers that use the handler +/// for response generation. Allows logic interested in generically analyzing +/// such handlers (e.g. the OpenAPI concern) to stay loosely coupled. +/// +public interface IServiceMethodProvider +{ + + /// + /// The method collection actually serving requests. + /// + MethodCollection Methods { get; } + +} diff --git a/Modules/Reflection/Injection.cs b/Modules/Reflection/Injection.cs index 8e4ed041..12ca98c7 100644 --- a/Modules/Reflection/Injection.cs +++ b/Modules/Reflection/Injection.cs @@ -8,6 +8,5 @@ public static class Injection public static InjectionRegistryBuilder Empty() => new(); public static InjectionRegistryBuilder Default() => new InjectionRegistryBuilder().Add(new RequestInjector()) - .Add(new RequestBodyInjector()) .Add(new HandlerInjector()); } diff --git a/Modules/Reflection/Injectors/HandlerInjector.cs b/Modules/Reflection/Injectors/HandlerInjector.cs index f120a0d4..a37e1603 100644 --- a/Modules/Reflection/Injectors/HandlerInjector.cs +++ b/Modules/Reflection/Injectors/HandlerInjector.cs @@ -9,5 +9,4 @@ public class HandlerInjector : IParameterInjector public bool Supports(Type type) => type == typeof(IHandler); public object GetValue(IHandler handler, IRequest request, Type targetType) => handler; - } diff --git a/Modules/Reflection/Injectors/RequestBodyInjector.cs b/Modules/Reflection/Injectors/RequestBodyInjector.cs deleted file mode 100644 index 13cb4096..00000000 --- a/Modules/Reflection/Injectors/RequestBodyInjector.cs +++ /dev/null @@ -1,20 +0,0 @@ -using GenHTTP.Api.Content; -using GenHTTP.Api.Protocol; - -namespace GenHTTP.Modules.Reflection.Injectors; - -public class RequestBodyInjector : IParameterInjector -{ - - public bool Supports(Type type) => type == typeof(Stream); - - public object? GetValue(IHandler handler, IRequest request, Type targetType) - { - if (request.Content is null) - { - throw new ProviderException(ResponseStatus.BadRequest, "Request body expected"); - } - - return request.Content; - } -} diff --git a/Modules/Reflection/MethodAttribute.cs b/Modules/Reflection/MethodAttribute.cs index cde97500..5452ac05 100644 --- a/Modules/Reflection/MethodAttribute.cs +++ b/Modules/Reflection/MethodAttribute.cs @@ -39,7 +39,7 @@ public MethodAttribute() /// The HTTP verbs supported by this method public MethodAttribute(params RequestMethod[] methods) { - SupportedMethods = new HashSet(methods.Select(m => FlexibleRequestMethod.Get(m))); + SupportedMethods = new HashSet(methods.Select(FlexibleRequestMethod.Get)); } /// diff --git a/Modules/Reflection/MethodCollection.cs b/Modules/Reflection/MethodCollection.cs index 552372a0..571b2bc9 100644 --- a/Modules/Reflection/MethodCollection.cs +++ b/Modules/Reflection/MethodCollection.cs @@ -39,7 +39,7 @@ public MethodCollection(IHandler parent, IEnumerable 1) { // if there is only one non-wildcard, use this one - var nonWildcards = methods.Where(m => !m.Routing.IsWildcard).ToList(); + var nonWildcards = methods.Where(m => !m.Operation.Path.IsWildcard).ToList(); if (nonWildcards.Count == 1) { @@ -72,7 +72,7 @@ private List FindProviders(string path, FlexibleRequestMethod req foreach (var method in Methods) { - if (method.Routing.IsIndex && path == "/") + if (method.Operation.Path.IsIndex && path == "/") { if (method.Configuration.SupportedMethods.Contains(requestedMethod)) { @@ -85,7 +85,7 @@ private List FindProviders(string path, FlexibleRequestMethod req } else { - if (method.Routing.ParsedPath.IsMatch(path)) + if (method.Operation.Path.Matcher.IsMatch(path)) { if (method.Configuration.SupportedMethods.Contains(requestedMethod)) { diff --git a/Modules/Reflection/MethodHandler.cs b/Modules/Reflection/MethodHandler.cs index 8e3ed2e2..d8030cba 100644 --- a/Modules/Reflection/MethodHandler.cs +++ b/Modules/Reflection/MethodHandler.cs @@ -4,11 +4,8 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; using GenHTTP.Api.Routing; -using GenHTTP.Modules.Conversion; -using GenHTTP.Modules.Conversion.Formatters; -using GenHTTP.Modules.Conversion.Serializers; using GenHTTP.Modules.Conversion.Serializers.Forms; -using GenHTTP.Modules.Reflection.Injectors; +using GenHTTP.Modules.Reflection.Operations; namespace GenHTTP.Modules.Reflection; @@ -24,50 +21,44 @@ public sealed class MethodHandler : IHandler { private static readonly object?[] NoArguments = []; - private static readonly Type? VoidTaskResult = Type.GetType("System.Threading.Tasks.VoidTaskResult"); + #region Get-/Setters - #region Initialization + public IHandler Parent { get; } - public MethodHandler(IHandler parent, MethodInfo method, MethodRouting routing, Func instanceProvider, IMethodConfiguration metaData, - Func?, ValueTask> responseProvider, SerializationRegistry serialization, - InjectionRegistry injection, FormatterRegistry formatting) - { - Parent = parent; + public Operation Operation { get; } - Method = method; - Configuration = metaData; - InstanceProvider = instanceProvider; + public IMethodConfiguration Configuration { get; } - Serialization = serialization; - Injection = injection; - Formatting = formatting; + private object Instance { get; } - ResponseProvider = responseProvider; + public MethodRegistry Registry { get; } - Routing = routing; - } + private ResponseProvider ResponseProvider { get; } #endregion - #region Get-/Setters - - public IHandler Parent { get; } - - public MethodRouting Routing { get; } - - public IMethodConfiguration Configuration { get; } - - public MethodInfo Method { get; } - - private Func InstanceProvider { get; } + #region Initialization - private Func?, ValueTask> ResponseProvider { get; } + /// + /// Creates a new handler to serve a single API operation. + /// + /// The parent of this handler + /// The operation to be executed and provided (use to create an operation) + /// The object to execute the operation on + /// Additional, use-specified information about the operation + /// The customized registry to be used to read and write data + public MethodHandler(IHandler parent, Operation operation, object instance, IMethodConfiguration metaData, MethodRegistry registry) + { + Parent = parent; - private SerializationRegistry Serialization { get; } + Configuration = metaData; + Instance = instance; - private InjectionRegistry Injection { get; } + Operation = operation; + Registry = registry; - private FormatterRegistry Formatting { get; } + ResponseProvider = new(registry); + } #endregion @@ -79,18 +70,18 @@ public MethodHandler(IHandler parent, MethodInfo method, MethodRouting routing, var result = Invoke(arguments); - return await ResponseProvider(request, this, await UnwrapAsync(result), null); + return await ResponseProvider.GetResponseAsync(request, this, Operation, await UnwrapAsync(result), null); } private async ValueTask GetArguments(IRequest request) { - var targetParameters = Method.GetParameters(); + var targetParameters = Operation.Method.GetParameters(); Match? sourceParameters = null; - if (!Routing.IsIndex) + if (!Operation.Path.IsIndex) { - sourceParameters = Routing.ParsedPath.Match(request.Target.GetRemaining().ToString()); + sourceParameters = Operation.Path.Matcher.Match(request.Target.GetRemaining().ToString()); var matchedPath = WebPath.FromString(sourceParameters.Value); @@ -107,102 +98,22 @@ public MethodHandler(IHandler parent, MethodInfo method, MethodRouting routing, { var par = targetParameters[i]; - // try to provide via injector - var injected = false; - - foreach (var injector in Injection) - { - if (injector.Supports(par.ParameterType)) - { - targetArguments[i] = injector.GetValue(this, request, par.ParameterType); - - injected = true; - break; - } - } - - if (injected) - { - continue; - } - - if (par.Name is not null && par.CanFormat(Formatting)) + if (par.Name != null) { - // should the value be read from the body? - var fromBody = par.GetCustomAttribute(); - - if (fromBody != null) + if (Operation.Arguments.TryGetValue(par.Name, out var arg)) { - if (request.Content != null) + targetArguments[i] = arg.Source switch { - using var reader = new StreamReader(request.Content, leaveOpen: true); - - var body = await reader.ReadToEndAsync(); - - if (!string.IsNullOrWhiteSpace(body)) - { - targetArguments[i] = body.ConvertTo(par.ParameterType, Formatting); - } - - request.Content.Seek(0, SeekOrigin.Begin); - } + OperationArgumentSource.Injected => ArgumentProvider.GetInjectedArgument(request, this, arg, Registry), + OperationArgumentSource.Path => ArgumentProvider.GetPathArgument(arg, sourceParameters, Registry), + OperationArgumentSource.Body => await ArgumentProvider.GetBodyArgumentAsync(request, arg, Registry), + OperationArgumentSource.Query => ArgumentProvider.GetQueryArgument(request, bodyArguments, arg, Registry), + OperationArgumentSource.Content => await ArgumentProvider.GetContentAsync(request, arg, Registry), + OperationArgumentSource.Streamed => ArgumentProvider.GetStream(request), + _ => throw new ProviderException(ResponseStatus.InternalServerError, $"Unable to map argument '{arg.Name}' of type '{arg.Type}' because source '{arg.Source}' is not supported") + }; } - else - { - // is there a named parameter? - if (sourceParameters is not null) - { - var sourceArgument = sourceParameters.Groups[par.Name]; - - if (sourceArgument.Success) - { - targetArguments[i] = sourceArgument.Value.ConvertTo(par.ParameterType, Formatting); - continue; - } - } - - // is there a query parameter? - if (request.Query.TryGetValue(par.Name, out var queryValue)) - { - targetArguments[i] = queryValue.ConvertTo(par.ParameterType, Formatting); - continue; - } - - // is there a parameter from the body? - if (bodyArguments is not null) - { - if (bodyArguments.TryGetValue(par.Name, out var bodyValue)) - { - targetArguments[i] = bodyValue.ConvertTo(par.ParameterType, Formatting); - } - } - } - - // assume the default value - continue; } - // deserialize from body - var deserializer = Serialization.GetDeserialization(request); - - if (deserializer is null) - { - throw new ProviderException(ResponseStatus.UnsupportedMediaType, "Requested format is not supported"); - } - - if (request.Content is null) - { - throw new ProviderException(ResponseStatus.BadRequest, "Request body expected"); - } - - try - { - targetArguments[i] = await deserializer.DeserializeAsync(request.Content, par.ParameterType); - } - catch (Exception e) - { - throw new ProviderException(ResponseStatus.BadRequest, "Failed to deserialize request body", e); - } - } return targetArguments; @@ -217,7 +128,7 @@ public MethodHandler(IHandler parent, MethodInfo method, MethodRouting routing, { try { - return Method.Invoke(InstanceProvider(), arguments); + return Operation.Method.Invoke(Instance, arguments); } catch (TargetInvocationException e) { @@ -246,12 +157,8 @@ public MethodHandler(IHandler parent, MethodInfo method, MethodRouting routing, await task; - if (type.GenericTypeArguments.Length == 1 && type.GenericTypeArguments[0] == VoidTaskResult) - { - return null; - } + return type.IsGenericallyVoid() ? null : task.Result; - return task.Result; } if (type == typeof(ValueTask) || type == typeof(Task)) { diff --git a/Modules/Reflection/MethodRegistry.cs b/Modules/Reflection/MethodRegistry.cs new file mode 100644 index 00000000..0f678b95 --- /dev/null +++ b/Modules/Reflection/MethodRegistry.cs @@ -0,0 +1,19 @@ +using GenHTTP.Modules.Conversion.Formatters; +using GenHTTP.Modules.Conversion.Serializers; +using GenHTTP.Modules.Reflection.Injectors; + +namespace GenHTTP.Modules.Reflection; + +/// +/// Composite structure to reference all customizable registries used by the +/// reflection functionality. +/// +/// The serialization registry to be used +/// The injection registry to be used +/// The formatter registry to be used +public record MethodRegistry +( + SerializationRegistry Serialization, + InjectionRegistry Injection, + FormatterRegistry Formatting +); diff --git a/Modules/Reflection/Operations/ArgumentProvider.cs b/Modules/Reflection/Operations/ArgumentProvider.cs new file mode 100644 index 00000000..8ee991fe --- /dev/null +++ b/Modules/Reflection/Operations/ArgumentProvider.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Conversion; + +namespace GenHTTP.Modules.Reflection.Operations; + +public static class ArgumentProvider +{ + + public static object? GetInjectedArgument(IRequest request, IHandler handler, OperationArgument argument, MethodRegistry registry) + { + foreach (var injector in registry.Injection) + { + if (injector.Supports(argument.Type)) + { + return injector.GetValue(handler, request, argument.Type); + } + } + + return null; + } + + public static object? GetPathArgument(OperationArgument argument, Match? matchedPath, MethodRegistry registry) + { + if (matchedPath != null) + { + var sourceArgument = matchedPath.Groups[argument.Name]; + + if (sourceArgument.Success) + { + return sourceArgument.Value.ConvertTo(argument.Type, registry.Formatting); + } + } + + return null; + } + + public static async ValueTask GetBodyArgumentAsync(IRequest request, OperationArgument argument, MethodRegistry registry) + { + if (request.Content == null) + { + throw new ProviderException(ResponseStatus.BadRequest, $"Argument '{argument.Name}' is expected to be read from the request body but the request does not contain any payload"); + } + + object? result = null; + + using var reader = new StreamReader(request.Content, leaveOpen: true); + + var body = await reader.ReadToEndAsync(); + + if (!string.IsNullOrWhiteSpace(body)) + { + result = body.ConvertTo(argument.Type, registry.Formatting); + } + + request.Content.Seek(0, SeekOrigin.Begin); + + return result; + } + + public static object? GetQueryArgument(IRequest request, Dictionary? formArguments, OperationArgument argument, MethodRegistry registry) + { + if (request.Query.TryGetValue(argument.Name, out var queryValue)) + { + return queryValue.ConvertTo(argument.Type, registry.Formatting); + } + + if (formArguments is not null) + { + if (formArguments.TryGetValue(argument.Name, out var bodyValue)) + { + return bodyValue.ConvertTo(argument.Type, registry.Formatting); + } + } + + return null; + } + + public static async ValueTask GetContentAsync(IRequest request, OperationArgument argument, MethodRegistry registry) + { + var deserializer = registry.Serialization.GetDeserialization(request); + + if (deserializer is null) + { + throw new ProviderException(ResponseStatus.UnsupportedMediaType, "Requested format is not supported"); + } + + if (request.Content is null) + { + throw new ProviderException(ResponseStatus.BadRequest, "Request body expected"); + } + + try + { + return await deserializer.DeserializeAsync(request.Content, argument.Type); + } + catch (Exception e) + { + throw new ProviderException(ResponseStatus.BadRequest, "Failed to deserialize request body", e); + } + } + + public static object? GetStream(IRequest request) + { + if (request.Content == null) + { + throw new ProviderException(ResponseStatus.BadRequest, "Request body expected"); + } + + return request.Content; + } +} diff --git a/Modules/Reflection/Operations/Operation.cs b/Modules/Reflection/Operations/Operation.cs new file mode 100644 index 00000000..449821b2 --- /dev/null +++ b/Modules/Reflection/Operations/Operation.cs @@ -0,0 +1,44 @@ +using System.Reflection; + +namespace GenHTTP.Modules.Reflection.Operations; + +public sealed class Operation +{ + + #region Get-/Setters + + /// + /// The .NET method to be executed to retrieve a result. + /// + public MethodInfo Method { get; } + + /// + /// Information about the endpoint provided by this operation. + /// + public OperationPath Path { get; } + + /// + /// The arguments expected by this operation. + /// + public IReadOnlyDictionary Arguments { get; } + + /// + /// The result generated by this operation. + /// + public OperationResult Result { get; } + + #endregion + + #region Initialization + + public Operation(MethodInfo method, OperationPath path, OperationResult result, IReadOnlyDictionary arguments) + { + Method = method; + Path = path; + Result = result; + Arguments = arguments; + } + + #endregion + +} diff --git a/Modules/Reflection/Operations/OperationArgument.cs b/Modules/Reflection/Operations/OperationArgument.cs new file mode 100644 index 00000000..c51fd45d --- /dev/null +++ b/Modules/Reflection/Operations/OperationArgument.cs @@ -0,0 +1,78 @@ +using GenHTTP.Modules.Conversion.Serializers; +using GenHTTP.Modules.Reflection.Injectors; + +namespace GenHTTP.Modules.Reflection.Operations; + +/// +/// Defines where the engine will read the argument from +/// to obtain a value. +/// +public enum OperationArgumentSource +{ + + /// + /// The argument will be read from a path variable (e.g. "/users/:id"). + /// + Path, + + /// + /// The argument will either be read from the request query or form encoded data (e.g. "/users?id=1"). + /// + Query, + + /// + /// The argument will directly be read from the body of the request, with no deserialization applied. + /// + Body, + + /// + /// The argument will be injected by a compatible . + /// + Injected, + + /// + /// The argument will be deserialized from the body using a compatible . + /// + Content, + + /// + /// The argument re-presents the stream of the request body. + /// + Streamed + +} + +public sealed class OperationArgument +{ + + #region Get-/Setters + + /// + /// The name of the argument. + /// + public string Name { get; } + + /// + /// The type as expected by the .NET method to be invoked by the operation. + /// + public Type Type { get; } + + /// + /// Specifies how the argument can get read from the incoming request. + /// + public OperationArgumentSource Source { get; } + + #endregion + + #region Initialization + + public OperationArgument(string name, Type type, OperationArgumentSource source) + { + Name = name; + Type = type; + Source = source; + } + + #endregion + +} diff --git a/Modules/Reflection/Operations/OperationBuilder.cs b/Modules/Reflection/Operations/OperationBuilder.cs new file mode 100644 index 00000000..ff4962bf --- /dev/null +++ b/Modules/Reflection/Operations/OperationBuilder.cs @@ -0,0 +1,181 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using GenHTTP.Api.Content; + +namespace GenHTTP.Modules.Reflection.Operations; + +public static partial class OperationBuilder +{ + private static readonly Regex VarPattern = CreateVarPattern(); + + private static readonly Regex RegexPattern = CreateRegexPattern(); + + private static readonly Regex EmptyWildcardRoute = CreateEmptyWildcardRoute(); + + private static readonly Regex EmptyRoute = CreateEmptyRoute(); + + #region Functionality + + /// + /// Analyzes the given configuration and converts it into an operation that can + /// get executed by the . + /// + /// The path definition of the endpoint, e.g. "/users/:id" + /// The actual .NET method to be executed to retrieve a result + /// The customizable registry used to read and write data + /// If set to true, the operation requires the client to append a trailing slash to the path + /// The newly created operation + public static Operation Create(string? definition, MethodInfo method, MethodRegistry registry, bool forceTrailingSlash = false) + { + var isWildcard = CheckWildcardRoute(method.ReturnType); + + OperationPath path; + + var pathArguments = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(definition)) + { + if (isWildcard) + { + path = new OperationPath("/{path}", EmptyWildcardRoute, true, true); + pathArguments.Add("path"); + } + else + { + path = new OperationPath("/", EmptyRoute, true, false); + } + } + else + { + var normalized = Normalize(definition); + + var matchBuilder = new StringBuilder(normalized); + var nameBuilder = new StringBuilder(WithPrefix(normalized)); + + // convert parameters of the format ":var" into appropriate groups + foreach (Match match in VarPattern.Matches(definition)) + { + var name = match.Groups[1].Value; + + matchBuilder.Replace(match.Value, name.ToParameter()); + nameBuilder.Replace(match.Value, "{" + name + "}"); + + pathArguments.Add(name); + } + + // convert advanced regex params as well + foreach (Match match in RegexPattern.Matches(definition)) + { + var name = match.Groups[1].Value; + + nameBuilder.Replace(match.Value, "{" + name + "}"); + + pathArguments.Add(name); + } + + if (forceTrailingSlash || definition.EndsWith('/')) + { + matchBuilder.Append('/'); + nameBuilder.Append('/'); + } + else + { + matchBuilder.Append("(/|)"); + } + + if (isWildcard) + { + nameBuilder.Append("{path}"); + pathArguments.Add("path"); + } + else + { + matchBuilder.Append('$'); + } + + var matcher = new Regex($"^/{matchBuilder}", RegexOptions.Compiled); + + path = new OperationPath(nameBuilder.ToString(), matcher, false, isWildcard); + } + + var arguments = SignatureAnalyzer.GetArguments(method, pathArguments, registry); + + var result = SignatureAnalyzer.GetResult(method, registry); + + return new Operation(method, path, result, arguments); + } + + private static bool CheckWildcardRoute(Type returnType) + { + if (IsHandlerType(returnType)) + { + return true; + } + + if (returnType.IsAsyncGeneric()) + { + if (returnType.GenericTypeArguments.Length == 1) + { + return IsHandlerType(returnType.GenericTypeArguments[0]); + } + } + + return false; + } + + private static bool IsHandlerType(Type returnType) => typeof(IHandlerBuilder).IsAssignableFrom(returnType) || typeof(IHandler).IsAssignableFrom(returnType); + + private static string Normalize(string definition) + { + int trimStart = 0, trimEnd = 0; + + if (definition.Length > 0) + { + if (definition[0] == '/') + { + trimStart = 1; + } + + if (definition[^1] == '/') + { + trimEnd = 1; + } + } + + return definition.Substring(trimStart, definition.Length - trimStart - trimEnd); + } + + private static string WithPrefix(string path) + { + if (path.Length > 0) + { + if (path[0] != '/') + { + return $"{path}"; + } + } + + return path; + } + + #endregion + + #region Regular Expressions + + [GeneratedRegex(@"\:([a-z]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex CreateVarPattern(); + + [GeneratedRegex(@"\(\?\<([a-z]+)\>([^)]+)\)", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex CreateRegexPattern(); + + [GeneratedRegex("^.*", RegexOptions.Compiled)] + private static partial Regex CreateEmptyWildcardRoute(); + + [GeneratedRegex("^(/|)$", RegexOptions.Compiled)] + private static partial Regex CreateEmptyRoute(); + + #endregion + +} diff --git a/Modules/Reflection/MethodRouting.cs b/Modules/Reflection/Operations/OperationPath.cs similarity index 66% rename from Modules/Reflection/MethodRouting.cs rename to Modules/Reflection/Operations/OperationPath.cs index 4a7990a1..dcfd9610 100644 --- a/Modules/Reflection/MethodRouting.cs +++ b/Modules/Reflection/Operations/OperationPath.cs @@ -1,33 +1,22 @@ using System.Text.RegularExpressions; -using GenHTTP.Api.Routing; -namespace GenHTTP.Modules.Reflection; +namespace GenHTTP.Modules.Reflection.Operations; -public sealed class MethodRouting +public sealed class OperationPath { - private readonly string _PathExpression; - - private Regex? _ParsedPath; - - #region Initialization - - public MethodRouting(string pathExpression, bool isIndex, bool isWildcard) - { - _PathExpression = pathExpression; - - IsIndex = isIndex; - IsWildcard = isWildcard; - } - - #endregion #region Get-/Setters + /// + /// An user-friendly string to display this path. + /// + public string Name { get; } + /// /// The path of the method, converted into a regular /// expression to be evaluated at runtime. /// - public Regex ParsedPath => _ParsedPath ??= new Regex(_PathExpression, RegexOptions.Compiled); + public Regex Matcher { get; } /// /// True, if this route matches the index of the @@ -49,4 +38,16 @@ public MethodRouting(string pathExpression, bool isIndex, bool isWildcard) #endregion + #region Initialization + + public OperationPath(string name, Regex matcher, bool isIndex, bool isWildcard) + { + Matcher = matcher; + Name = name; + IsIndex = isIndex; + IsWildcard = isWildcard; + } + + #endregion + } diff --git a/Modules/Reflection/Operations/OperationResult.cs b/Modules/Reflection/Operations/OperationResult.cs new file mode 100644 index 00000000..1ec3ec0b --- /dev/null +++ b/Modules/Reflection/Operations/OperationResult.cs @@ -0,0 +1,69 @@ +using GenHTTP.Api.Content; +using GenHTTP.Modules.Conversion.Formatters; +using GenHTTP.Modules.Conversion.Serializers; + +namespace GenHTTP.Modules.Reflection.Operations; + +/// +/// Specifies how a result returned by an operation should get converted +/// into a HTTP response. +/// +public enum OperationResultSink +{ + + /// + /// The value should be formatted using a compatible , without serialization being applied. + /// + Formatter, + + /// + /// The value should get serialized to the response body, using a compatible . + /// + Serializer, + + /// + /// The value returned by the operation is a framework type that can generate a response on its own, such + /// as an . + /// + Dynamic, + + /// + /// The value represents a stream that should directly be passed to the client. + /// + Stream, + + /// + /// The operation does not return any value, resulting in a HTTP 204 response. + /// + None + +} + +public class OperationResult +{ + + #region Get-/Setters + + /// + /// The sink to be used to generate a HTTP response. + /// + public OperationResultSink Sink { get; } + + /// + /// The type of the result as declared by the .NET method. + /// + public Type Type { get; } + + #endregion + + #region Initialization + + public OperationResult(Type type, OperationResultSink sink) + { + Type = type; + Sink = sink; + } + + #endregion + +} diff --git a/Modules/Reflection/Operations/SignatureAnalyzer.cs b/Modules/Reflection/Operations/SignatureAnalyzer.cs new file mode 100644 index 00000000..0eb0de82 --- /dev/null +++ b/Modules/Reflection/Operations/SignatureAnalyzer.cs @@ -0,0 +1,153 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Modules.Reflection.Operations; + +public static class SignatureAnalyzer +{ + + public static Dictionary GetArguments(MethodInfo method, HashSet pathArguments, MethodRegistry registry) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var param in method.GetParameters()) + { + if (param.Name == null) + { + continue; + } + + if (pathArguments.Contains(param.Name)) + { + result.Add(param.Name, new OperationArgument(param.Name, param.ParameterType, OperationArgumentSource.Path)); + continue; + } + + if (TryInject(param, registry, out var injectedArg)) + { + result.Add(param.Name, injectedArg); + continue; + } + + if (TryStream(param, out var streamedArg)) + { + result.Add(param.Name, streamedArg); + continue; + } + + if (param.CanFormat(registry.Formatting)) + { + if (TryFromBody(param, out var bodyArg)) + { + result.Add(param.Name, bodyArg); + } + else + { + result.Add(param.Name, new OperationArgument(param.Name, param.ParameterType, OperationArgumentSource.Query)); + } + } + else + { + result.Add(param.Name, new OperationArgument(param.Name, param.ParameterType, OperationArgumentSource.Content)); + } + } + + if (pathArguments.Contains("path")) + { + result.Add("path", new OperationArgument("path", typeof(string), OperationArgumentSource.Path)); + } + + return result; + } + + private static bool TryStream(ParameterInfo param, [NotNullWhen(true)] out OperationArgument? argument) + { + if (param.ParameterType == typeof(Stream)) + { + argument = new OperationArgument(param.Name!, param.ParameterType, OperationArgumentSource.Streamed); + return true; + } + + argument = null; + return false; + } + + private static bool TryInject(ParameterInfo param, MethodRegistry registry, [NotNullWhen(true)] out OperationArgument? argument) + { + foreach (var injector in registry.Injection) + { + if (injector.Supports(param.ParameterType)) + { + argument = new OperationArgument(param.Name!, param.ParameterType, OperationArgumentSource.Injected); + return true; + } + } + + argument = null; + return false; + } + + private static bool TryFromBody(ParameterInfo param, [NotNullWhen(true)] out OperationArgument? argument) + { + var fromBody = param.GetCustomAttribute(); + + if (fromBody != null) + { + argument = new OperationArgument(param.Name!, param.ParameterType, OperationArgumentSource.Body); + return true; + } + + argument = null; + return false; + } + + public static OperationResult GetResult(MethodInfo method, MethodRegistry registry) + { + var type = FindActualType(method); + + if (type == null || type.FullName == "System.Void") + { + return new OperationResult(method.ReturnType, OperationResultSink.None); + } + + if (typeof(IHandler).IsAssignableFrom(type) || typeof(IHandlerBuilder).IsAssignableFrom(type) || typeof(IResponse).IsAssignableFrom(type) || typeof(IResponseBuilder).IsAssignableFrom(type)) + { + return new OperationResult(type, OperationResultSink.Dynamic); + } + + if (typeof(Stream).IsAssignableFrom(type)) + { + return new OperationResult(type, OperationResultSink.Stream); + } + + if (registry.Formatting.CanHandle(type)) + { + return new OperationResult(type, OperationResultSink.Formatter); + } + + return new OperationResult(type, OperationResultSink.Serializer); + } + + private static Type? FindActualType(MethodInfo method) + { + var type = method.ReturnType; + + if (type.IsAsyncGeneric()) + { + return type.IsGenericallyVoid() ? null : type.GenericTypeArguments[0]; + } + if (type == typeof(ValueTask) || type == typeof(Task)) + { + return null; + } + + if (typeof(IResultWrapper).IsAssignableFrom(type)) + { + return type.GenericTypeArguments[0]; + } + + return type; + } +} diff --git a/Modules/Reflection/PathArguments.cs b/Modules/Reflection/PathArguments.cs deleted file mode 100644 index c85f602a..00000000 --- a/Modules/Reflection/PathArguments.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; -using System.Text.RegularExpressions; -using GenHTTP.Api.Content; - -namespace GenHTTP.Modules.Reflection; - -public static partial class PathArguments -{ - private static readonly MethodRouting Empty = new("^(/|)$", true, false); - - private static readonly MethodRouting EmptyWildcard = new("^.*", true, true); - - private static readonly Regex VarPattern = CreateVarPattern(); - - [GeneratedRegex(@"\:([a-z]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] - private static partial Regex CreateVarPattern(); - - /// - /// Parses the given path and returns a routing structure - /// expected by the method provider to check which logic - /// to be executed on request. - /// - /// The path to be analyzed - /// If true, a route will be created that matches any sub path - /// The routing information to be used by the method provider - public static MethodRouting Route(string? path, bool wildcard = false) - { - if (path is not null) - { - var builder = new StringBuilder(path); - - if (builder[0] == '/') - { - builder.Remove(0, 1); - } - - if (builder.Length > 0 && builder[^1] == '/') - { - builder.Remove(builder.Length - 1, 1); - } - - // convert parameters of the format ":var" into appropriate groups - foreach (Match match in VarPattern.Matches(path)) - { - builder.Replace(match.Value, match.Groups[1].Value.ToParameter()); - } - - var end = wildcard ? "(/|)" : "(/|)$"; - - return new MethodRouting($"^/{builder}{end}", false, wildcard); - } - - return wildcard ? EmptyWildcard : Empty; - } - - /// - /// Checks, whether the given type ultimately returns a handler or handler builder, - /// so requests should passed to this handler which means that we allow any sub - /// routes here. - /// - /// The return type to be checked - /// true, if the given type will ultimately create an IHandler instance that should handle the request - public static bool CheckWildcardRoute(Type returnType) - { - if (IsHandlerType(returnType)) - { - return true; - } - - if (returnType.IsAsyncGeneric()) - { - if (returnType.GenericTypeArguments.Length == 1) - { - return IsHandlerType(returnType.GenericTypeArguments[0]); - } - } - - return false; - } - - private static bool IsHandlerType(Type returnType) => typeof(IHandlerBuilder).IsAssignableFrom(returnType) || typeof(IHandler).IsAssignableFrom(returnType); - -} diff --git a/Modules/Reflection/ResponseProvider.cs b/Modules/Reflection/ResponseProvider.cs index a371b984..c39f9209 100644 --- a/Modules/Reflection/ResponseProvider.cs +++ b/Modules/Reflection/ResponseProvider.cs @@ -1,10 +1,9 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; using GenHTTP.Modules.Basics; -using GenHTTP.Modules.Conversion.Formatters; -using GenHTTP.Modules.Conversion.Serializers; using GenHTTP.Modules.IO; using GenHTTP.Modules.IO.Streaming; +using GenHTTP.Modules.Reflection.Operations; namespace GenHTTP.Modules.Reflection; @@ -17,25 +16,27 @@ public class ResponseProvider #region Initialization - public ResponseProvider(SerializationRegistry serialization, FormatterRegistry formatting) + public ResponseProvider(MethodRegistry registry) { - Serialization = serialization; - Formatting = formatting; + Registry = registry; } #endregion + #region Get-/Setters + + private MethodRegistry Registry { get; } + + #endregion + #region Functionality - public async ValueTask GetResponseAsync(IRequest request, IHandler handler, object? result, Action? adjustments = null) + public async ValueTask GetResponseAsync(IRequest request, IHandler handler, Operation operation, object? result, Action? adjustments = null) { // no result = 204 if (result is null) { - return request.Respond() - .Status(ResponseStatus.NoContent) - .Adjust(adjustments) - .Build(); + return GetNoContent(request, adjustments); } var type = result.GetType(); @@ -45,10 +46,27 @@ public ResponseProvider(SerializationRegistry serialization, FormatterRegistry f { var wrapped = (IResultWrapper)result; - return await GetResponseAsync(request, handler, wrapped.Payload, b => wrapped.Apply(b)); + return await GetResponseAsync(request, handler, operation, wrapped.Payload, b => wrapped.Apply(b)); } - // response returned by the method + return operation.Result.Sink switch + { + OperationResultSink.Dynamic => await GetDynamicResponse(request, result, handler, adjustments), + OperationResultSink.Stream => GetDownloadResponse(request, (Stream)result, adjustments), + OperationResultSink.Formatter => GetFormattedResponse(request, result, type, adjustments), + OperationResultSink.Serializer => await GetSerializedResponse(request, result, adjustments), + OperationResultSink.None => GetNoContent(request, adjustments), + _ => throw new ProviderException(ResponseStatus.InternalServerError, $"Unsupported sink '{operation.Result.Sink}' for type '{operation.Result.Type}'") + }; + } + + private static IResponse GetNoContent(IRequest request, Action? adjustments) => request.Respond() + .Status(ResponseStatus.NoContent) + .Adjust(adjustments) + .Build(); + + private static async Task GetDynamicResponse(IRequest request, object result, IHandler handler, Action? adjustments) + { if (result is IResponseBuilder responseBuilder) { return responseBuilder.Adjust(adjustments).Build(); @@ -59,44 +77,40 @@ public ResponseProvider(SerializationRegistry serialization, FormatterRegistry f return response; } - // handler returned by the method if (result is IHandlerBuilder handlerBuilder) { return await handlerBuilder.Build(handler) - .HandleAsync(request) - ; + .HandleAsync(request); } if (result is IHandler resultHandler) { - return await resultHandler.HandleAsync(request) - ; + return await resultHandler.HandleAsync(request); } - // stream returned as a download - if (result is Stream download) - { - var downloadResponse = request.Respond() - .Content(download, () => download.CalculateChecksumAsync()) - .Type(ContentType.ApplicationForceDownload) - .Adjust(adjustments) - .Build(); + throw new ProviderException(ResponseStatus.InternalServerError, $"Unexpected return type '{result.GetType()}' to be processed by dynamic sink"); + } - return downloadResponse; - } + private static IResponse GetDownloadResponse(IRequest request, Stream download, Action? adjustments) + { + var downloadResponse = request.Respond() + .Content(download, download.CalculateChecksumAsync) + .Type(ContentType.ApplicationForceDownload) + .Adjust(adjustments) + .Build(); - // format the value if possible - if (Formatting.CanHandle(type)) - { - return request.Respond() - .Content(Formatting.Write(result, type) ?? string.Empty) - .Type(ContentType.TextPlain) - .Adjust(adjustments) - .Build(); - } + return downloadResponse; + } + + private IResponse GetFormattedResponse(IRequest request, object result, Type type, Action? adjustments) => request.Respond() + .Content(Registry.Formatting.Write(result, type) ?? string.Empty) + .Type(ContentType.TextPlain) + .Adjust(adjustments) + .Build(); - // serialize the result - var serializer = Serialization.GetSerialization(request); + private async ValueTask GetSerializedResponse(IRequest request, object result, Action? adjustments) + { + var serializer = Registry.Serialization.GetSerialization(request); if (serializer is null) { @@ -111,12 +125,4 @@ public ResponseProvider(SerializationRegistry serialization, FormatterRegistry f #endregion - #region Get-/Setters - - private SerializationRegistry Serialization { get; } - - private FormatterRegistry Formatting { get; } - - #endregion - } diff --git a/Modules/ReverseProxy/Provider/ReverseProxyProvider.cs b/Modules/ReverseProxy/Provider/ReverseProxyProvider.cs index d70d03c4..5bf61a50 100644 --- a/Modules/ReverseProxy/Provider/ReverseProxyProvider.cs +++ b/Modules/ReverseProxy/Provider/ReverseProxyProvider.cs @@ -255,7 +255,10 @@ private string RewriteLocation(string location, IRequest request) private static string GetForwardings(IRequest request) { return string.Join(", ", request.Forwardings - .Union(new[] { new Forwarding(request.LocalClient.IPAddress, request.LocalClient.Host, request.LocalClient.Protocol) }) + .Union(new[] + { + new Forwarding(request.LocalClient.IPAddress, request.LocalClient.Host, request.LocalClient.Protocol) + }) .Select(GetForwarding)); } diff --git a/Modules/Security/CorsPolicy.cs b/Modules/Security/CorsPolicy.cs index c41837b9..32005030 100644 --- a/Modules/Security/CorsPolicy.cs +++ b/Modules/Security/CorsPolicy.cs @@ -19,7 +19,10 @@ public static CorsPolicyBuilder Permissive(bool allowAuthorization = true) null, allowAuthorization ? new List - { "*", "Authorization" } + { + "*", + "Authorization" + } : null, null, true, diff --git a/Modules/ServerCaching/Provider/ServerCacheHandlerBuilder.cs b/Modules/ServerCaching/Provider/ServerCacheHandlerBuilder.cs index a812a7a9..aba68a8e 100644 --- a/Modules/ServerCaching/Provider/ServerCacheHandlerBuilder.cs +++ b/Modules/ServerCaching/Provider/ServerCacheHandlerBuilder.cs @@ -7,9 +7,9 @@ namespace GenHTTP.Modules.ServerCaching.Provider; public class ServerCacheHandlerBuilder : IConcernBuilder { - private bool _Invalidate = true; private ICache? _Data; + private bool _Invalidate = true; private ICache? _Meta; private Func? _Predicate; diff --git a/Modules/SinglePageApplications/Provider/SinglePageProvider.cs b/Modules/SinglePageApplications/Provider/SinglePageProvider.cs index c0327803..3ec442ed 100644 --- a/Modules/SinglePageApplications/Provider/SinglePageProvider.cs +++ b/Modules/SinglePageApplications/Provider/SinglePageProvider.cs @@ -9,7 +9,8 @@ public sealed class SinglePageProvider : IHandler { private static readonly HashSet IndexFiles = new(StringComparer.InvariantCultureIgnoreCase) { - "index.html", "index.htm" + "index.html", + "index.htm" }; private IHandler? _Index; diff --git a/Modules/SinglePageApplications/SinglePageApplication.cs b/Modules/SinglePageApplications/SinglePageApplication.cs index 624054be..9479302e 100644 --- a/Modules/SinglePageApplications/SinglePageApplication.cs +++ b/Modules/SinglePageApplications/SinglePageApplication.cs @@ -10,5 +10,4 @@ public static class SinglePageApplication public static SinglePageBuilder From(IBuilder tree) => From(tree.Build()); public static SinglePageBuilder From(IResourceTree tree) => new SinglePageBuilder().Tree(tree); - } diff --git a/Modules/StaticWebsites/StaticWebsite.cs b/Modules/StaticWebsites/StaticWebsite.cs index 31ebb4e0..1d8e2169 100644 --- a/Modules/StaticWebsites/StaticWebsite.cs +++ b/Modules/StaticWebsites/StaticWebsite.cs @@ -22,5 +22,4 @@ public static class StaticWebsite /// /// The resource to generate the application from public static StaticWebsiteBuilder From(IResourceTree tree) => new StaticWebsiteBuilder().Tree(tree); - } diff --git a/Modules/VirtualHosting/Provider/VirtualHostRouterBuilder.cs b/Modules/VirtualHosting/Provider/VirtualHostRouterBuilder.cs index bcea0a52..7accb14c 100644 --- a/Modules/VirtualHosting/Provider/VirtualHostRouterBuilder.cs +++ b/Modules/VirtualHosting/Provider/VirtualHostRouterBuilder.cs @@ -4,9 +4,9 @@ namespace GenHTTP.Modules.VirtualHosting.Provider; public sealed class VirtualHostRouterBuilder : IHandlerBuilder { - private readonly Dictionary _Hosts = []; private readonly List _Concerns = []; + private readonly Dictionary _Hosts = []; private IHandlerBuilder? _DefaultRoute; diff --git a/Modules/Webservices/Provider/ServiceResourceBuilder.cs b/Modules/Webservices/Provider/ServiceResourceBuilder.cs index a7867e14..5b2862b8 100644 --- a/Modules/Webservices/Provider/ServiceResourceBuilder.cs +++ b/Modules/Webservices/Provider/ServiceResourceBuilder.cs @@ -65,7 +65,9 @@ public IHandler Build(IHandler parent) var instance = _Instance ?? throw new BuilderMissingPropertyException("instance"); - return Concerns.Chain(parent, _Concerns, p => new ServiceResourceRouter(p, instance, serializers, injectors, formatters)); + var extensions = new MethodRegistry(serializers, injectors, formatters); + + return Concerns.Chain(parent, _Concerns, p => new ServiceResourceRouter(p, instance, extensions)); } #endregion diff --git a/Modules/Webservices/Provider/ServiceResourceRouter.cs b/Modules/Webservices/Provider/ServiceResourceRouter.cs index f41be591..9f71ff99 100644 --- a/Modules/Webservices/Provider/ServiceResourceRouter.cs +++ b/Modules/Webservices/Provider/ServiceResourceRouter.cs @@ -1,19 +1,17 @@ using System.Reflection; using GenHTTP.Api.Content; using GenHTTP.Api.Protocol; -using GenHTTP.Modules.Conversion.Formatters; -using GenHTTP.Modules.Conversion.Serializers; using GenHTTP.Modules.Reflection; -using GenHTTP.Modules.Reflection.Injectors; +using GenHTTP.Modules.Reflection.Operations; namespace GenHTTP.Modules.Webservices.Provider; -public sealed class ServiceResourceRouter : IHandler +public sealed class ServiceResourceRouter : IHandler, IServiceMethodProvider { #region Get-/Setters - private MethodCollection Methods { get; } + public MethodCollection Methods { get; } public IHandler Parent { get; } @@ -25,18 +23,18 @@ public sealed class ServiceResourceRouter : IHandler #region Initialization - public ServiceResourceRouter(IHandler parent, object instance, SerializationRegistry serialization, InjectionRegistry injection, FormatterRegistry formatting) + public ServiceResourceRouter(IHandler parent, object instance, MethodRegistry registry) { Parent = parent; Instance = instance; - ResponseProvider = new ResponseProvider(serialization, formatting); + ResponseProvider = new ResponseProvider(registry); - Methods = new MethodCollection(this, AnalyzeMethods(instance.GetType(), serialization, injection, formatting)); + Methods = new MethodCollection(this, AnalyzeMethods(instance.GetType(), registry)); } - private IEnumerable> AnalyzeMethods(Type type, SerializationRegistry serialization, InjectionRegistry injection, FormatterRegistry formatting) + private IEnumerable> AnalyzeMethods(Type type, MethodRegistry registry) { foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance)) { @@ -44,11 +42,9 @@ private IEnumerable> AnalyzeMethods(Type type, Ser if (attribute is not null) { - var wildcardRoute = PathArguments.CheckWildcardRoute(method.ReturnType); + var operation = OperationBuilder.Create(attribute.Path, method, registry); - var path = PathArguments.Route(attribute.Path, wildcardRoute); - - yield return parent => new MethodHandler(parent, method, path, () => Instance, attribute, ResponseProvider.GetResponseAsync, serialization, injection, formatting); + yield return parent => new MethodHandler(parent, operation, Instance, attribute, registry); } } } diff --git a/Playground/GenHTTP.Playground.csproj b/Playground/GenHTTP.Playground.csproj index 67ea1a5d..d4aa2d9f 100644 --- a/Playground/GenHTTP.Playground.csproj +++ b/Playground/GenHTTP.Playground.csproj @@ -31,6 +31,7 @@ + diff --git a/Playground/Program.cs b/Playground/Program.cs index 2753d48f..ebe6dea1 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,12 +1,52 @@ -using GenHTTP.Engine; -using GenHTTP.Modules.IO; +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Engine; +using GenHTTP.Modules.Basics; +using GenHTTP.Modules.Controllers; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.OpenApi; using GenHTTP.Modules.Practices; +using GenHTTP.Modules.Webservices; -var app = Content.From(Resource.FromString("Hello World")); +// todo bug: Inline.Get("/") => wirft fehler! + +var description = ApiDescription.Create() + .Title("My API") + .Version("1.0.0"); + +var inline = Inline.Create() + .Put("file", (Stream stream) => true) + .Add(description); Host.Create() - .Handler(app) + .Handler(inline) .Defaults() .Development() .Console() .Run(); + +public record User(int ID, string Name); + +public class UserService +{ + + [ResourceMethod] + public Stream Avatar(DateTime cannot, short s, byte b, bool b2) => new MemoryStream(); +} + +public class DeviceController +{ + + // [ControllerAction(RequestMethod.Post)] + public void Register(int id) + { + + } + + [ControllerAction(RequestMethod.Get)] + public IHandlerBuilder Wildcard() => Redirect.To("https://google.de"); + + [Obsolete] + [ControllerAction(RequestMethod.Get)] + public ValueTask GetUserAsync() => new(); +} diff --git a/Testing/Acceptance/Engine/CompressionTests.cs b/Testing/Acceptance/Engine/CompressionTests.cs index b73b3059..3ce699ab 100644 --- a/Testing/Acceptance/Engine/CompressionTests.cs +++ b/Testing/Acceptance/Engine/CompressionTests.cs @@ -38,7 +38,10 @@ public async Task TestCompression() [TestMethod] public async Task TestSpecificAlgorithms() { - foreach (var algorithm in new[] { "gzip", "br", "zstd" }) + foreach (var algorithm in new[] + { + "gzip", "br", "zstd" + }) { using var runner = TestHost.Run(Layout.Create()); @@ -163,5 +166,4 @@ private class CustomAlgorithm : ICompressionAlgorithm public IResponseContent Compress(IResponseContent content, CompressionLevel level) => content; } - } diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj index cbff65f8..3c70b9ff 100644 --- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj +++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj @@ -40,16 +40,19 @@ + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - @@ -83,6 +86,7 @@ + diff --git a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs index 5daac307..b271d965 100644 --- a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs +++ b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs @@ -57,7 +57,9 @@ public async Task TestUser() { var auth = BearerAuthentication.Create() .UserMapping((_, _) => new ValueTask(new MyUser - { DisplayName = "User Name" })) + { + DisplayName = "User Name" + })) .AllowExpired(); using var response = await Execute(auth, ValidToken); diff --git a/Testing/Acceptance/Modules/Caching/CacheTests.cs b/Testing/Acceptance/Modules/Caching/CacheTests.cs index cf2fab90..7945a94f 100644 --- a/Testing/Acceptance/Modules/Caching/CacheTests.cs +++ b/Testing/Acceptance/Modules/Caching/CacheTests.cs @@ -104,7 +104,10 @@ public async Task TestDirectStreaming() { foreach (var cache in GetCaches()) { - await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] { 1 })); + await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] + { + 1 + })); Assert.AreEqual(1, (await cache.GetEntriesAsync("k")).Length); @@ -119,9 +122,15 @@ public async Task TestDirectStreamingOverwrite() { foreach (var cache in GetCaches()) { - await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] { 1 })); + await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] + { + 1 + })); - await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] { 1 })); + await cache.StoreDirectAsync("k", "v", s => s.WriteAsync(new byte[] + { + 1 + })); Assert.AreEqual(1, (await cache.GetEntriesAsync("k")).Length); } @@ -138,12 +147,9 @@ public async Task TestStreamingMiss() } } - private static ICache[] GetCaches() - { - return - [ - Cache.Memory().Build(), - Cache.TemporaryFiles().Build() - ]; - } + private static ICache[] GetCaches() => + [ + Cache.Memory().Build(), + Cache.TemporaryFiles().Build() + ]; } diff --git a/Testing/Acceptance/Modules/Controllers/ActionTests.cs b/Testing/Acceptance/Modules/Controllers/ActionTests.cs index 6178c4b2..d08c2a9f 100644 --- a/Testing/Acceptance/Modules/Controllers/ActionTests.cs +++ b/Testing/Acceptance/Modules/Controllers/ActionTests.cs @@ -30,7 +30,7 @@ public sealed class Model public sealed class TestController { - public IHandlerBuilder Index() => Content.From(Resource.FromString("Hello World!")); + public string Index() => "Hello World!"; public IHandlerBuilder Action(int? query) => Content.From(Resource.FromString(query?.ToString() ?? "Action")); @@ -93,7 +93,9 @@ public async Task TestActionWithQueryFromBody() var dict = new Dictionary { - { "value2", "test" } + { + "value2", "test" + } }; var request = runner.GetRequest("/t/action/"); diff --git a/Testing/Acceptance/Modules/Controllers/DataTests.cs b/Testing/Acceptance/Modules/Controllers/DataTests.cs index 2516eb15..01dd3243 100644 --- a/Testing/Acceptance/Modules/Controllers/DataTests.cs +++ b/Testing/Acceptance/Modules/Controllers/DataTests.cs @@ -48,7 +48,9 @@ public async Task TestDateOnly() var data = new Dictionary { - { "date", "2024-03-11" } + { + "date", "2024-03-11" + } }; request.Content = new FormUrlEncodedContent(data); @@ -69,7 +71,9 @@ public async Task TestInvalidDateOnly() var data = new Dictionary { - { "date", "ABC" } + { + "date", "ABC" + } }; request.Content = new FormUrlEncodedContent(data); diff --git a/Testing/Acceptance/Modules/Controllers/ErrorHandlingTests.cs b/Testing/Acceptance/Modules/Controllers/ErrorHandlingTests.cs deleted file mode 100644 index 5ee4d569..00000000 --- a/Testing/Acceptance/Modules/Controllers/ErrorHandlingTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using GenHTTP.Modules.Controllers; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace GenHTTP.Testing.Acceptance.Modules.Controllers; - -[TestClass] -public sealed class ErrorHandlingTests -{ - - #region Supporting data structures - - public sealed class ControllerWithNullablePath - { - - public int Test([FromPath] int? id) => 42; - } - - public sealed class ComplexPath; - - public sealed class ControllerWithComplexPath - { - - public int Test([FromPath] ComplexPath value) => 42; - } - - #endregion - - #region Tests - - [TestMethod] - public void TestNoNullablePathArguments() - { - Assert.ThrowsException(() => - { - var controller = Controller.From(); - using var _ = TestHost.Run(controller); - }); - } - - [TestMethod] - public void TestNoComplexPathArguments() - { - Assert.ThrowsException(() => - { - var controller = Controller.From(); - using var _ = TestHost.Run(controller); - }); - } - - #endregion - -} diff --git a/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs b/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs index 5d2cdeae..b72831f8 100644 --- a/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs +++ b/Testing/Acceptance/Modules/IO/VirtualTreeTests.cs @@ -62,5 +62,4 @@ public async Task TestUsage() } private static RoutingTarget GetTarget(string path) => new(WebPath.FromString(path)); - } diff --git a/Testing/Acceptance/Modules/LayoutTests.cs b/Testing/Acceptance/Modules/LayoutTests.cs index 206fcb3f..49283a36 100644 --- a/Testing/Acceptance/Modules/LayoutTests.cs +++ b/Testing/Acceptance/Modules/LayoutTests.cs @@ -40,7 +40,10 @@ public async Task TestDefaultContent() using var runner = TestHost.Run(layout); - foreach (var path in new[] { "/something", "/" }) + foreach (var path in new[] + { + "/something", "/" + }) { using var response = await runner.GetResponseAsync(path); diff --git a/Testing/Acceptance/Modules/OpenApi/ArgumentTests.cs b/Testing/Acceptance/Modules/OpenApi/ArgumentTests.cs new file mode 100644 index 00000000..03691037 --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/ArgumentTests.cs @@ -0,0 +1,106 @@ +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.Reflection; + +using Microsoft.OpenApi.Models; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class ArgumentTests +{ + + [TestMethod] + public async Task TestInjectionCausesNoArgument() + { + var api = Inline.Create() + .Get((IRequest request) => request.Host); + + var (_, op) = await Extensions.GetOperationAsync(api); + + AssertX.Empty(op.Parameters); + } + + [TestMethod] + public async Task TestPathParam() + { + var api = Inline.Create() + .Get("/users/:id", (int id) => id); + + var (path, operation) = await Extensions.GetOperationAsync(api); + + Assert.AreEqual("/users/{id}", path.Item1); + AssertParameter(operation, "id", ParameterLocation.Path); + } + + [TestMethod] + public async Task TestQueryParam() + { + var api = Inline.Create() + .Get("/users/", (int id) => id); + + var (path, operation) = await Extensions.GetOperationAsync(api); + + Assert.AreEqual("/users/", path.Item1); + AssertParameter(operation, "id", ParameterLocation.Query); + } + + [TestMethod] + public async Task TestBodyParam() + { + var api = Inline.Create() + .Post("/users/filter", (HashSet items) => items.Count); + + var (path, operation) = await Extensions.GetOperationAsync(api); + + Assert.AreEqual("/users/filter", path.Item1); + + Assert.IsTrue(operation.RequestBody.Content.ContainsKey("application/json")); + Assert.IsTrue(operation.RequestBody.Content.ContainsKey("text/xml")); + } + + [TestMethod] + public async Task TestContentParam() + { + var api = Inline.Create() + .Post("/users/filter", ([FromBody] DateOnly date) => date); + + var (path, operation) = await Extensions.GetOperationAsync(api); + + Assert.AreEqual("/users/filter", path.Item1); + + Assert.IsTrue(operation.RequestBody.Content.ContainsKey("text/plain")); + } + + [TestMethod] + public async Task TestStreamParam() + { + var api = Inline.Create() + .Put("/users/avatar", (Stream file) => true); + + var (path, operation) = await Extensions.GetOperationAsync(api); + + Assert.AreEqual("/users/avatar", path.Item1); + + Assert.IsTrue(operation.RequestBody.Content.ContainsKey("*/*")); + Assert.AreEqual("binary", operation.RequestBody.Content["*/*"].Schema.Format); + } + + #region Helpers + + private static void AssertParameter(OpenApiOperation operation, string name, ParameterLocation location) + { + Assert.AreEqual(1, operation.Parameters.Count); + + var param = operation.Parameters.First(); + + Assert.AreEqual(name, param.Name); + Assert.AreEqual(location, param.In); + } + + #endregion + +} diff --git a/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs b/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs new file mode 100644 index 00000000..d6c76578 --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/DiscoveryTests.cs @@ -0,0 +1,83 @@ +using GenHTTP.Api.Content; + +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.IO; +using GenHTTP.Modules.Layouting; +using GenHTTP.Modules.OpenApi; +using GenHTTP.Modules.OpenApi.Discovery; +using GenHTTP.Modules.OpenApi.Handler; + +using Microsoft.OpenApi.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using OpenApiDocument = NSwag.OpenApiDocument; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class DiscoveryTests +{ + + [TestMethod] + public async Task TestTraversal() + { + var api = Layout.Create() + .Add("service", Layout.Create().Add(Inline.Create().Get("/method", () => 1))) + .Add(Inline.Create().Get("/method2", () => 2)) + .AddRangeSupport() + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.IsTrue(doc.Paths.ContainsKey("/service/method")); + Assert.IsTrue(doc.Paths.ContainsKey("/method2")); + } + + [TestMethod] + public async Task TestCustomExplorer() + { + var discovery = ApiDiscovery.Empty().Add(); + + var api = Inline.Create().Add(ApiDescription.With(discovery)); + + var doc = (await api.GetOpenApiAsync(false)).OpenApiDocument; + + Assert.AreEqual("Added by explorer", doc.Servers.First().Description); + } + + [TestMethod] + public async Task TestSamePathWithDifferentMethods() + { + var api = Inline.Create() + .Get("/method", () => 42) + .Put("/method", (int i) => i) + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.AreEqual(1, doc.Paths.Count); + + var operations = doc.Paths.First().Value.Operations; + + Assert.AreEqual(2, operations.Count); + + Assert.IsTrue(operations.ContainsKey(OperationType.Get)); + Assert.IsTrue(operations.ContainsKey(OperationType.Put)); + } + + #region Supporting data structures + + public class CustomExplorer : IApiExplorer + { + + public bool CanExplore(IHandler handler) => true; + + public void Explore(IHandler handler, List path, OpenApiDocument document, SchemaManager schemata, ApiDiscoveryRegistry registry) + { + document.Servers.First().Description = "Added by explorer"; + } + } + + #endregion + +} diff --git a/Testing/Acceptance/Modules/OpenApi/Extensions.cs b/Testing/Acceptance/Modules/OpenApi/Extensions.cs new file mode 100644 index 00000000..2f0f0efa --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/Extensions.cs @@ -0,0 +1,47 @@ +using System.Net; +using GenHTTP.Api.Content; +using GenHTTP.Modules.Functional.Provider; +using GenHTTP.Modules.OpenApi; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +internal static class Extensions +{ + + internal static async Task<((string, OpenApiPathItem), OpenApiOperation)> GetOperationAsync(InlineBuilder api) + { + var doc = (await api.Add(ApiDescription.Create()).GetOpenApiAsync()).OpenApiDocument; + + var path = doc.Paths.First(); + + return ((path.Key, path.Value), path.Value.Operations.First().Value); + } + + internal static async Task AsOpenApiAsync(this HttpResponseMessage response) + { + await response.AssertStatusAsync(HttpStatusCode.OK); + + await using var content = await response.Content.ReadAsStreamAsync(); + + return await new OpenApiStreamReader().ReadAsync(content); + } + + internal static async Task GetOpenApiAsync(this IHandlerBuilder api, bool validate = true) + { + using var host = TestHost.Run(api); + + using var response = await host.GetResponseAsync("/openapi.json"); + + var result = await response.AsOpenApiAsync(); + + if (validate) + { + AssertX.Empty(result.OpenApiDiagnostic.Errors); + AssertX.Empty(result.OpenApiDiagnostic.Warnings); + } + + return result; + } +} diff --git a/Testing/Acceptance/Modules/OpenApi/FormatTests.cs b/Testing/Acceptance/Modules/OpenApi/FormatTests.cs new file mode 100644 index 00000000..83506a5d --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/FormatTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.Functional.Provider; +using GenHTTP.Modules.OpenApi; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class FormatTests +{ + + [TestMethod] + public Task TestJsonFile() => TestApi("/openapi.json", "application/json"); + + [TestMethod] + public Task TestJsonFileUpper() => TestApi("/OPENAPI.JSON", "application/json"); + + [TestMethod] + public Task TestJsonFallback() => TestApi("/openapi", "application/json"); + + [TestMethod] + public Task TestYamlFile() => TestApi("/openapi.yaml", "application/yaml"); + + [TestMethod] + public Task TestYmlFile() => TestApi("/openapi.yml", "application/yaml"); + + [TestMethod] + public Task TestJsonByAccept() => TestApi("/openapi", accept: "application/json"); + + [TestMethod] + public Task TestJsonByAcceptUpper() => TestApi("/OPENAPI", accept: "application/json"); + + [TestMethod] + public Task TestYamlByAccept() => TestApi("/openapi", accept: "application/yaml"); + + [TestMethod] + public async Task TestUnsupportedFormat() + { + using var host = TestHost.Run(GetApi()); + + var request = host.GetRequest("/openapi"); + + request.Headers.Add("Accept", "text/plain"); + + using var response = await host.GetResponseAsync(request); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + private static async Task TestApi(string path, string? expectedContentType = null, string? accept = null) + { + + using var host = TestHost.Run(GetApi()); + + var request = host.GetRequest(path); + + if (accept != null) + { + request.Headers.Add("Accept", accept); + } + + using var response = await host.GetResponseAsync(request); + + Assert.AreEqual(expectedContentType ?? accept, response.GetContentHeader("Content-Type")); + + await response.AsOpenApiAsync(); + } + + private static InlineBuilder GetApi() + { + return Inline.Create() + .Get(() => "Hello World") + .Add(ApiDescription.Create()); + } +} diff --git a/Testing/Acceptance/Modules/OpenApi/InfrastructureTests.cs b/Testing/Acceptance/Modules/OpenApi/InfrastructureTests.cs new file mode 100644 index 00000000..1020d958 --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/InfrastructureTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.OpenApi; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class InfrastructureTests +{ + + [TestMethod] + public async Task TestContentIsPassed() + { + var api = Inline.Create() + .Get("/some-path", () => "Hello World") + .Add(ApiDescription.Create()); + + using var host = TestHost.Run(api); + + using var response = await host.GetResponseAsync("/some-path"); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Hello World", await response.GetContentAsync()); + } + + [TestMethod] + public async Task TestCaching() => Assert.AreEqual(1, await RunCachingTest(true)); + + [TestMethod] + public async Task TestNoCaching() => Assert.AreEqual(2, await RunCachingTest(false)); + + private async Task RunCachingTest(bool cacheEnabled) + { + var counter = 0; + + var description = ApiDescription.Create().Caching(cacheEnabled).PostProcessor((_, _) => counter++); + + var api = Inline.Create() + .Get(() => "Hello World") + .Add(description); + + using var host = TestHost.Run(api); + + await host.GetResponseAsync("/openapi.json"); + await host.GetResponseAsync("/openapi.json"); + + return counter; + } +} diff --git a/Testing/Acceptance/Modules/OpenApi/IntegrationTests.cs b/Testing/Acceptance/Modules/OpenApi/IntegrationTests.cs new file mode 100644 index 00000000..68131c4a --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/IntegrationTests.cs @@ -0,0 +1,108 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Controllers; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.Layouting; +using GenHTTP.Modules.OpenApi; +using GenHTTP.Modules.Webservices; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class IntegrationTests +{ + + [TestMethod] + public async Task TestWebserviceSupported() + { + var api = Layout.Create() + .AddService("my") + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.AreEqual("/my/", doc.Paths.First().Key); + } + + [TestMethod] + public async Task TestControllerSupported() + { + var api = Layout.Create() + .AddController("my") + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.AreEqual("/my/method/", doc.Paths.First().Key); + } + + [TestMethod] + public async Task TestInlineSupported() + { + var api = Inline.Create() + .Get("/method", () => 42) + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.AreEqual("/method", doc.Paths.First().Key); + } + + [TestMethod] + public async Task TestObsoleteOperation() + { + var api = Layout.Create() + .AddService("my") + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.IsTrue(doc.Paths.First().Value.Operations.First().Value.Deprecated); + } + + [TestMethod] + public async Task TestControllerWithMultipleMethods() + { + var api = Layout.Create() + .AddController("my") + .Add(ApiDescription.Create()); + + var doc = (await api.GetOpenApiAsync()).OpenApiDocument; + + Assert.AreEqual(2, doc.Paths["/my/method/"].Operations.Count); + } + + #region Supporting data structures + + public class MyService + { + + [ResourceMethod] + public int Method() => 42; + } + + public class ObsoleteService + { + + [Obsolete("Don't use this anymore")] + [ResourceMethod("obsolete")] + public int Obsolete() => 43; + } + + public class MyController + { + + [ControllerAction(RequestMethod.Get)] + public int Method() => 42; + } + + public class MultipleMethodsController + { + + [ControllerAction(RequestMethod.Get, RequestMethod.Options)] + public int Method() => 42; + } + + #endregion + +} diff --git a/Testing/Acceptance/Modules/OpenApi/MetaDataTests.cs b/Testing/Acceptance/Modules/OpenApi/MetaDataTests.cs new file mode 100644 index 00000000..be4b4a08 --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/MetaDataTests.cs @@ -0,0 +1,62 @@ +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.Functional.Provider; +using GenHTTP.Modules.OpenApi; +using GenHTTP.Modules.OpenApi.Handler; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class MetaDataTests +{ + + [TestMethod] + public async Task TestDefaultTitleAndVersion() + { + var doc = await GetApi(ApiDescription.Create()).GetOpenApiAsync(); + + Assert.AreEqual("Swagger specification", doc.OpenApiDocument.Info.Title); + Assert.AreEqual("1.0.0", doc.OpenApiDocument.Info.Version); + } + + [TestMethod] + public async Task TestCustomTitleAndVersion() + { + var desc = ApiDescription.Create() + .Title("My Title") + .Version("2.0.1"); + + var doc = await GetApi(desc).GetOpenApiAsync(); + + Assert.AreEqual("My Title", doc.OpenApiDocument.Info.Title); + Assert.AreEqual("2.0.1", doc.OpenApiDocument.Info.Version); + } + + [TestMethod] + public async Task TestServerGenerated() + { + var doc = await GetApi(ApiDescription.Create()).GetOpenApiAsync(); + + var server = doc.OpenApiDocument.Servers.First(); + + AssertX.Contains("http://localhost:", server.Url); + } + + [TestMethod] + public async Task TestPostProcessing() + { + var desc = ApiDescription.Create() + .PostProcessor((_, doc) => doc.Servers.First().Url = "https://google.de/"); + + var doc = await GetApi(desc).GetOpenApiAsync(); + + Assert.AreEqual("https://google.de/", doc.OpenApiDocument.Servers.First().Url); + } + + private static InlineBuilder GetApi(OpenApiConcernBuilder description) + { + return Inline.Create() + .Get(() => "Hello World") + .Add(description); + } +} diff --git a/Testing/Acceptance/Modules/OpenApi/ResponseTests.cs b/Testing/Acceptance/Modules/OpenApi/ResponseTests.cs new file mode 100644 index 00000000..f2b46f97 --- /dev/null +++ b/Testing/Acceptance/Modules/OpenApi/ResponseTests.cs @@ -0,0 +1,82 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Basics; +using GenHTTP.Modules.Functional; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.OpenApi; + +[TestClass] +public class ResponseTests +{ + + [TestMethod] + public async Task TestFormatted() + { + var api = Inline.Create().Get(() => 42); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsTrue(op.Responses.ContainsKey("200")); + Assert.IsTrue(op.Responses["200"].Content.ContainsKey("text/plain")); + } + + [TestMethod] + public async Task TestFormattedNullable() + { + var api = Inline.Create().Get(() => (int?)42); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsTrue(op.Responses.ContainsKey("204")); + } + + [TestMethod] + public async Task TestStream() + { + var api = Inline.Create().Get(() => new MemoryStream()); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsTrue(op.Responses.ContainsKey("200")); + Assert.IsTrue(op.Responses["200"].Content.ContainsKey("application/octet-stream")); + Assert.AreEqual("binary", op.Responses["200"].Content["application/octet-stream"].Schema.Format); + } + + [TestMethod] + public async Task TestNone() + { + var api = Inline.Create().Get(() => { }); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsFalse(op.Responses.ContainsKey("200")); + Assert.IsTrue(op.Responses.ContainsKey("204")); + } + + [TestMethod] + public async Task TestDynamic() + { + var api = Inline.Create() + .Get("h", (IHandler parent) => Redirect.To("https://google.de").Build(parent)) + .Get("hb", () => Redirect.To("https://google.de")) + .Get("r", (IRequest request) => request.Respond().Build()) + .Get("rb", (IRequest request) => request.Respond()); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsTrue(op.Responses.ContainsKey("200")); + Assert.IsTrue(op.Responses["200"].Content.ContainsKey("*/*")); + } + + [TestMethod] + public async Task TestSerialized() + { + var api = Inline.Create().Get(() => new HashSet()); + + var (_, op) = await Extensions.GetOperationAsync(api); + + Assert.IsTrue(op.Responses.ContainsKey("200")); + Assert.IsTrue(op.Responses["200"].Content.ContainsKey("application/json")); + } +} diff --git a/Testing/Acceptance/Modules/ProtobufTests.cs b/Testing/Acceptance/Modules/ProtobufTests.cs index 6120e2bc..3c73b45c 100644 --- a/Testing/Acceptance/Modules/ProtobufTests.cs +++ b/Testing/Acceptance/Modules/ProtobufTests.cs @@ -46,7 +46,6 @@ public TestEntity GetEntity() [ResourceMethod(RequestMethod.Post)] public TestEntity PostEntity(TestEntity entity) => entity; - } #endregion diff --git a/Testing/Acceptance/Modules/Reflection/ErrorHandlingTests.cs b/Testing/Acceptance/Modules/Reflection/ErrorHandlingTests.cs new file mode 100644 index 00000000..c7cb87ec --- /dev/null +++ b/Testing/Acceptance/Modules/Reflection/ErrorHandlingTests.cs @@ -0,0 +1,29 @@ +using System.Net; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Conversion; +using GenHTTP.Modules.Functional; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.Reflection; + +[TestClass] +public class ErrorHandlingTests +{ + + [TestMethod] + public async Task TestSerializationNotPossible() + { + var serialization = Serialization.Empty() + .Default(ContentType.AudioMp4); + + var api = Inline.Create() + .Get(() => new HashSet()) + .Serializers(serialization); + + using var host = TestHost.Run(api); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.UnsupportedMediaType); + } +} diff --git a/Testing/Acceptance/Modules/Reflection/ResultTests.cs b/Testing/Acceptance/Modules/Reflection/ResultTests.cs index f510a7e2..b44b8697 100644 --- a/Testing/Acceptance/Modules/Reflection/ResultTests.cs +++ b/Testing/Acceptance/Modules/Reflection/ResultTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text; using GenHTTP.Api.Protocol; using GenHTTP.Modules.Functional; using GenHTTP.Modules.Reflection; diff --git a/Testing/Acceptance/Modules/Security/CorsTests.cs b/Testing/Acceptance/Modules/Security/CorsTests.cs index 74d973d5..076e5505 100644 --- a/Testing/Acceptance/Modules/Security/CorsTests.cs +++ b/Testing/Acceptance/Modules/Security/CorsTests.cs @@ -93,8 +93,12 @@ public async Task TestCustom() { var policy = CorsPolicy.Restrictive() .Add("http://google.de", new List - { new(RequestMethod.Get) }, null, new List - { "Accept" }, false); + { + new(RequestMethod.Get) + }, null, new List + { + "Accept" + }, false); using var runner = GetRunner(policy); diff --git a/Testing/Acceptance/TestExtensions.cs b/Testing/Acceptance/TestExtensions.cs index 65674533..1c664932 100644 --- a/Testing/Acceptance/TestExtensions.cs +++ b/Testing/Acceptance/TestExtensions.cs @@ -11,5 +11,4 @@ public static class TestExtensions public static string? GetETag(this HttpResponseMessage response) => response.GetHeader("ETag"); public static DateTime WithoutMs(this DateTime date) => new(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second, date.Kind); - } diff --git a/Testing/Acceptance/Testing/ContentTests.cs b/Testing/Acceptance/Testing/ContentTests.cs index 4e507892..521b653e 100644 --- a/Testing/Acceptance/Testing/ContentTests.cs +++ b/Testing/Acceptance/Testing/ContentTests.cs @@ -54,5 +54,4 @@ await Assert.ThrowsExceptionAsync(async () => } public record MyType(int Id); - } diff --git a/Testing/Acceptance/Utilities/IHandlerWithParent.cs b/Testing/Acceptance/Utilities/IHandlerWithParent.cs index 01326a2a..bded6efc 100644 --- a/Testing/Acceptance/Utilities/IHandlerWithParent.cs +++ b/Testing/Acceptance/Utilities/IHandlerWithParent.cs @@ -6,5 +6,4 @@ public interface IHandlerWithParent : IHandler { public new IHandler Parent { get; set; } - }